-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathprusalink_exporter.py
executable file
·404 lines (358 loc) · 16 KB
/
prusalink_exporter.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
#!/usr/bin/env python3
# https://github.com/mmanjos/prometheus-prusalink-exporter
__author__ = "github.com/mmanjos"
__copyright__ = "Copyright 2024, Matthew Manjos"
__license__ = "GPL-3"
__version__ = "0.0.1"
__maintainer__ = "Matthew Manjos"
__email__ = "[email protected]"
import argparse
import time
import json
import sys
import logging
import yaml
import requests
import prometheus_client
from prometheus_client.core import GaugeMetricFamily, InfoMetricFamily, REGISTRY
from prometheus_client.registry import Collector
class PrusalinkPrinter:
def __init__(self, host: str, user: str, password: str, scrape_timeout: int):
self.host = host
self.auth = requests.auth.HTTPDigestAuth(user, password)
self.up = False
self.scrape_timeout = scrape_timeout
# PrusaLink-Web API Paths to Scrape
# See: https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml
self.scrape_paths = {
"version": "/api/version",
"status": "/api/v1/status",
"info": "/api/v1/info",
"job": "/api/v1/job",
}
self.scrape_data = {}
self.state_metrics = {}
self.gauge_metrics = {}
self.info_metrics = {}
self.labels = {}
def refresh(self):
"""Rebuild all data for the printer"""
self._refresh_scrape_data()
self._set_labels()
self._update_metrics()
def _refresh_scrape_data(self):
"""Fetch (HTTP) and Parse (JSON) various api pages off of the printer"""
# Clear old scrape data
self.scrape_data = {}
try:
for name, path in self.scrape_paths.items():
response = requests.get(
"http://" + self.host + path,
auth=self.auth,
timeout=self.scrape_timeout,
)
if response.status_code == 200:
# The response was good; store it
self.scrape_data[name] = json.loads(response.content)
elif response.status_code == 204:
# An empty page is still valid for some API calls
self.scrape_data[name] = {}
else:
# If any of the api requests have failed, treat the printer as down
logging.error("Unable to fetch %s from %s", path, self.host)
logging.error("Request status code: %s", response.status_code)
self.up = False
except Exception as e:
logging.error("Unable to fetch HTTP raw scrape_data on %s", self.host)
logging.error("Exception: %s", e)
self.up = False
# Only consider the Collector as up if all paths now have data
if len(self.scrape_data) == len(self.scrape_paths):
self.up = True
def _set_labels(self):
"""Set global labels for all metrics relating to this printer"""
# Clear old lables (needed in case a printer goes offline)
self.labels = {}
self.labels["printer"] = self.host
if self.up:
self.labels["serialnumber"] = self.scrape_data["info"]["serial"]
def _update_metrics(self):
"""Place scraped api data into metric data structures so it can be collected"""
# Clear old metrics
self.state_metrics = {}
self.gauge_metrics = {}
self.info_metrics = {}
if self.up:
# Metrics to report on and where to find them in the data
# Info Metrics
self.info_metrics = [
{
"name": "prusalink_server_firmware_version",
"help": "Prusa Firmware Running on the Printer",
"values": {
"version": safe_nested_get(self.scrape_data, "Unknown", "version", "server"),
"api": safe_nested_get(self.scrape_data, "Unknown", "version", "api"),
},
}
]
# State-based Metrics
# Fake Enum type metrics (no EnumMetricFamily?)
self.state_metrics = [
{
"name": "prusalink_printer_state",
"help": "Current Printer State",
"states": [
"IDLE",
"BUSY",
"PRINTING",
"PAUSED",
"FINISHED",
"STOPPED",
"ERROR",
"ATTENTION",
"READY",
"UNKNOWN",
],
"value": safe_nested_get(self.scrape_data, "UNKNOWN", "status", "printer", "state"),
}
]
# Gauge Metrics
self.gauge_metrics = [
{
"name": "prusalink_nozzle_diameter",
"help": "Nozzle Diameter in mm",
"value": safe_nested_get(self.scrape_data, None, "info", "nozzle_diameter"),
},
{
"name": "prusalink_speed",
"help": "Current Printer Configured Speed in Percent",
"value": safe_nested_get(self.scrape_data, None, "status", "printer", "speed"),
},
{
"name": "prusalink_flow_rate",
"help": "Current Printer Configured Flow Rate in Percent",
"value": safe_nested_get(self.scrape_data, None, "status", "printer", "flow"),
},
{
"name": "prusalink_bed_temp_current",
"help": "Current Printer Bed Temperature in Celcius",
"value": safe_nested_get(self.scrape_data, None, "status", "printer", "temp_bed"),
},
{
"name": "prusalink_bed_temp_desired",
"help": "Set (Desired) Printer Bed Temperature in Celcius",
"value": safe_nested_get(self.scrape_data, None, "status", "printer", "target_bed"),
},
{
"name": "prusalink_nozzle_temp_current",
"help": "Current Extruder Nozzle Temperature in Celcius",
"value": safe_nested_get(self.scrape_data, None, "status", "printer", "temp_nozzle"),
},
{
"name": "prusalink_nozzle_temp_desired",
"help": "Set (Desired) Extruder Nozzle Temperature in Celcius",
"value": safe_nested_get(self.scrape_data, None, "status", "printer", "target_nozzle"),
},
{
"name": "prusalink_axis_z",
"help": "Current Z Axis Position in mm",
"value": safe_nested_get(self.scrape_data, None, "status", "printer", "axis_z"),
},
]
# Extra metrics to add if the printer is working on a job
stopped_states = ["IDLE", "FINISHED", "STOPPED", "UNKNOWN"]
if self.scrape_data["status"]["printer"]["state"] not in stopped_states:
self.gauge_metrics.append(
{
"name": "prusalink_job_progress",
"help": "Current Job Progress in Percent",
"value": safe_nested_get(self.scrape_data, None, "job", "progress"),
}
)
self.gauge_metrics.append(
{
"name": "prusalink_job_time_elapsed",
"help": "Current Job Elapsed Time Printing in Seconds",
"value": safe_nested_get(self.scrape_data, None, "job", "time_printing"),
}
)
self.gauge_metrics.append(
{
"name": "prusalink_job_time_remaining",
"help": "Current Job Time Remaining in Seconds",
"value": safe_nested_get(self.scrape_data, None, "job", "time_remaining"),
}
)
self.info_metrics.append(
{
"name": "prusalink_job",
"help": "Information on the Current Active Job",
"values": {
"filename": safe_nested_get(
self.scrape_data, "Unknown", "job", "file", "display_name"
),
"filesize": str(
safe_nested_get(self.scrape_data, "Unknown", "job", "file", "size")
),
},
}
)
class PrusalinkCollector(Collector):
def __init__(self, configdata_printers: list, scrape_timeout: int):
self.printers = {}
self.collected_gauge_metrics = {}
self.collected_state_metrics = {}
self.collected_info_metrics = {}
for printer, settings in configdata_printers.items():
self.printers[str(printer)] = PrusalinkPrinter(
host=str(printer),
user=settings["username"],
password=settings["password"],
scrape_timeout=scrape_timeout,
)
def collect(self):
"""Assemble and yield the scraped metrics"""
# Reset previously collected metrics
self.collected_gauge_metrics = {}
self.collected_state_metrics = {}
self.collected_info_metrics = {}
# Always collect this metric
scrape_successful = GaugeMetricFamily(
"prusalink_scrape_successful",
"Indicates if the scrape from the printer was successful",
labels=["printer", "serialnumber"],
)
# Refresh all scrape data, labels and metrics for all printers
for printer in self.printers.values():
printer.refresh()
# Populate the InfoMetricFamily values
for info_metric in printer.info_metrics:
info_metric_labels = ["printer", "serialnumber"] + list(info_metric["values"].keys())
try:
self.collected_info_metrics[str(info_metric["name"])]
except KeyError:
self.collected_info_metrics[str(info_metric["name"])] = InfoMetricFamily(
info_metric["name"],
info_metric["help"],
labels=info_metric_labels,
)
metric_values = dict(printer.labels) | info_metric["values"]
metric_labels = list(metric_values.keys())
self.collected_info_metrics[str(info_metric["name"])].add_metric(
labels=metric_labels, value=metric_values
)
# Populate the GaugeMetricFamily values
for gauge_metric in printer.gauge_metrics:
if gauge_metric["value"] is None:
# No metric data was found; unable to add_metric
continue
try:
self.collected_gauge_metrics[str(gauge_metric["name"])]
except KeyError:
# This metric has not been seen yet; add it to the collected metrics array
self.collected_gauge_metrics[str(gauge_metric["name"])] = GaugeMetricFamily(
gauge_metric["name"],
gauge_metric["help"],
labels=["printer", "serialnumber"],
)
self.collected_gauge_metrics[str(gauge_metric["name"])].add_metric(
printer.labels.values(), gauge_metric["value"]
)
# Populate the State-based (Enum) values by faking the output with GaugeMetricFamily
# TODO: Rewrite this using StateSetMetricFamily?
for state_metric in printer.state_metrics:
if state_metric["value"] is None:
# No metric data was found; unable to add_metric
continue
try:
self.collected_state_metrics[str(state_metric["name"])]
except KeyError:
# This metric has not been seen yet; add it to the collected metrics array
state_metric_labels = ["printer", "serialnumber", "state"]
self.collected_state_metrics[str(state_metric["name"])] = GaugeMetricFamily(
state_metric["name"],
state_metric["help"],
labels=state_metric_labels,
)
# Add a metric for each state
for state in state_metric["states"]:
state_metric_labels = dict(printer.labels) # make a copy of the labels for use here
state_metric_labels["state"] = state
if state == state_metric["value"]:
state_metric_value = 1
else:
state_metric_value = 0
self.collected_state_metrics[str(state_metric["name"])].add_metric(
state_metric_labels.values(), state_metric_value
)
# Finally, add the overall success metric
scrape_successful.add_metric(printer.labels.values(), int(printer.up))
# Send back all of the collected metrics
yield scrape_successful
for gauge_metric in self.collected_gauge_metrics.values():
yield gauge_metric
for info_metric in self.collected_info_metrics.values():
yield info_metric
for state_metric in self.collected_state_metrics.values():
yield state_metric
def safe_nested_get(d: dict, fallback, *keys):
"""
Return the value at d[ keys[0] ][ keys[1] ][ keys[2] ] ... [ keys[n] ],
or return fallback if the nested key does not exist
"""
value = fallback
try:
for k in keys:
d = d[k]
value = d
except KeyError:
logging.warning("Error finding a value from %s", keys)
return value
if __name__ == "__main__":
# Disable extra metrics
REGISTRY.unregister(prometheus_client.PROCESS_COLLECTOR)
REGISTRY.unregister(prometheus_client.PLATFORM_COLLECTOR)
REGISTRY.unregister(prometheus_client.GC_COLLECTOR)
# Parse command-line args
parser = argparse.ArgumentParser()
parser.add_argument("-c", "--config", required=True, help="Config File")
args = parser.parse_args()
# Load the config file specified
with open(args.config, "r", encoding="utf8") as f:
configdata = yaml.safe_load(f)
# Default config options
default_config = {
"exporter_port": 9528,
"exporter_address": "127.0.0.1",
"scrape_timeout": 10,
}
# Set default options if they aren't found in the config data
for setting in default_config:
if setting not in configdata:
configdata[setting] = default_config[setting]
logging.warning(
"Config setting {0} was not found! Defaulting to: {1}".format(
setting, default_config[setting]
)
)
# Check that at least the list of printers exists in the loaded config file
try:
configdata["printers"]
except KeyError:
errormsg = """
Error: no printers were defined in the config file. Nothing to do!
Please make a list of printers in {configpath} following this structure (indentation matters!):
printers:
"prusaxl.mydomain.invalid":
username: "maker"
password: "myprinterpassword"
""".format(
configpath=args.config
)
sys.exit(errormsg)
# Start the Prometheus Exporter web server
prometheus_client.start_http_server(configdata["exporter_port"], configdata["exporter_address"])
# Start our collector
REGISTRY.register(PrusalinkCollector(configdata["printers"], scrape_timeout=configdata["scrape_timeout"]))
while True:
time.sleep(1)