diff --git a/README.md b/README.md new file mode 100644 index 0000000..b20ce70 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Prusa Connect Prometheus Exporter + +Version v0.1.0 of this exporter supports the pre-4.4.0 changes to the Prusa Connect API (non-API-key version). + +Current master branch supports the new, 4.4.0-beta2 release of Prusa Connect as well as the older API. + +## Usage + +`./prusa-connect-exporter -h` + +* `apikey` + Prusa Connect API key (see Main Menu -> Settings -> Network on the printer). + If no key is provided, exporter assumes the older API layout when querying the printer. +* `hostname` + Hostname the Prusa Connect API is available at (assumes http) (default "localhost") +* `interval` + How often, in seconds, to query the API (default 2) +* `path` + Local path to export metrics on (default "metrics") +* `port` + Local port to export metrics on (default "2112") diff --git a/main.go b/main.go index 46cebac..0df1f89 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,8 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" ) -type Metrics struct { +// original /api/telemetry type struct +type MetricsV1 struct { TempNozzle int `json:"temp_nozzle"` TempBed int `json:"temp_bed"` Material string `json:"material"` @@ -29,68 +30,135 @@ type Metrics struct { 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 ( - opsFlowFactor = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "prusa_connect", - Name: "flow_factor", - Help: "Current flow factor, as a unitless number", - }) - opsPrintSpeed = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "prusa_connect", - Name: "printing_speed", - Help: "Current print speed, as a percentage", - }) - opsZPosition = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "prusa_connect", - Name: "z_position", - Help: "Vertical depth, in mm, of the Z head", - }) - opsBed = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "prusa_connect", - Name: "temp_bed", - Help: "Temperature, in celsius, of the print bed", - }) - opsNozzle = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "prusa_connect", - Name: "temp_nozzle", - Help: "Temperature, in celsius, of the print nozzle", - }) - opsProgress = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "prusa_connect", - Name: "progress", - Help: "Current print completeness, as a percentage", - }) - opsDuration = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "prusa_connect", - Name: "duration", - Help: "Duration of current print job, as seconds since start", - }) - opsRemaining = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "prusa_connect", - Name: "remaining", - Help: "Estimated remaining time of current print job, as seconds", - }) 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 - opsFlowFactor.Set(0) - opsPrintSpeed.Set(0) - opsZPosition.Set(0) - opsBed.Set(0) - opsNozzle.Set(0) - opsProgress.Set(0) - opsDuration.Set(0) - opsRemaining.Set(0) + for _, g := range gauges { + g.Set(0) + } if errCount == 5 { log.Printf("suppressing further error logging") return @@ -109,42 +177,124 @@ func recordMetrics(ctx context.Context, config Config) { return default: time.Sleep(time.Second * time.Duration(config.QueryInterval)) - res, err := http.Get(config.QueryHostname + "/api/telemetry") - if err != nil { - errLog(err) + if config.APIKey == "" { + parseMetricsV1(ctx, config) break } - b, err := io.ReadAll(res.Body) - if err != nil { - errLog(err) - break - } - t := &Metrics{} - err = json.Unmarshal(b, t) - if err != nil { - errLog(err) - break - } - if errCount != 0 { - log.Printf("connection established") - } - errCount = 0 - opsFlowFactor.Set(float64(t.FlowFactor)) - opsPrintSpeed.Set(float64(t.PrintingSpeed)) - opsZPosition.Set(t.ZPosition) - opsBed.Set(float64(t.TempBed)) - opsNozzle.Set(float64(t.TempNozzle)) - opsProgress.Set(float64(t.Progress)) - opsDuration.Set(float64(parseDuration(t.PrintDur) / time.Second)) - estimatedTimeRemaining, err := strconv.ParseFloat(t.TimeEst, 64) - if err == nil { - opsRemaining.Set(float64(estimatedTimeRemaining)) - } + 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 @@ -187,6 +337,8 @@ func main() { 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 { @@ -198,18 +350,13 @@ func main() { QueryInterval: queryInterval, Port: port, Path: path, + APIKey: apiKey, } r := prometheus.NewRegistry() - r.MustRegister(opsNozzle) - r.MustRegister(opsBed) - r.MustRegister(opsZPosition) - r.MustRegister(opsPrintSpeed) - r.MustRegister(opsFlowFactor) - r.MustRegister(opsProgress) - r.MustRegister(opsDuration) - r.MustRegister(opsRemaining) - + for _, g := range gauges { + r.MustRegister(g) + } recordMetrics(context.Background(), config) log.Printf("starting exporter on :%v", config.Port)