367 lines
9.2 KiB
Go
367 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
)
|
|
|
|
// original /api/telemetry type struct
|
|
type MetricsV1 struct {
|
|
TempNozzle int `json:"temp_nozzle"`
|
|
TempBed int `json:"temp_bed"`
|
|
Material string `json:"material"`
|
|
ZPosition float64 `json:"pos_z_mm"`
|
|
PrintingSpeed int `json:"printing_speed"`
|
|
FlowFactor int `json:"flow_factor"`
|
|
Progress int `json:"progress"`
|
|
PrintDur string `json:"print_dur"`
|
|
TimeEst string `json:"time_est"`
|
|
TimeZone string `json:"time_zone"`
|
|
ProjectName string `json:"project_name"`
|
|
}
|
|
|
|
// new 4.4.0+ /api/printer struct
|
|
type MetricsV2 struct {
|
|
Telemetry Telemetry `json:"telemetry"`
|
|
Temperature Temperature `json:"temperature"`
|
|
State State `json:"state"`
|
|
}
|
|
|
|
type Telemetry struct {
|
|
TempBed float64 `json:"temp-bed"`
|
|
TempNozzle float64 `json:"temp-nozzle"`
|
|
PrintSpeed int `json:"print-speed"`
|
|
ZHeight float64 `json:"z-height"`
|
|
Material string `json:"material"`
|
|
}
|
|
|
|
type Temperature struct {
|
|
Tool0 Tempatures `json:"tool0"`
|
|
Bed Tempatures `json:"bed"`
|
|
}
|
|
|
|
type Tempatures struct {
|
|
Actual float64 `json:"actual"`
|
|
Target float64 `json:"target"`
|
|
Display float64 `json:"display"`
|
|
Offset float64 `json:"offset"`
|
|
}
|
|
|
|
type State struct {
|
|
Text string `json:"text"`
|
|
Flags Flags `json:"flags"`
|
|
}
|
|
|
|
type Flags struct {
|
|
Operational bool `json:"operational"`
|
|
Paused bool `json:"paused"`
|
|
Printing bool `json:"printing"`
|
|
Cancelling bool `json:"cancelling"`
|
|
Pausing bool `json:"pausing"`
|
|
SdReady bool `json:"sdReady"`
|
|
Error bool `json:"error"`
|
|
ClosedOnError bool `json:"closedOnError"`
|
|
Ready bool `json:"ready"`
|
|
Busy bool `json:"busy"`
|
|
}
|
|
|
|
// /api/job struct
|
|
type JobV2 struct {
|
|
State string `json:"state"`
|
|
Job Job `json:"job"`
|
|
Progress Progress `json:"progress"`
|
|
}
|
|
|
|
type File struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Display string `json:"display"`
|
|
}
|
|
|
|
type Job struct {
|
|
EstimatedPrintTime int `json:"estimatedPrintTime"`
|
|
File File `json:"file"`
|
|
}
|
|
|
|
type Progress struct {
|
|
Completion float64 `json:"completion"`
|
|
PrintTime int `json:"printTime"`
|
|
PrintTimeLeft int `json:"printTimeLeft"`
|
|
}
|
|
|
|
type Config struct {
|
|
QueryHostname string
|
|
QueryInterval int
|
|
Port string
|
|
Path string
|
|
APIKey string
|
|
}
|
|
|
|
var (
|
|
errCount = 0
|
|
gauges = map[string]prometheus.Gauge{
|
|
"flow_factor": prometheus.NewGauge(prometheus.GaugeOpts{
|
|
Namespace: "prusa_connect",
|
|
Name: "flow_factor",
|
|
Help: "Current flow factor, as a unitless number",
|
|
}),
|
|
"printing_speed": prometheus.NewGauge(prometheus.GaugeOpts{
|
|
Namespace: "prusa_connect",
|
|
Name: "printing_speed",
|
|
Help: "Current print speed, as a percentage",
|
|
}),
|
|
"z_position": prometheus.NewGauge(prometheus.GaugeOpts{
|
|
Namespace: "prusa_connect",
|
|
Name: "z_position",
|
|
Help: "Vertical depth, in mm, of the Z head",
|
|
}),
|
|
"temp_bed": prometheus.NewGauge(prometheus.GaugeOpts{
|
|
Namespace: "prusa_connect",
|
|
Name: "temp_bed",
|
|
Help: "Temperature, in celsius, of the print bed",
|
|
}),
|
|
"temp_nozzle": prometheus.NewGauge(prometheus.GaugeOpts{
|
|
Namespace: "prusa_connect",
|
|
Name: "temp_nozzle",
|
|
Help: "Temperature, in celsius, of the print nozzle",
|
|
}),
|
|
"progress": prometheus.NewGauge(prometheus.GaugeOpts{
|
|
Namespace: "prusa_connect",
|
|
Name: "progress",
|
|
Help: "Current print completeness, as a percentage",
|
|
}),
|
|
"duration": prometheus.NewGauge(prometheus.GaugeOpts{
|
|
Namespace: "prusa_connect",
|
|
Name: "duration",
|
|
Help: "Duration of current print job, as seconds since start",
|
|
}),
|
|
"remaining": prometheus.NewGauge(prometheus.GaugeOpts{
|
|
Namespace: "prusa_connect",
|
|
Name: "remaining",
|
|
Help: "Estimated remaining time of current print job, as seconds",
|
|
}),
|
|
}
|
|
)
|
|
|
|
func errLog(err error) {
|
|
errCount++
|
|
// reset metrics
|
|
for _, g := range gauges {
|
|
g.Set(0)
|
|
}
|
|
if errCount == 5 {
|
|
log.Printf("suppressing further error logging")
|
|
return
|
|
} else if errCount > 5 {
|
|
return
|
|
}
|
|
log.Printf("error retrieving telemetry: %v", err)
|
|
}
|
|
|
|
func recordMetrics(ctx context.Context, config Config) {
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Print("exiting!")
|
|
return
|
|
default:
|
|
time.Sleep(time.Second * time.Duration(config.QueryInterval))
|
|
if config.APIKey == "" {
|
|
parseMetricsV1(ctx, config)
|
|
break
|
|
}
|
|
parseMetricsV2(ctx, config)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func parseMetricsV1(ctx context.Context, config Config) {
|
|
t, err := getTelemetry(config.QueryHostname)
|
|
if err != nil {
|
|
errLog(err)
|
|
return
|
|
}
|
|
if errCount != 0 {
|
|
errCount = 0
|
|
log.Printf("connection established")
|
|
}
|
|
gauges["flow_factor"].Set(float64(t.FlowFactor))
|
|
gauges["printing_speed"].Set(float64(t.PrintingSpeed))
|
|
gauges["z_position"].Set(t.ZPosition)
|
|
gauges["temp_bed"].Set(float64(t.TempBed))
|
|
gauges["temp_nozzle"].Set(float64(t.TempNozzle))
|
|
gauges["progress"].Set(float64(t.Progress))
|
|
gauges["duration"].Set(float64(parseDuration(t.PrintDur) / time.Second))
|
|
estimatedTimeRemaining, err := strconv.ParseFloat(t.TimeEst, 64)
|
|
if err == nil {
|
|
gauges["remaining"].Set(float64(estimatedTimeRemaining))
|
|
}
|
|
}
|
|
|
|
func parseMetricsV2(ctx context.Context, config Config) {
|
|
t, err := getPrinter(config.Port, config.APIKey)
|
|
if err != nil {
|
|
errLog(err)
|
|
return
|
|
}
|
|
j, err := getJob(config.Port, config.APIKey)
|
|
if err != nil {
|
|
errLog(err)
|
|
return
|
|
}
|
|
if errCount != 0 {
|
|
errCount = 0
|
|
log.Printf("connection established")
|
|
}
|
|
gauges["printing_speed"].Set(float64(t.Telemetry.PrintSpeed))
|
|
gauges["z_position"].Set(t.Telemetry.ZHeight)
|
|
gauges["temp_bed"].Set(float64(t.Telemetry.TempBed))
|
|
gauges["temp_nozzle"].Set(float64(t.Telemetry.TempNozzle))
|
|
gauges["progress"].Set(float64(j.Progress.Completion) * 100)
|
|
gauges["duration"].Set(float64(j.Progress.PrintTime))
|
|
gauges["remaining"].Set(float64(j.Progress.PrintTimeLeft))
|
|
}
|
|
|
|
func getJob(hostname, apiKey string) (*JobV2, error) {
|
|
r, err := http.NewRequest(http.MethodGet, hostname+"/api/job", nil)
|
|
if err != nil {
|
|
return &JobV2{}, err
|
|
}
|
|
r.Header.Set("X-Api-Key", apiKey)
|
|
res, err := http.DefaultClient.Do(r)
|
|
if err != nil {
|
|
return &JobV2{}, err
|
|
}
|
|
b, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return &JobV2{}, err
|
|
}
|
|
j := &JobV2{}
|
|
err = json.Unmarshal(b, j)
|
|
if err != nil {
|
|
return &JobV2{}, err
|
|
}
|
|
return j, nil
|
|
}
|
|
|
|
func getPrinter(hostname, apiKey string) (*MetricsV2, error) {
|
|
r, err := http.NewRequest(http.MethodGet, hostname+"/api/printer", nil)
|
|
if err != nil {
|
|
return &MetricsV2{}, err
|
|
}
|
|
r.Header.Set("X-Api-Key", apiKey)
|
|
res, err := http.DefaultClient.Do(r)
|
|
if err != nil {
|
|
return &MetricsV2{}, err
|
|
}
|
|
b, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return &MetricsV2{}, err
|
|
}
|
|
j := &MetricsV2{}
|
|
err = json.Unmarshal(b, j)
|
|
if err != nil {
|
|
return &MetricsV2{}, err
|
|
}
|
|
return j, nil
|
|
}
|
|
|
|
func getTelemetry(hostname string) (*MetricsV1, error) {
|
|
res, err := http.Get(hostname + "/api/telemetry")
|
|
if err != nil {
|
|
return &MetricsV1{}, err
|
|
}
|
|
b, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return &MetricsV1{}, err
|
|
}
|
|
t := &MetricsV1{}
|
|
err = json.Unmarshal(b, t)
|
|
if err != nil {
|
|
return &MetricsV1{}, err
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// taking a guess on proper parsing here...
|
|
func parseDuration(d string) time.Duration {
|
|
dur := 0
|
|
d = strings.TrimSpace(d)
|
|
number := []rune{}
|
|
for _, t := range d {
|
|
switch t {
|
|
case ' ':
|
|
continue
|
|
case '1', '2', '3', '4', '5', '6', '7', '8', '9', '0':
|
|
number = append(number, t)
|
|
case 's':
|
|
i, _ := strconv.Atoi(string(number))
|
|
dur += i * int(time.Second)
|
|
number = []rune{}
|
|
case 'm':
|
|
i, _ := strconv.Atoi(string(number))
|
|
dur += (i * int(time.Minute))
|
|
number = []rune{}
|
|
case 'h':
|
|
i, _ := strconv.Atoi(string(number))
|
|
dur += (i * int(time.Hour))
|
|
number = []rune{}
|
|
case 'd':
|
|
i, _ := strconv.Atoi(string(number))
|
|
dur += (i * int(time.Hour) * 24)
|
|
number = []rune{}
|
|
}
|
|
|
|
}
|
|
return time.Duration(dur)
|
|
}
|
|
|
|
func main() {
|
|
var hostname string
|
|
flag.StringVar(&hostname, "hostname", "localhost", "Hostname the Prusa Connect API is available at (assumes http)")
|
|
var queryInterval int
|
|
flag.IntVar(&queryInterval, "interval", 2, "How often, in seconds, to query the API")
|
|
var port string
|
|
flag.StringVar(&port, "port", "2112", "Local port to export metrics on")
|
|
var path string
|
|
flag.StringVar(&path, "path", "metrics", "Local path to export metrics on")
|
|
var apiKey string
|
|
flag.StringVar(&apiKey, "apikey", "", "Prusa Connect API key (see Main Menu -> Settings -> Network on the printer)")
|
|
flag.Parse()
|
|
|
|
if queryInterval < 1 {
|
|
log.Fatalf("query interval must be greater than 0; %d received", queryInterval)
|
|
}
|
|
|
|
config := Config{
|
|
QueryHostname: "http://" + hostname,
|
|
QueryInterval: queryInterval,
|
|
Port: port,
|
|
Path: path,
|
|
APIKey: apiKey,
|
|
}
|
|
|
|
r := prometheus.NewRegistry()
|
|
for _, g := range gauges {
|
|
r.MustRegister(g)
|
|
}
|
|
recordMetrics(context.Background(), config)
|
|
|
|
log.Printf("starting exporter on :%v", config.Port)
|
|
|
|
http.Handle("/"+config.Path, promhttp.HandlerFor(r, promhttp.HandlerOpts{}))
|
|
http.ListenAndServe(":"+config.Port, nil)
|
|
}
|