├── MANIFEST.in
├── requirements.txt
├── octoprint_prometheus
├── templates
│ └── prometheus_settings.jinja2
├── gcodeparser.py
└── __init__.py
├── setup.py
└── README.md
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | prometheus_client
2 |
--------------------------------------------------------------------------------
/octoprint_prometheus/templates/prometheus_settings.jinja2:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/octoprint_prometheus/gcodeparser.py:
--------------------------------------------------------------------------------
1 | import re
2 | import sys
3 |
4 | # https://community.octoprint.org/t/how-to-determine-filament-extruded/7828
5 |
6 | # stolen directly from filaswitch
7 | class Gcode_parser(object):
8 | MOVE_RE = re.compile("^G0\s+|^G1\s+")
9 | X_COORD_RE = re.compile(".*\s+X([-]*\d+\.*\d*)")
10 | Y_COORD_RE = re.compile(".*\s+Y([-]*\d+\.*\d*)")
11 | E_COORD_RE = re.compile(".*\s+E([-]*\d+\.*\d*)")
12 | Z_COORD_RE = re.compile(".*\s+Z([-]*\d+\.*\d*)")
13 | SPEED_VAL_RE = re.compile(".*\s+F(\d+\.*\d*)")
14 |
15 | FAN_SET_RE = re.compile("^M106\s+")
16 | FAN_SPEED_RE = re.compile(".*\s+S(\d+\.*\d*)")
17 |
18 | FAN_OFF_RE = re.compile("^M107")
19 |
20 | def __init__(self):
21 | self.reset()
22 |
23 | def reset(self):
24 | self.last_extrusion_move = None
25 | self.extrusion_counter = 0
26 | self.x = None
27 | self.y = None
28 | self.z = None
29 | self.e = None
30 | self.speed = None
31 | self.print_fan_speed = None
32 |
33 | def is_extrusion_move(self, m):
34 | """ args are a tuple (x,y,z,e,speed)
35 | """
36 | if m and (m[0] is not None or m[1] is not None) and m[3] is not None and m[3] != 0:
37 | return True
38 | else:
39 | return False
40 |
41 | def parse_move_args(self, line):
42 | """ returns a tuple (x,y,z,e,speed) or None
43 | """
44 |
45 | m = self.MOVE_RE.match(line)
46 | if m:
47 | x = None
48 | y = None
49 | z = None
50 | e = None
51 | speed = None
52 |
53 | m = self.X_COORD_RE.match(line)
54 | if m:
55 | x = float(m.groups()[0])
56 |
57 | m = self.Y_COORD_RE.match(line)
58 | if m:
59 | y = float(m.groups()[0])
60 |
61 | m = self.Z_COORD_RE.match(line)
62 | if m:
63 | z = float(m.groups()[0])
64 |
65 | m = self.E_COORD_RE.match(line)
66 | if m:
67 | e = float(m.groups()[0])
68 |
69 | m = self.SPEED_VAL_RE.match(line)
70 | if m:
71 | speed = float(m.groups()[0])
72 |
73 | return x, y, z, e, speed
74 |
75 | return None
76 |
77 | def parse_fan_speed(self, line):
78 | m = self.FAN_SET_RE.match(line)
79 | if m:
80 | m = self.FAN_SPEED_RE.match(line)
81 | if m:
82 | speed = float(m.groups()[0])
83 | else:
84 | speed = 255.0
85 | return speed
86 |
87 | m = self.FAN_OFF_RE.match(line)
88 | if m:
89 | return 0.0
90 |
91 | return None
92 |
93 | def process_line(self, line):
94 | movement = self.parse_move_args(line)
95 | if movement is not None:
96 | (x, y, z, e, speed) = movement
97 | if e is not None:
98 | self.extrusion_counter += e
99 | self.e = e
100 | if y is not None:
101 | self.y = y
102 | if z is not None:
103 | self.z = z
104 | if x is not None:
105 | self.x = x
106 | if speed is not None:
107 | self.speed = speed
108 | return "movement"
109 |
110 | fanspeed = self.parse_fan_speed(line)
111 | if fanspeed is not None:
112 | self.print_fan_speed = fanspeed
113 | return "print_fan_speed"
114 |
115 | return None
116 |
117 |
118 | if __name__ == "__main__":
119 | # a simple self-test: open the first arg on the command line and parse it
120 |
121 | parser = Gcode_parser()
122 |
123 | for line in open(sys.argv[1]).readlines():
124 | line = line.strip()
125 | parse_result = parser.process_line(line)
126 | if parse_result == "movement":
127 | print "M %s %s %s" % (parser.x, parser.y, parser.z)
128 | elif parse_result == "print_fan_speed":
129 | print "FAN %0.2f" % parser.print_fan_speed
130 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 |
3 | ########################################################################################################################
4 | ### Do not forget to adjust the following variables to your own plugin.
5 |
6 | # The plugin's identifier, has to be unique
7 | plugin_identifier = "prometheus"
8 |
9 | # The plugin's python package, should be "octoprint_", has to be unique
10 | plugin_package = "octoprint_prometheus"
11 |
12 | # The plugin's human readable name. Can be overwritten within OctoPrint's internal data via __plugin_name__ in the
13 | # plugin module
14 | plugin_name = "octoprint-prometheus"
15 |
16 | # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module
17 | plugin_version = "1.1.1"
18 |
19 | # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
20 | # module
21 | plugin_description = """Prometheus endpoint for OctoPrint"""
22 |
23 | # The plugin's author. Can be overwritten within OctoPrint's internal data via __plugin_author__ in the plugin module
24 | plugin_author = "Scott Baker"
25 |
26 | # The plugin's author's mail address.
27 | plugin_author_email = "smbaker@gmail.com"
28 |
29 | # The plugin's homepage URL. Can be overwritten within OctoPrint's internal data via __plugin_url__ in the plugin module
30 | plugin_url = "https://github.com/sbelectronics/octoprint-prometheus"
31 |
32 | # The plugin's license. Can be overwritten within OctoPrint's internal data via __plugin_license__ in the plugin module
33 | plugin_license = "AGPLv3"
34 |
35 | # Any additional requirements besides OctoPrint should be listed here
36 | plugin_requires = ["prometheus_client"]
37 |
38 | ### --------------------------------------------------------------------------------------------------------------------
39 | ### More advanced options that you usually shouldn't have to touch follow after this point
40 | ### --------------------------------------------------------------------------------------------------------------------
41 |
42 | # Additional package data to install for this plugin. The subfolders "templates", "static" and "translations" will
43 | # already be installed automatically if they exist.
44 | plugin_additional_data = []
45 |
46 | # Any additional python packages you need to install with your plugin that are not contains in .*
47 | plugin_addtional_packages = []
48 |
49 | # Any python packages within .* you do NOT want to install with your plugin
50 | plugin_ignored_packages = []
51 |
52 | # Additional parameters for the call to setuptools.setup. If your plugin wants to register additional entry points,
53 | # define dependency links or other things like that, this is the place to go. Will be merged recursively with the
54 | # default setup parameters as provided by octoprint_setuptools.create_plugin_setup_parameters using
55 | # octoprint.util.dict_merge.
56 | #
57 | # Example:
58 | # plugin_requires = ["someDependency==dev"]
59 | # additional_setup_parameters = {"dependency_links": ["https://github.com/someUser/someRepo/archive/master.zip#egg=someDependency-dev"]}
60 | additional_setup_parameters = {}
61 |
62 | ########################################################################################################################
63 |
64 | from setuptools import setup
65 |
66 | try:
67 | import octoprint_setuptools
68 | except:
69 | print("Could not import OctoPrint's setuptools, are you sure you are running that under "
70 | "the same python installation that OctoPrint is installed under?")
71 | import sys
72 | sys.exit(-1)
73 |
74 | setup_parameters = octoprint_setuptools.create_plugin_setup_parameters(
75 | identifier=plugin_identifier,
76 | package=plugin_package,
77 | name=plugin_name,
78 | version=plugin_version,
79 | description=plugin_description,
80 | author=plugin_author,
81 | mail=plugin_author_email,
82 | url=plugin_url,
83 | license=plugin_license,
84 | requires=plugin_requires,
85 | additional_packages=plugin_addtional_packages,
86 | ignored_packages=plugin_ignored_packages,
87 | additional_data=plugin_additional_data
88 | )
89 |
90 | if len(additional_setup_parameters):
91 | from octoprint.util import dict_merge
92 | setup_parameters = dict_merge(setup_parameters, additional_setup_parameters)
93 |
94 | setup(**setup_parameters)
95 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Prometheus Client for OctoPrint #
2 | Scott Baker, http://www.smbaker.com/
3 |
4 | ## Purpose ##
5 |
6 | This plugin implements a Prometheus client inside of OctoPrint. This is native endpoint served up directly from the OctoPrint. This allows you to monitor your 3D printer using the combination of Prometheus and Grafana.
7 |
8 | This plugin will export the following data:
9 | * Progress
10 | * Heat bed temperature
11 | * Tool (extruder) temperatures
12 | * X, Y, Z, E coordinates
13 |
14 | It will also monitor several built in prometheus python client variables, such as process start time, cpu seconds, virtual memory, open file descriptors, etc.
15 |
16 | The reason I chose to do this is I already have Prometheus/Grafana for monitoring the environment in my office. Having OctoPrint data available lets me keep track of printer utilization using the same toolchain, and to correlate printer usage with environmental changes.
17 |
18 | ## Dependencies ##
19 |
20 | This package depends upon `prometheus_client`, which should be automatically installed as necessary by pip.
21 |
22 | ## Installation ##
23 |
24 | Either of the following commands can be used to install the package from the command line:
25 |
26 | * `pip install octoprint-prometheus`
27 | * `pip install https://github.com/sbelectronics/octoprint-prometheus/archive/master.zip`
28 |
29 | Additionally, you can install this using the Octoprint GUI by using Plugin Manager --> Get More --> from URL, and entering the URL `https://github.com/sbelectronics/octoprint-prometheus/archive/master.zip`.
30 |
31 | ## Configuration ##
32 |
33 | The printer by default exposes an endpoint on port 8000. This port may be changed using the plugin's setup page in the OctoPrint UI.
34 |
35 | ## Testing ##
36 |
37 | You can use `curl` or a web browser to view the Prometheus endpoint and ensure it is producting data. For example,
38 |
39 | ```bash
40 | pi@octopi:~ $ curl http://localhost:8000/
41 | # HELP python_info Python platform information
42 | # TYPE python_info gauge
43 | python_info{implementation="CPython",major="2",minor="7",patchlevel="13",version="2.7.13"} 1.0
44 | # HELP process_virtual_memory_bytes Virtual memory size in bytes.
45 | # TYPE process_virtual_memory_bytes gauge
46 | process_virtual_memory_bytes 3.17431808e+08
47 | # HELP process_resident_memory_bytes Resident memory size in bytes.
48 | # TYPE process_resident_memory_bytes gauge
49 | process_resident_memory_bytes 8.835072e+07
50 | # HELP process_start_time_seconds Start time of the process since unix epoch in seconds.
51 | # TYPE process_start_time_seconds gauge
52 | process_start_time_seconds 1.55090426429e+09
53 | # HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
54 | # TYPE process_cpu_seconds_total counter
55 | process_cpu_seconds_total 54.35
56 | # HELP process_open_fds Number of open file descriptors.
57 | # TYPE process_open_fds gauge
58 | process_open_fds 38.0
59 | # HELP process_max_fds Maximum number of open file descriptors.
60 | # TYPE process_max_fds gauge
61 | process_max_fds 1024.0
62 | # HELP temperature_bed_target temperature_bed_target
63 | # TYPE temperature_bed_target gauge
64 | temperature_bed_target 0.0
65 | # HELP temperature_tool0_actual temperature_tool0_actual
66 | # TYPE temperature_tool0_actual gauge
67 | temperature_tool0_actual 21.3
68 | # HELP temperature_bed_actual temperature_bed_actual
69 | # TYPE temperature_bed_actual gauge
70 | temperature_bed_actual 20.9
71 | # HELP temperature_tool0_target temperature_tool0_target
72 | # TYPE temperature_tool0_target gauge
73 | temperature_tool0_target 0.0
74 | ```
75 |
76 | Note that certain fields will not appear until you've connected to your printer.
77 |
78 | ## Installing Prometheus and Grafana ##
79 |
80 | Install Prometheus and Grafana on another machine or another pi, as you'll be maintaining a database.
81 |
82 | Personally I install it using helm and kubernetes, but there are many different ways to install these tools. Using the helm chart, I add a datasource to Prometheus as follows:
83 |
84 | ```yaml
85 | scrape_configs:
86 | # 3dprinter
87 | - job_name: '3dprinter'
88 | metrics_path: /metrics
89 | scrape_interval: 10s
90 | static_configs:
91 | - targets:
92 | - 198.0.0.246:8000
93 | ```
94 |
95 | How to install Prometheus and Grafana is beyond the scope of this README. The following links may be helpful to you:
96 |
97 | * https://medium.com/@at_ishikawa/install-prometheus-and-grafana-by-helm-9784c73a3e97
98 | * https://www.digitalocean.com/community/tutorials/how-to-install-prometheus-on-ubuntu-16-04
99 | * http://docs.grafana.org/installation/debian/
100 | * https://github.com/carlosedp/arm-monitoring
101 |
--------------------------------------------------------------------------------
/octoprint_prometheus/__init__.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 |
3 | """ Octoprint-Prometheus
4 | Scott Baker, http://www.smbaker.com/
5 |
6 | This is an Octoprint plugin that exposes a Prometheus client endpoint, allowing printer statistics to be
7 | collected by Prometheus and view in Grafana.
8 |
9 | Development notes:
10 | # Sourcing the oprint environment on the pi for development.
11 | source ~/oprint/bin/activate
12 |
13 | # Find octoprint logs here
14 | tail -f /home/pi/.octoprint/logs/octoprint.log
15 |
16 | # Uninstall and cleanup
17 | pip uninstall octoprint-prometheus
18 | rm -rf /home/pi/oprint/local/lib/python2.7/site-packages/octoprint_prometheus*
19 |
20 | # Upload to pypi
21 | rm -rf dist
22 | python setup.py sdist bdist_wheel
23 | twine upload dist/*
24 |
25 | """
26 |
27 | from __future__ import absolute_import
28 |
29 | from threading import Timer
30 | from prometheus_client import Counter, Enum, Gauge, Info, start_http_server
31 | import octoprint.plugin
32 |
33 | from .gcodeparser import Gcode_parser
34 |
35 |
36 | class PrometheusPlugin(octoprint.plugin.StartupPlugin,
37 | octoprint.plugin.SettingsPlugin,
38 | octoprint.plugin.TemplatePlugin,
39 | octoprint.plugin.ProgressPlugin,
40 | octoprint.plugin.EventHandlerPlugin):
41 |
42 | DESCRIPTIONS = {"temperature_bed_actual": "Actual Temperature in Celsius of Bed",
43 | "temperature_bed__target": "Target Temperature in Celsius of Bed",
44 | "temperature_tool0_actual": "Actual Temperature in Celsius of Extruder Hot End",
45 | "temperature_tool0__target": "Target Temperature in Celsius of Extruder Hot End",
46 | "movement_x": "Movement of X axis from G0 or G1 gcode",
47 | "movement_y": "Movement of Y axis from G0 or G1 gcode",
48 | "movement_z": "Movement of Z axis from G0 or G1 gcode",
49 | "movement_e": "Movement of Extruder from G0 or G1 gcode",
50 | "movement_speed": "Speed setting from G0 or G1 gcode",
51 | "extrusion_print": "Filament extruded this print",
52 | "extrusion_total": "Filament extruded total",
53 | "progress": "Progress percentage of print",
54 | "printing": "1 if printing, 0 otherwise",
55 | "print": "Filename information about print",
56 | }
57 |
58 | def __init__(self, *args, **kwargs):
59 | super(PrometheusPlugin, self).__init__(*args, **kwargs)
60 | self.parser = Gcode_parser()
61 | self.gauges = {} # holds gauges, counters, infos, and enums
62 | self.last_extrusion_counter = 0
63 | self.completion_timer = None
64 |
65 | self.gauges["printer_state"] = Enum("printer_state",
66 | "State of printer",
67 | states=["init", "printing", "done", "failed", "cancelled", "idle"])
68 | self.gauges["printer_state"].state("init")
69 |
70 | self.init_gauge("progress")
71 | self.init_gauge("extrusion_print")
72 | self.init_gauge("printing")
73 | self.init_gauge("zchange")
74 | self.init_gauge("movement_x")
75 | self.init_gauge("movement_y")
76 | self.init_gauge("movement_z")
77 | self.init_gauge("movement_e")
78 | self.init_gauge("movement_speed")
79 | self.init_gauge("temperature_bed_actual")
80 | self.init_gauge("temperature_bed_target")
81 | self.init_gauge("temperature_tool0_actual")
82 | self.init_gauge("temperature_tool0_target")
83 | self.init_gauge("temperature_tool1_actual")
84 | self.init_gauge("temperature_tool1_target")
85 | self.init_gauge("temperature_tool2_actual")
86 | self.init_gauge("temperature_tool2_target")
87 | self.init_gauge("temperature_tool3_actual")
88 | self.init_gauge("temperature_tool3_target")
89 | self.init_gauge("print_fan_speed")
90 |
91 | self.init_counter("extrusion_total")
92 |
93 | self.init_info("print")
94 |
95 | def on_after_startup(self):
96 | self._logger.info("Starting Prometheus! (port: %s)" % self._settings.get(["prometheus_port"]))
97 | start_http_server(int(self._settings.get(["prometheus_port"])))
98 |
99 | def get_settings_defaults(self):
100 | return dict(prometheus_port=8000)
101 |
102 | def get_template_configs(self):
103 | return [
104 | dict(type="settings", custom_bindings=False)
105 | ]
106 |
107 | def init_gauge(self, name):
108 | self.gauges[name] = Gauge(name, self.DESCRIPTIONS.get(name, name))
109 |
110 | def init_counter(self, name):
111 | self.gauges[name] = Counter(name, self.DESCRIPTIONS.get(name, name))
112 |
113 | def init_info(self, name):
114 | self.gauges[name] = Info(name, self.DESCRIPTIONS.get(name, name))
115 |
116 | def get_gauge(self, name):
117 | return self.gauges[name]
118 |
119 | def on_print_progress(self, storage, path, progress):
120 | gauge = self.get_gauge("progress")
121 | gauge.set(progress)
122 |
123 | def print_complete_callback(self):
124 | self.get_gauge("printer_state").state("idle")
125 | self.get_gauge("progress").set(0)
126 | self.get_gauge("extrusion_print").set(0)
127 | self.get_gauge("print").info({}) # This doesn't actually cause it to reset...
128 | self.completion_timer = None
129 |
130 | def print_complete(self, reason):
131 | self.get_gauge("printer_state").state(reason)
132 | self.get_gauge("printing").set(0) # TODO: may be redundant with printer_state
133 |
134 | # In 30 seconds, reset all the progress variables back to 0
135 | # At a default 10 second interval, this gives us plenty of room for Prometheus to capture the 100%
136 | # complete gauge.
137 |
138 | # TODO: Is this really a good idea?
139 |
140 | self.completion_timer = Timer(30, self.print_complete_callback)
141 | self.completion_timer.start()
142 |
143 | def on_event(self, event, payload):
144 | if event == "ZChange":
145 | # TODO: This doesn't seem useful...
146 | gauge = self.get_gauge("zchange")
147 | gauge.set(payload["new"])
148 | elif event == "PrintStarted":
149 | # If there's a completion timer running, kill it.
150 | if self.completion_timer:
151 | self.completion_timer.cancel()
152 | self.completion_timer = None
153 |
154 | # reset the extrusion counter
155 | self.parser.reset()
156 | self.last_extrusion_counter = 0
157 | self.get_gauge("printing").set(1) # TODO: may be redundant with printer_state
158 | self.get_gauge("printer_state").state("printing")
159 | self.get_gauge("print").info({"name": payload.get("name", ""),
160 | "path": payload.get("path", ""),
161 | "origin": payload.get("origin", "")})
162 | elif event == "PrintFailed":
163 | self.print_complete("failed")
164 | elif event == "PrintDone":
165 | self.print_complete("done")
166 | elif event == "PrintCancelled":
167 | self.print_complete("cancelled")
168 |
169 | """
170 | # This was my first attempt at measuring positions and extrusions.
171 | # Didn't work the way I expected.
172 | # Went with gcodephase_hook and counting extrusion gcode instead.
173 | if (event == "PositionUpdate"):
174 | for (k,v) in payload.items():
175 | if k in ["x", "y", "z", "e"]:
176 | k = "position_" + k
177 | gauge = self.get_gauge(k)
178 | gauge.set(v)
179 | """
180 |
181 | def gcodephase_hook(self, comm_instance, phase, cmd, cmd_type, gcode, subcode=None, tags=None, *args, **kwargs):
182 | if phase == "sent":
183 | parse_result = self.parser.process_line(cmd)
184 | if parse_result == "movement":
185 | for k in ["x", "y", "z", "e", "speed"]:
186 | v = getattr(self.parser, k)
187 | if v is not None:
188 | gauge = self.get_gauge("movement_" + k)
189 | gauge.set(v)
190 |
191 | # extrusion_print is modeled as a gauge so we can reset it after every print
192 | gauge = self.get_gauge("extrusion_print")
193 | gauge.set(self.parser.extrusion_counter)
194 |
195 | if self.parser.extrusion_counter > self.last_extrusion_counter:
196 | # extrusion_total is monotonically increasing for the lifetime of the plugin
197 | counter = self.get_gauge("extrusion_total")
198 | counter.inc(self.parser.extrusion_counter - self.last_extrusion_counter)
199 | self.last_extrusion_counter = self.parser.extrusion_counter
200 |
201 | elif parse_result == "print_fan_speed":
202 | v = getattr(self.parser, "print_fan_speed")
203 | if v is not None:
204 | gauge = self.get_gauge("print_fan_speed")
205 | gauge.set(v)
206 |
207 | return None # no change
208 |
209 | def temperatures_handler(self, comm, parsed_temps):
210 | for (k, v) in parsed_temps.items():
211 | mapname = {"B": "temperature_bed",
212 | "T0": "temperature_tool0",
213 | "T1": "temperature_tool1",
214 | "T2": "temperature_tool2",
215 | "T3": "temperature_tool3"}
216 |
217 | # We only support four tools. If someone runs into a printer with more tools, please
218 | # let me know...
219 | if k not in mapname:
220 | continue
221 |
222 | k_actual = mapname.get(k, k) + "_actual"
223 | gauge = self.get_gauge(k_actual)
224 | try:
225 | gauge.set(v[0])
226 | except TypeError:
227 | pass # not an integer or float
228 |
229 | k_target = mapname.get(k, k) + "_target"
230 | gauge = self.get_gauge(k_target)
231 | try:
232 | gauge.set(v[1])
233 | except TypeError:
234 | pass # not an integer or float
235 |
236 | return parsed_temps
237 |
238 |
239 | def __plugin_load__():
240 | plugin = PrometheusPlugin()
241 |
242 | global __plugin_implementation__
243 | __plugin_implementation__ = plugin
244 |
245 | global __plugin_hooks__
246 | __plugin_hooks__ = {"octoprint.comm.protocol.temperatures.received": plugin.temperatures_handler,
247 | "octoprint.comm.protocol.gcode.sent": plugin.gcodephase_hook}
248 |
--------------------------------------------------------------------------------