├── 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 |
2 |
3 | 4 |
5 | 6 |
7 |
8 |
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 | --------------------------------------------------------------------------------