bambu-mqtt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bambu_mqtt/__init__.py +0 -0
- bambu_mqtt/__main__.py +208 -0
- bambu_mqtt/logger.py +44 -0
- bambu_mqtt/mqtt_client.py +33 -0
- bambu_mqtt/parser.py +41 -0
- bambu_mqtt/telemetry.py +194 -0
- bambu_mqtt/utils.py +96 -0
- bambu_mqtt/writers/__init__.py +0 -0
- bambu_mqtt/writers/base.py +65 -0
- bambu_mqtt/writers/influxdb.py +235 -0
- bambu_mqtt-0.1.0.dist-info/METADATA +257 -0
- bambu_mqtt-0.1.0.dist-info/RECORD +15 -0
- bambu_mqtt-0.1.0.dist-info/WHEEL +5 -0
- bambu_mqtt-0.1.0.dist-info/licenses/LICENSE +21 -0
- bambu_mqtt-0.1.0.dist-info/top_level.txt +1 -0
bambu_mqtt/__init__.py
ADDED
|
File without changes
|
bambu_mqtt/__main__.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entry point for Bambu MQTT middle layer.
|
|
3
|
+
|
|
4
|
+
Connects to a local Bambu printer over MQTT using TLS, subscribes to telemetry
|
|
5
|
+
topics, parses incoming JSON messages, and logs or processes them for further use
|
|
6
|
+
(e.g., Grafana / InfluxDB pipelines).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python -m bambu_mqtt --host 192.168.0.123 --device-id ABC123 --access-code 123456
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import logging
|
|
14
|
+
import signal
|
|
15
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any
|
|
18
|
+
from paho.mqtt.client import Client, MQTTMessage
|
|
19
|
+
from bambu_mqtt.telemetry import Telemetry
|
|
20
|
+
from bambu_mqtt.utils import connect_local_mqtt, extract_device_id_from_cert
|
|
21
|
+
from bambu_mqtt.parser import Parser
|
|
22
|
+
from bambu_mqtt.logger import configure_logging
|
|
23
|
+
from bambu_mqtt.writers.influxdb import InfluxDBWriter
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("bambu_mqtt")
|
|
26
|
+
|
|
27
|
+
TOPIC_TEMPLATE: str = "device/{device_id}/{suffix}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = version("bambu-mqtt")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
__version__ = "unknown"
|
|
34
|
+
logger.warning(
|
|
35
|
+
"Package not found. Version information may be unavailable. "
|
|
36
|
+
"Ensure that the package is installed in the current environment."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="bambu_mqtt",
|
|
42
|
+
description=(
|
|
43
|
+
"Bambu MQTT listener - connect to a local Bambu printer over TLS, "
|
|
44
|
+
"subscribe to telemetry topics, and parse the incoming messages for "
|
|
45
|
+
"logging or further processing (e.g., Grafana / InfluxDB)."
|
|
46
|
+
),
|
|
47
|
+
epilog=(
|
|
48
|
+
"Note: The device ID is derived from the TLS certificate, so you do not need to "
|
|
49
|
+
"provide it explicitly. The access code is required for MQTT authentication.\n\n"
|
|
50
|
+
"Example usage:\n"
|
|
51
|
+
" python -m bambu_mqtt --host 192.0.2.10 "
|
|
52
|
+
"--access-code YOUR_PRINTER_ACCESS_CODE --cert ./blcert.pem\n"
|
|
53
|
+
" python -m bambu_mqtt --host 192.0.2.10 "
|
|
54
|
+
"--access-code YOUR_PRINTER_ACCESS_CODE --cert ./blcert.pem --log-level DEBUG\n\n"
|
|
55
|
+
),
|
|
56
|
+
formatter_class=argparse.RawTextHelpFormatter,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"-H", "--host", required=True, help="LAN IP or hostname of the printer."
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument("--access-code", required=True, help="Printer MQTT access code.")
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"-c",
|
|
65
|
+
"--cert",
|
|
66
|
+
required=True,
|
|
67
|
+
help="Path to the TLS certificate (used to derive device ID for MQTT).",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"-l",
|
|
71
|
+
"--log-level",
|
|
72
|
+
default="INFO",
|
|
73
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
74
|
+
help="Set the logging level (default: INFO).",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"-v",
|
|
79
|
+
"--version",
|
|
80
|
+
action="version",
|
|
81
|
+
version=f"%(prog)s {__version__}",
|
|
82
|
+
help="Show the version of the Bambu MQTT listener and exit.",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--influx-url",
|
|
87
|
+
default="http://localhost:8086",
|
|
88
|
+
help="InfluxDB server URL.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--influx-token",
|
|
92
|
+
default=os.getenv("INFLUXDB_TOKEN"),
|
|
93
|
+
required=os.getenv("INFLUXDB_TOKEN") is None,
|
|
94
|
+
help="InfluxDB authentication token (or set INFLUXDB_TOKEN).",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument("--influx-org", required=True, help="InfluxDB organization.")
|
|
97
|
+
parser.add_argument("--influx-bucket", required=True, help="InfluxDB bucket.")
|
|
98
|
+
|
|
99
|
+
args = parser.parse_args()
|
|
100
|
+
configure_logging(args.log_level)
|
|
101
|
+
|
|
102
|
+
writer = InfluxDBWriter(
|
|
103
|
+
url=args.influx_url,
|
|
104
|
+
token=args.influx_token,
|
|
105
|
+
org=args.influx_org,
|
|
106
|
+
bucket=args.influx_bucket,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Connect to printer via MQTT
|
|
110
|
+
try:
|
|
111
|
+
device_id = extract_device_id_from_cert(args.cert)
|
|
112
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
113
|
+
logger.error("%s", exc)
|
|
114
|
+
raise SystemExit(2) from exc
|
|
115
|
+
|
|
116
|
+
client = connect_local_mqtt(args.host, device_id, args.access_code, args.cert)
|
|
117
|
+
TOPIC_REPORT = TOPIC_TEMPLATE.format(device_id=device_id, suffix="report")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Define callback for incoming MQTT messages
|
|
121
|
+
def on_message(_client: Client, _userdata: Any, msg: MQTTMessage):
|
|
122
|
+
"""On message handler for the MQTT client
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
_client (_type_): MQTT client session
|
|
126
|
+
_userdata (_type_): MQTT userdata
|
|
127
|
+
msg (_type_): Received message
|
|
128
|
+
"""
|
|
129
|
+
logger.info("Received MQTT message on topic %s: parsing now", msg.topic)
|
|
130
|
+
try:
|
|
131
|
+
payload = msg.payload.decode("utf-8")
|
|
132
|
+
data = Parser(payload).data
|
|
133
|
+
telemetry = Telemetry.from_dict(
|
|
134
|
+
data
|
|
135
|
+
) # TODO use telemetry object for further processing (e.g., logging, Grafana, InfluxDB)
|
|
136
|
+
logger.debug("Received telemetry: %s", data)
|
|
137
|
+
with writer:
|
|
138
|
+
writer.write(telemetry)
|
|
139
|
+
logger.info("Telemetry written to InfluxDB successfully.")
|
|
140
|
+
logger.debug("Telemetry object: %s", telemetry)
|
|
141
|
+
except UnicodeDecodeError as exc:
|
|
142
|
+
logger.warning("Failed to decode MQTT payload: %s", exc)
|
|
143
|
+
except ValueError as exc:
|
|
144
|
+
logger.warning("Failed to parse MQTT payload: %s", exc)
|
|
145
|
+
except Exception:
|
|
146
|
+
logger.exception("Failed to process or store telemetry")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def on_connect(_client: Client, _userdata: Any, _flags: dict, rc: int):
|
|
150
|
+
"""On connect handler for the MQTT client
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
_client (Client): MQTT client session
|
|
154
|
+
_userdata (Any): MQTT userdata
|
|
155
|
+
_flags (dict): MQTT flags
|
|
156
|
+
rc (int): Connection result code
|
|
157
|
+
"""
|
|
158
|
+
if rc == 0:
|
|
159
|
+
logger.info("Connected to printer %s at %s", device_id, args.host)
|
|
160
|
+
client.subscribe(TOPIC_REPORT)
|
|
161
|
+
logger.debug("Subscribed to topic %s", TOPIC_REPORT)
|
|
162
|
+
else:
|
|
163
|
+
logger.error("Failed to connect to printer: %s", rc)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def on_disconnect(_client: Client, _userdata: Any, rc: int):
|
|
167
|
+
"""On disconnect handler for the MQTT client
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
_client (_type_): MQTT client session
|
|
171
|
+
_userdata (_type_): MQTT userdata
|
|
172
|
+
rc (_type_): Disconnection result code
|
|
173
|
+
"""
|
|
174
|
+
if rc != 0:
|
|
175
|
+
logger.warning("Unexpected disconnection from printer: %s", rc)
|
|
176
|
+
else:
|
|
177
|
+
logger.info("Disconnected from printer %s at %s", device_id, args.host)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def handle_sigterm(_signum: int, _frame: Any):
|
|
181
|
+
"""Handle SIGTERM signal for graceful shutdown.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
_signum (int): Signal number
|
|
185
|
+
_frame (frame object): Current stack frame
|
|
186
|
+
"""
|
|
187
|
+
logger.info("Received SIGTERM signal. Disconnecting from printer...")
|
|
188
|
+
client.disconnect()
|
|
189
|
+
logger.info("Disconnected. Exiting.")
|
|
190
|
+
raise SystemExit(0)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
client.on_message = on_message
|
|
194
|
+
client.on_connect = on_connect
|
|
195
|
+
client.on_disconnect = on_disconnect
|
|
196
|
+
|
|
197
|
+
client.subscribe(TOPIC_REPORT)
|
|
198
|
+
|
|
199
|
+
# Start MQTT network loop
|
|
200
|
+
signal.signal(signal.SIGTERM, handle_sigterm)
|
|
201
|
+
try:
|
|
202
|
+
client.loop_forever()
|
|
203
|
+
except KeyboardInterrupt:
|
|
204
|
+
logger.info("Shutdown requested by user (Ctrl+C)")
|
|
205
|
+
finally:
|
|
206
|
+
logger.info("Disconnecting from printer")
|
|
207
|
+
client.disconnect()
|
|
208
|
+
logger.info("Bambu MQTT listener stopped")
|
bambu_mqtt/logger.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Configure application-wide colored logging."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ColoredFormatter(logging.Formatter):
|
|
8
|
+
"""Format log records with colors based on severity."""
|
|
9
|
+
|
|
10
|
+
RESET = "\033[0m"
|
|
11
|
+
COLORS = {
|
|
12
|
+
logging.DEBUG: "\033[36m", # Cyan
|
|
13
|
+
logging.INFO: "\033[32m", # Green
|
|
14
|
+
logging.WARNING: "\033[33m", # Yellow
|
|
15
|
+
logging.ERROR: "\033[31m", # Red
|
|
16
|
+
logging.CRITICAL: "\033[1;31m", # Bold red
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
20
|
+
"""Format a log record with a colored level name."""
|
|
21
|
+
original_levelname = record.levelname
|
|
22
|
+
color = self.COLORS.get(record.levelno, self.RESET)
|
|
23
|
+
|
|
24
|
+
record.levelname = f"{color}{original_levelname}{self.RESET}"
|
|
25
|
+
try:
|
|
26
|
+
return super().format(record)
|
|
27
|
+
finally:
|
|
28
|
+
record.levelname = original_levelname
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
32
|
+
"""Configure colored console logging."""
|
|
33
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
34
|
+
handler.setFormatter(
|
|
35
|
+
ColoredFormatter(
|
|
36
|
+
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
37
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
root_logger = logging.getLogger()
|
|
42
|
+
root_logger.setLevel(level)
|
|
43
|
+
root_logger.handlers.clear()
|
|
44
|
+
root_logger.addHandler(handler)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MQTT client with TLS support to connect to an MQTT broker.
|
|
3
|
+
|
|
4
|
+
Project: Bambu MQTT
|
|
5
|
+
Author: dvanhecke
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ssl
|
|
9
|
+
import paho.mqtt.client as mqtt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MQTTSClient(mqtt.Client):
|
|
13
|
+
"""
|
|
14
|
+
MQTT client that supports custom Server Name Indication (SNI) for TLS certificates.
|
|
15
|
+
|
|
16
|
+
This is useful when the broker's certificate hostname does not match the connection hostname.
|
|
17
|
+
See: https://github.com/eclipse-paho/paho.mqtt.python/issues/734#issuecomment-2256633060
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *args, server_name=None, **kwargs):
|
|
21
|
+
super().__init__(*args, **kwargs)
|
|
22
|
+
self._server_name = server_name
|
|
23
|
+
|
|
24
|
+
def _ssl_wrap_socket(self, tcp_sock) -> ssl.SSLSocket:
|
|
25
|
+
"""
|
|
26
|
+
Wrap the TCP socket with SSL, overriding the host for custom SNI if specified.
|
|
27
|
+
"""
|
|
28
|
+
orig_host = self._host
|
|
29
|
+
if self._server_name:
|
|
30
|
+
self._host = self._server_name
|
|
31
|
+
res = super()._ssl_wrap_socket(tcp_sock)
|
|
32
|
+
self._host = orig_host
|
|
33
|
+
return res
|
bambu_mqtt/parser.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Parse and normalize Bambu Lab printer telemetry.
|
|
2
|
+
|
|
3
|
+
This module extracts useful printer and AMS metrics from MQTT status
|
|
4
|
+
payloads and converts them into a structured dictionary suitable for
|
|
5
|
+
logging, monitoring, and time-series storage.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Parser:
|
|
13
|
+
"""Parse and normalize Bambu Lab printer telemetry."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, payload: str) -> None:
|
|
16
|
+
self._payload: str = payload
|
|
17
|
+
self._data: Dict[str, Any] = self.parse_data()
|
|
18
|
+
|
|
19
|
+
def parse_data(self) -> Dict[str, Any]:
|
|
20
|
+
"""Parse the MQTT payload and return a structured dictionary."""
|
|
21
|
+
try:
|
|
22
|
+
parsed = json.loads(self._payload)
|
|
23
|
+
# Normalize keys and values as needed
|
|
24
|
+
normalized_data = {k.lower(): v for k, v in parsed.items()}
|
|
25
|
+
return normalized_data
|
|
26
|
+
except json.JSONDecodeError as e:
|
|
27
|
+
raise ValueError("Invalid JSON payload") from e
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def data(self) -> Dict[str, Any]:
|
|
31
|
+
"""Return the parsed and normalized data."""
|
|
32
|
+
return self._data
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def payload(self) -> str:
|
|
36
|
+
"""Return the original payload."""
|
|
37
|
+
return self._payload
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
"""Return a string representation of the parsed data."""
|
|
41
|
+
return json.dumps(self._data, indent=2)
|
bambu_mqtt/telemetry.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
|
|
2
|
+
"""Parse and normalize Bambu Lab H2S printer telemetry."""
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _as_float(value: Any) -> float | None:
|
|
9
|
+
"""Convert a value to float."""
|
|
10
|
+
try:
|
|
11
|
+
return float(value) if value is not None else None
|
|
12
|
+
except (TypeError, ValueError):
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _as_int(value: Any) -> int | None:
|
|
17
|
+
"""Convert a value to int."""
|
|
18
|
+
try:
|
|
19
|
+
return int(value) if value is not None else None
|
|
20
|
+
except (TypeError, ValueError):
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_str(value: Any) -> str | None:
|
|
25
|
+
"""Convert a value to string, if valid."""
|
|
26
|
+
return value if isinstance(value, str) else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PrinterStatus:
|
|
31
|
+
"""Printer and print-job information."""
|
|
32
|
+
|
|
33
|
+
state: str | None = None
|
|
34
|
+
progress: float | None = None
|
|
35
|
+
current_layer: int | None = None
|
|
36
|
+
total_layers: int | None = None
|
|
37
|
+
remaining_minutes: int | None = None
|
|
38
|
+
job_name: str | None = None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_dict(cls, data: dict[str, Any]) -> "PrinterStatus":
|
|
42
|
+
return cls(
|
|
43
|
+
state=_as_str(data.get("gcode_state")),
|
|
44
|
+
progress=_as_float(data.get("mc_percent")),
|
|
45
|
+
current_layer=_as_int(data.get("layer_num")),
|
|
46
|
+
total_layers=_as_int(data.get("total_layer_num")),
|
|
47
|
+
remaining_minutes=_as_int(data.get("mc_remaining_time")),
|
|
48
|
+
job_name=_as_str(data.get("subtask_name")),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Temperature:
|
|
55
|
+
"""Printer temperature readings."""
|
|
56
|
+
|
|
57
|
+
nozzle: float | None = None
|
|
58
|
+
nozzle_target: float | None = None
|
|
59
|
+
bed: float | None = None
|
|
60
|
+
bed_target: float | None = None
|
|
61
|
+
chamber: float | None = None
|
|
62
|
+
chamber_target: float | None = None
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: dict[str, Any]) -> "Temperature":
|
|
66
|
+
"""Create temperature readings from Bambu MQTT data."""
|
|
67
|
+
|
|
68
|
+
device = data.get("device", {})
|
|
69
|
+
ctc = device.get("ctc", {}) if isinstance(device, dict) else {}
|
|
70
|
+
ctc_info = ctc.get("info", {}) if isinstance(ctc, dict) else {}
|
|
71
|
+
|
|
72
|
+
return cls(
|
|
73
|
+
nozzle=_as_float(data.get("nozzle_temper")),
|
|
74
|
+
nozzle_target=_as_float(data.get("nozzle_target_temper")),
|
|
75
|
+
bed=_as_float(data.get("bed_temper")),
|
|
76
|
+
bed_target=_as_float(data.get("bed_target_temper")),
|
|
77
|
+
chamber=(
|
|
78
|
+
_as_float(ctc_info.get("temp"))
|
|
79
|
+
if isinstance(ctc_info, dict)
|
|
80
|
+
else None
|
|
81
|
+
),
|
|
82
|
+
chamber_target=None,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Connectivity:
|
|
87
|
+
"""Printer network connectivity."""
|
|
88
|
+
|
|
89
|
+
wifi_signal: str | None = None
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, data: dict[str, Any]) -> "Connectivity":
|
|
93
|
+
return cls(
|
|
94
|
+
wifi_signal=_as_str(data.get("wifi_signal")),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class AMSTray:
|
|
100
|
+
"""Individual filament tray."""
|
|
101
|
+
|
|
102
|
+
id: int | None = None
|
|
103
|
+
filament_type: str | None = None
|
|
104
|
+
color: str | None = None
|
|
105
|
+
remaining: int | None = None
|
|
106
|
+
nozzle_temp_min: float | None = None
|
|
107
|
+
nozzle_temp_max: float | None = None
|
|
108
|
+
tray_diameter: float | None = None
|
|
109
|
+
tray_weight: float | None = None
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_dict(cls, data: dict[str, Any]) -> "AMSTray":
|
|
113
|
+
remaining = _as_int(data.get("remain"))
|
|
114
|
+
|
|
115
|
+
return cls(
|
|
116
|
+
id=_as_int(data.get("id")),
|
|
117
|
+
filament_type=_as_str(data.get("tray_type")),
|
|
118
|
+
color=_as_str(data.get("tray_color")),
|
|
119
|
+
remaining=(
|
|
120
|
+
remaining if remaining is not None and remaining >= 0 else None
|
|
121
|
+
),
|
|
122
|
+
nozzle_temp_min=_as_float(data.get("nozzle_temp_min")),
|
|
123
|
+
nozzle_temp_max=_as_float(data.get("nozzle_temp_max")),
|
|
124
|
+
tray_diameter=_as_float(data.get("tray_diameter")),
|
|
125
|
+
tray_weight=_as_float(data.get("tray_weight")),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class AMSStatus:
|
|
131
|
+
"""One AMS unit and its filament trays."""
|
|
132
|
+
|
|
133
|
+
id: str | None = None
|
|
134
|
+
temperature: float | None = None
|
|
135
|
+
humidity: float | None = None
|
|
136
|
+
humidity_raw: float | None = None
|
|
137
|
+
trays: list[AMSTray] = field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: dict[str, Any]) -> "AMSStatus":
|
|
141
|
+
tray_data = data.get("tray", [])
|
|
142
|
+
|
|
143
|
+
trays = [
|
|
144
|
+
AMSTray.from_dict(tray)
|
|
145
|
+
for tray in tray_data
|
|
146
|
+
if isinstance(tray, dict)
|
|
147
|
+
] if isinstance(tray_data, list) else []
|
|
148
|
+
|
|
149
|
+
return cls(
|
|
150
|
+
id=_as_str(data.get("id")),
|
|
151
|
+
temperature=_as_float(data.get("temp")),
|
|
152
|
+
humidity=_as_float(data.get("humidity")),
|
|
153
|
+
humidity_raw=_as_float(data.get("humidity_raw")),
|
|
154
|
+
trays=trays,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class Telemetry:
|
|
160
|
+
"""Normalized Bambu Lab printer telemetry."""
|
|
161
|
+
|
|
162
|
+
printer: PrinterStatus = field(default_factory=PrinterStatus)
|
|
163
|
+
temperatures: Temperature = field(default_factory=Temperature)
|
|
164
|
+
connectivity: Connectivity = field(default_factory=Connectivity)
|
|
165
|
+
ams: list[AMSStatus] = field(default_factory=list)
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_dict(cls, payload: dict[str, Any]) -> "Telemetry":
|
|
169
|
+
"""Parse a complete Bambu MQTT status payload."""
|
|
170
|
+
|
|
171
|
+
print_data = payload.get("print", payload)
|
|
172
|
+
|
|
173
|
+
if not isinstance(print_data, dict):
|
|
174
|
+
raise ValueError("Invalid MQTT payload: missing print dictionary")
|
|
175
|
+
|
|
176
|
+
ams_container = print_data.get("ams", {})
|
|
177
|
+
ams_units = (
|
|
178
|
+
ams_container.get("ams", [])
|
|
179
|
+
if isinstance(ams_container, dict)
|
|
180
|
+
else []
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
ams = [
|
|
184
|
+
AMSStatus.from_dict(unit)
|
|
185
|
+
for unit in ams_units
|
|
186
|
+
if isinstance(unit, dict)
|
|
187
|
+
] if isinstance(ams_units, list) else []
|
|
188
|
+
|
|
189
|
+
return cls(
|
|
190
|
+
printer=PrinterStatus.from_dict(print_data),
|
|
191
|
+
temperatures=Temperature.from_dict(print_data),
|
|
192
|
+
connectivity=Connectivity.from_dict(print_data),
|
|
193
|
+
ams=ams,
|
|
194
|
+
)
|
bambu_mqtt/utils.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for Bambu MQTT connections and TLS setup.
|
|
3
|
+
|
|
4
|
+
This module provides helpers to:
|
|
5
|
+
|
|
6
|
+
- Create a TLS/SSL context for secure connections to local Bambu printers,
|
|
7
|
+
automatically referencing the package-local certificate.
|
|
8
|
+
- Connect to a local printer over MQTT using TLS, handling authentication
|
|
9
|
+
and server name indication (SNI).
|
|
10
|
+
|
|
11
|
+
All functions are designed for LAN-only printer setups with developer mode
|
|
12
|
+
enabled and can be integrated with Telegraf / InfluxDB pipelines.
|
|
13
|
+
|
|
14
|
+
Project: Bambu MQTT
|
|
15
|
+
Author: dvanhecke
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import ssl
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from cryptography import x509
|
|
21
|
+
from cryptography.hazmat.backends import default_backend
|
|
22
|
+
|
|
23
|
+
from bambu_mqtt.mqtt_client import MQTTSClient
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_device_id_from_cert(cert_path: str | Path) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Extracts the Bambu printer device ID from the provided PEM certificate.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cert_path (str | Path): Path to the PEM certificate file.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
str: The device ID embedded in the certificate CN.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
FileNotFoundError: If the certificate file does not exist.
|
|
38
|
+
ValueError: If the device ID cannot be found in the certificate.
|
|
39
|
+
"""
|
|
40
|
+
cert_path = Path(cert_path)
|
|
41
|
+
if not cert_path.exists():
|
|
42
|
+
raise FileNotFoundError(f"Certificate file not found: {cert_path}")
|
|
43
|
+
|
|
44
|
+
with cert_path.open("rb") as f:
|
|
45
|
+
cert_data = f.read()
|
|
46
|
+
|
|
47
|
+
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
|
|
48
|
+
subject = cert.subject
|
|
49
|
+
|
|
50
|
+
# The device ID is usually in the Common Name (CN)
|
|
51
|
+
cn = subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
|
|
52
|
+
if not cn:
|
|
53
|
+
raise ValueError("Device ID not found in certificate CN")
|
|
54
|
+
|
|
55
|
+
return cn[0].value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_local_ssl_context(ca_cert_path: str) -> ssl.SSLContext:
|
|
59
|
+
"""
|
|
60
|
+
Create an SSL context for TLS connections to local Bambu printers.
|
|
61
|
+
|
|
62
|
+
This context validates the printer's certificate located in the package's
|
|
63
|
+
`certs/` folder, relaxing strict X.509 verification for LAN-only printer
|
|
64
|
+
certificates. When using the context, you must call:
|
|
65
|
+
context.wrap_socket(sock, servername=printer_serial_number)
|
|
66
|
+
to correctly set the Server Name Indication (SNI) for the printer.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
ssl.SSLContext: Configured SSL context for local printer connections.
|
|
70
|
+
"""
|
|
71
|
+
# Locate the certificate in the bambu_mqtt package
|
|
72
|
+
|
|
73
|
+
context = ssl.create_default_context(cafile=ca_cert_path)
|
|
74
|
+
context.verify_flags &= ~ssl.VERIFY_X509_STRICT
|
|
75
|
+
return context
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def connect_local_mqtt(
|
|
79
|
+
hostname: str, device_id: str, access_code: str, certfile: str
|
|
80
|
+
) -> MQTTSClient:
|
|
81
|
+
"""
|
|
82
|
+
Connect to a local Bambu printer over MQTT using TLS.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
hostname (str): The LAN IP or hostname of the printer.
|
|
86
|
+
device_id (str): The printer's device ID, used for SNI in TLS.
|
|
87
|
+
access_code (str): Printer-specific password for MQTT authentication.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
MQTTSClient: A connected MQTT client instance ready to subscribe or publish.
|
|
91
|
+
"""
|
|
92
|
+
client = MQTTSClient(server_name=device_id)
|
|
93
|
+
client.tls_set_context(create_local_ssl_context(certfile))
|
|
94
|
+
client.username_pw_set("bblp", access_code)
|
|
95
|
+
client.connect(hostname, port=8883, keepalive=60)
|
|
96
|
+
return client
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Define the abstract interface for telemetry writers."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
from bambu_mqtt.telemetry import Telemetry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseWriter(ABC):
|
|
11
|
+
"""Abstract base class for telemetry output backends."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def write(self, telemetry: Telemetry) -> None:
|
|
15
|
+
"""Write a telemetry snapshot to the configured backend.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
telemetry (Telemetry): Telemetry to write.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
NotImplementedError: Not yet implemented.
|
|
22
|
+
"""
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def connect(self) -> None:
|
|
27
|
+
"""Establish a connection.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
NotImplementedError: Not yet implemented.
|
|
31
|
+
"""
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def disconnect(self) -> None:
|
|
36
|
+
"""Close the connection.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
NotImplementedError: Not yet implemented.
|
|
40
|
+
"""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> Self:
|
|
44
|
+
"""Connect to the backend and enter the runtime context.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Self: Writer object.
|
|
48
|
+
"""
|
|
49
|
+
self.connect()
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(
|
|
53
|
+
self,
|
|
54
|
+
exc_type: type[BaseException] | None,
|
|
55
|
+
exc_value: BaseException | None,
|
|
56
|
+
traceback: TracebackType | None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Disconnect from the backend when leaving the runtime context.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
exc_type (type[BaseException] | None): exit type.
|
|
62
|
+
exc_value (BaseException | None): exit value.
|
|
63
|
+
traceback (TracebackType | None): traceback.
|
|
64
|
+
"""
|
|
65
|
+
self.disconnect()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Write Bambu Lab printer telemetry to InfluxDB.
|
|
2
|
+
|
|
3
|
+
This module provides an InfluxDB-backed implementation of the
|
|
4
|
+
:class:`BaseWriter` interface for storing printer and AMS telemetry
|
|
5
|
+
as time-series data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from influxdb_client import InfluxDBClient, Point
|
|
10
|
+
from influxdb_client.client.write_api import SYNCHRONOUS, WriteApi
|
|
11
|
+
|
|
12
|
+
from bambu_mqtt.telemetry import Telemetry
|
|
13
|
+
from bambu_mqtt.writers.base import BaseWriter
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InfluxDBWriter(BaseWriter):
|
|
19
|
+
"""Write Bambu Lab printer telemetry to InfluxDB.
|
|
20
|
+
|
|
21
|
+
This class implements the :class:`BaseWriter` interface for storing
|
|
22
|
+
printer and AMS telemetry as time-series data in an InfluxDB backend.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
url: str,
|
|
28
|
+
token: str,
|
|
29
|
+
org: str,
|
|
30
|
+
bucket: str,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize the InfluxDBWriter.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
url (str): InfluxDB server URL.
|
|
36
|
+
token (str): InfluxDB authentication token.
|
|
37
|
+
org (str): InfluxDB organization name.
|
|
38
|
+
bucket (str): InfluxDB bucket name.
|
|
39
|
+
"""
|
|
40
|
+
self._url = url
|
|
41
|
+
self._token = token
|
|
42
|
+
self._org = org
|
|
43
|
+
self._bucket = bucket
|
|
44
|
+
self._client: InfluxDBClient | None = None
|
|
45
|
+
self._write_api: WriteApi | None = None
|
|
46
|
+
|
|
47
|
+
def write(self, telemetry: Telemetry) -> None:
|
|
48
|
+
"""Write printer, AMS, and filament tray telemetry."""
|
|
49
|
+
|
|
50
|
+
if self._write_api is None:
|
|
51
|
+
raise RuntimeError("InfluxDB writer is not connected")
|
|
52
|
+
|
|
53
|
+
points: list[Point] = []
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------
|
|
56
|
+
# Printer measurement
|
|
57
|
+
# --------------------------------------------------
|
|
58
|
+
|
|
59
|
+
printer = telemetry.printer
|
|
60
|
+
temperatures = telemetry.temperatures
|
|
61
|
+
connectivity = telemetry.connectivity
|
|
62
|
+
|
|
63
|
+
printer_fields = {
|
|
64
|
+
"state": printer.state,
|
|
65
|
+
"progress": printer.progress,
|
|
66
|
+
"current_layer": printer.current_layer,
|
|
67
|
+
"total_layers": printer.total_layers,
|
|
68
|
+
"remaining_minutes": printer.remaining_minutes,
|
|
69
|
+
"job_name": printer.job_name,
|
|
70
|
+
"nozzle_temperature": temperatures.nozzle,
|
|
71
|
+
"nozzle_target_temperature": temperatures.nozzle_target,
|
|
72
|
+
"bed_temperature": temperatures.bed,
|
|
73
|
+
"bed_target_temperature": temperatures.bed_target,
|
|
74
|
+
"chamber_temperature": temperatures.chamber,
|
|
75
|
+
"chamber_target_temperature": temperatures.chamber_target,
|
|
76
|
+
"wifi_signal": connectivity.wifi_signal,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
printer_point = Point("printer")
|
|
80
|
+
|
|
81
|
+
for name, value in printer_fields.items():
|
|
82
|
+
if value is not None:
|
|
83
|
+
printer_point = printer_point.field(name, value)
|
|
84
|
+
|
|
85
|
+
if any(value is not None for value in printer_fields.values()):
|
|
86
|
+
points.append(printer_point)
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------
|
|
89
|
+
# AMS measurements
|
|
90
|
+
# --------------------------------------------------
|
|
91
|
+
|
|
92
|
+
for ams in telemetry.ams:
|
|
93
|
+
|
|
94
|
+
# AMS unit measurement
|
|
95
|
+
ams_fields = {
|
|
96
|
+
"temperature": ams.temperature,
|
|
97
|
+
"humidity": ams.humidity,
|
|
98
|
+
"humidity_raw": ams.humidity_raw,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
ams_point = Point("ams")
|
|
102
|
+
|
|
103
|
+
if ams.id is not None:
|
|
104
|
+
ams_point = ams_point.tag("ams_id", ams.id)
|
|
105
|
+
|
|
106
|
+
for name, value in ams_fields.items():
|
|
107
|
+
if value is not None:
|
|
108
|
+
ams_point = ams_point.field(name, value)
|
|
109
|
+
|
|
110
|
+
if any(value is not None for value in ams_fields.values()):
|
|
111
|
+
points.append(ams_point)
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------
|
|
114
|
+
# Individual filament tray measurements
|
|
115
|
+
# --------------------------------------------------
|
|
116
|
+
|
|
117
|
+
for tray in ams.trays:
|
|
118
|
+
tray_fields = {
|
|
119
|
+
"filament_type": tray.filament_type,
|
|
120
|
+
"color": tray.color,
|
|
121
|
+
"remaining": tray.remaining,
|
|
122
|
+
"nozzle_temp_min": tray.nozzle_temp_min,
|
|
123
|
+
"nozzle_temp_max": tray.nozzle_temp_max,
|
|
124
|
+
"tray_diameter": tray.tray_diameter,
|
|
125
|
+
"tray_weight": tray.tray_weight,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
tray_point = Point("ams_tray")
|
|
129
|
+
|
|
130
|
+
if ams.id is not None:
|
|
131
|
+
tray_point = tray_point.tag("ams_id", ams.id)
|
|
132
|
+
|
|
133
|
+
if tray.id is not None:
|
|
134
|
+
tray_point = tray_point.tag(
|
|
135
|
+
"tray_id",
|
|
136
|
+
str(tray.id),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
for name, value in tray_fields.items():
|
|
140
|
+
if value is not None:
|
|
141
|
+
tray_point = tray_point.field(name, value)
|
|
142
|
+
|
|
143
|
+
if any(value is not None for value in tray_fields.values()):
|
|
144
|
+
points.append(tray_point)
|
|
145
|
+
|
|
146
|
+
# --------------------------------------------------
|
|
147
|
+
# Write measurements
|
|
148
|
+
# --------------------------------------------------
|
|
149
|
+
|
|
150
|
+
if not points:
|
|
151
|
+
logger.debug("No telemetry data available to write")
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
self._write_api.write(
|
|
156
|
+
bucket=self._bucket,
|
|
157
|
+
org=self._org,
|
|
158
|
+
record=points,
|
|
159
|
+
)
|
|
160
|
+
except Exception:
|
|
161
|
+
logger.exception("Failed to write telemetry to InfluxDB")
|
|
162
|
+
raise
|
|
163
|
+
|
|
164
|
+
logger.debug(
|
|
165
|
+
"Successfully wrote %d telemetry points to InfluxDB",
|
|
166
|
+
len(points),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def connect(self) -> None:
|
|
170
|
+
"""Establish an InfluxDB connection."""
|
|
171
|
+
if self._client is not None:
|
|
172
|
+
logger.warning("InfluxDB writer is already connected")
|
|
173
|
+
raise RuntimeError("InfluxDB writer is already connected")
|
|
174
|
+
|
|
175
|
+
logger.info("Connecting to InfluxDB at %s", self._url)
|
|
176
|
+
|
|
177
|
+
client = InfluxDBClient(
|
|
178
|
+
url=self._url,
|
|
179
|
+
token=self._token,
|
|
180
|
+
org=self._org,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
write_api = client.write_api(write_options=SYNCHRONOUS)
|
|
185
|
+
except Exception:
|
|
186
|
+
logger.exception("Failed to initialise InfluxDB write API")
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
client.close()
|
|
190
|
+
except Exception:
|
|
191
|
+
logger.exception(
|
|
192
|
+
"Failed to close InfluxDB client after initialisation failure"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
self._client = client
|
|
198
|
+
self._write_api = write_api
|
|
199
|
+
|
|
200
|
+
logger.info("Successfully connected to InfluxDB")
|
|
201
|
+
|
|
202
|
+
def disconnect(self) -> None:
|
|
203
|
+
"""Close the InfluxDB connection."""
|
|
204
|
+
logger.info("Disconnecting from InfluxDB")
|
|
205
|
+
|
|
206
|
+
write_api = self._write_api
|
|
207
|
+
client = self._client
|
|
208
|
+
|
|
209
|
+
# Reset state before attempting cleanup.
|
|
210
|
+
self._write_api = None
|
|
211
|
+
self._client = None
|
|
212
|
+
|
|
213
|
+
cleanup_error = None
|
|
214
|
+
|
|
215
|
+
if write_api is not None:
|
|
216
|
+
try:
|
|
217
|
+
write_api.close()
|
|
218
|
+
logger.debug("InfluxDB write API closed")
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
logger.exception("Failed to close InfluxDB write API")
|
|
221
|
+
cleanup_error = exc
|
|
222
|
+
|
|
223
|
+
if client is not None:
|
|
224
|
+
try:
|
|
225
|
+
client.close()
|
|
226
|
+
logger.debug("InfluxDB client closed")
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
logger.exception("Failed to close InfluxDB client")
|
|
229
|
+
if cleanup_error is None:
|
|
230
|
+
cleanup_error = exc
|
|
231
|
+
|
|
232
|
+
if cleanup_error is not None:
|
|
233
|
+
raise cleanup_error
|
|
234
|
+
|
|
235
|
+
logger.info("Successfully disconnected from InfluxDB")
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bambu-mqtt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bambu Lab MQTT telemetry collector with InfluxDB integration
|
|
5
|
+
Author: NullVanta
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/NullVanta/bambu-mqtt
|
|
8
|
+
Project-URL: Repository, https://github.com/NullVanta/bambu-mqtt
|
|
9
|
+
Project-URL: Issues, https://github.com/NullVanta/bambu-mqtt/issues
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: paho-mqtt
|
|
14
|
+
Requires-Dist: cryptography
|
|
15
|
+
Requires-Dist: influxdb-client
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# bambu-mqtt
|
|
19
|
+
|
|
20
|
+
**A Python-based MQTT telemetry collector for Bambu Lab 3D printers.**
|
|
21
|
+
|
|
22
|
+
`bambu-mqtt` connects to a Bambu Lab printer over its local MQTT interface using TLS, parses printer and AMS telemetry into structured Python dataclasses, and exports the collected data to InfluxDB for monitoring and visualization.
|
|
23
|
+
|
|
24
|
+
Designed for self-hosted monitoring setups using InfluxDB and Grafana.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- Connects to Bambu Lab printers through local MQTT.
|
|
29
|
+
- Uses TLS certificate authentication.
|
|
30
|
+
- Parses MQTT status messages into structured telemetry objects.
|
|
31
|
+
- Tracks printer status, print progress and layer information.
|
|
32
|
+
- Collects nozzle, bed and chamber temperature readings.
|
|
33
|
+
- Monitors Wi-Fi signal strength.
|
|
34
|
+
- Supports multiple AMS units and their individual filament trays.
|
|
35
|
+
- Exports telemetry to InfluxDB.
|
|
36
|
+
- Uses Python logging for configurable runtime diagnostics.
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
```mermaid
|
|
41
|
+
flowchart TD
|
|
42
|
+
A["Bambu Lab Printer"] -->|MQTT over TLS| B["MQTT Client"]
|
|
43
|
+
B --> C["Telemetry Parser"]
|
|
44
|
+
C --> D["Python Dataclasses"]
|
|
45
|
+
D --> E["InfluxDB Writer"]
|
|
46
|
+
E --> F[("InfluxDB")]
|
|
47
|
+
F --> G["Grafana"]
|
|
48
|
+
|
|
49
|
+
subgraph Collector["bambu-mqtt"]
|
|
50
|
+
B
|
|
51
|
+
C
|
|
52
|
+
D
|
|
53
|
+
E
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
- A compatible Bambu Lab printer with local MQTT access.
|
|
60
|
+
- Developer mode enabled on the printer, where required.
|
|
61
|
+
- Python.
|
|
62
|
+
- An MQTT TLS certificate.
|
|
63
|
+
- An InfluxDB 2.x instance.
|
|
64
|
+
- An InfluxDB API token with write permissions.
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
Clone the repository:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/NullVanta/bambu-mqtt.git
|
|
72
|
+
cd bambu-mqtt
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Install the project dependencies using Pipenv:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pipenv install
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For development dependencies:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pipenv install --dev
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
Before starting the collector, obtain the required printer connection information:
|
|
90
|
+
|
|
91
|
+
| Parameter | Description |
|
|
92
|
+
|---|---|
|
|
93
|
+
| Host | Printer's local IP address |
|
|
94
|
+
| Access code | Printer's LAN access code |
|
|
95
|
+
| Certificate | TLS certificate used for authentication |
|
|
96
|
+
| InfluxDB URL | InfluxDB server address |
|
|
97
|
+
| InfluxDB token | API token with bucket write access |
|
|
98
|
+
| InfluxDB organization | Target InfluxDB organization |
|
|
99
|
+
| InfluxDB bucket | Target bucket for telemetry |
|
|
100
|
+
|
|
101
|
+
The printer device ID is derived from the certificate's Common Name (CN).
|
|
102
|
+
|
|
103
|
+
Set your InfluxDB token as an environment variable:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
export INFLUXDB_TOKEN="your-influxdb-token"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Usage
|
|
110
|
+
|
|
111
|
+
Start the collector:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pipenv run python -m bambu_mqtt \
|
|
115
|
+
--host 192.168.0.240 \
|
|
116
|
+
--cert ./blcert.pem \
|
|
117
|
+
--access-code YOUR_ACCESS_CODE \
|
|
118
|
+
--influx-url http://localhost:8086 \
|
|
119
|
+
--influx-org YOUR_ORG \
|
|
120
|
+
--influx-bucket bambulab
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Command-line arguments
|
|
124
|
+
|
|
125
|
+
| Argument | Description |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `-H`, `--host` | Printer IP address |
|
|
128
|
+
| `--access-code` | Printer LAN access code |
|
|
129
|
+
| `-c`, `--cert` | Path to the TLS certificate |
|
|
130
|
+
| `-l`, `--log-level` | Logging verbosity |
|
|
131
|
+
| `-v`, `--version` | Display application version |
|
|
132
|
+
| `--influx-url` | InfluxDB server URL |
|
|
133
|
+
| `--influx-token` | InfluxDB API token |
|
|
134
|
+
| `--influx-org` | InfluxDB organization |
|
|
135
|
+
| `--influx-bucket` | InfluxDB destination bucket |
|
|
136
|
+
|
|
137
|
+
Supported log levels:
|
|
138
|
+
|
|
139
|
+
`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
|
|
140
|
+
|
|
141
|
+
The default logging level is `INFO`.
|
|
142
|
+
|
|
143
|
+
## Telemetry
|
|
144
|
+
|
|
145
|
+
Telemetry is normalized into structured Python dataclasses before being passed to the configured writer.
|
|
146
|
+
|
|
147
|
+
### Printer
|
|
148
|
+
|
|
149
|
+
- Printer state
|
|
150
|
+
- Print progress
|
|
151
|
+
- Current layer
|
|
152
|
+
- Total layers
|
|
153
|
+
- Remaining print time
|
|
154
|
+
- Job name
|
|
155
|
+
|
|
156
|
+
### Temperatures
|
|
157
|
+
|
|
158
|
+
- Nozzle temperature
|
|
159
|
+
- Nozzle target temperature
|
|
160
|
+
- Bed temperature
|
|
161
|
+
- Bed target temperature
|
|
162
|
+
- Chamber temperature
|
|
163
|
+
- Chamber target temperature, when available
|
|
164
|
+
|
|
165
|
+
### Connectivity
|
|
166
|
+
|
|
167
|
+
- Wi-Fi signal strength
|
|
168
|
+
|
|
169
|
+
### AMS
|
|
170
|
+
|
|
171
|
+
- AMS identifier
|
|
172
|
+
- AMS temperature
|
|
173
|
+
- Humidity
|
|
174
|
+
- Raw humidity
|
|
175
|
+
- Individual filament tray information
|
|
176
|
+
|
|
177
|
+
### Filament trays
|
|
178
|
+
|
|
179
|
+
- Tray identifier
|
|
180
|
+
- Filament type
|
|
181
|
+
- Filament colour
|
|
182
|
+
- Remaining filament percentage
|
|
183
|
+
- Minimum and maximum nozzle temperatures
|
|
184
|
+
- Filament diameter
|
|
185
|
+
- Filament spool weight
|
|
186
|
+
|
|
187
|
+
Unavailable telemetry fields are represented as `None`.
|
|
188
|
+
|
|
189
|
+
## InfluxDB measurements
|
|
190
|
+
|
|
191
|
+
The InfluxDB writer currently exports three measurements:
|
|
192
|
+
|
|
193
|
+
| Measurement | Contents |
|
|
194
|
+
|---|---|
|
|
195
|
+
| `printer` | Printer status, temperatures and connectivity |
|
|
196
|
+
| `ams` | AMS temperature and humidity |
|
|
197
|
+
| `ams_tray` | Individual filament tray information |
|
|
198
|
+
|
|
199
|
+
These measurements can be queried in Grafana using Flux.
|
|
200
|
+
|
|
201
|
+
## Logging
|
|
202
|
+
|
|
203
|
+
The application uses Python's standard logging framework.
|
|
204
|
+
|
|
205
|
+
Example output:
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
INFO | Connected to printer
|
|
209
|
+
DEBUG | Subscribed to MQTT topic
|
|
210
|
+
INFO | Received MQTT message, parsing now
|
|
211
|
+
INFO | Connecting to InfluxDB
|
|
212
|
+
INFO | Successfully connected to InfluxDB
|
|
213
|
+
DEBUG | Successfully wrote 6 telemetry points
|
|
214
|
+
INFO | Telemetry written to InfluxDB successfully
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Logging verbosity can be adjusted using `--log-level`.
|
|
218
|
+
|
|
219
|
+
## Development
|
|
220
|
+
|
|
221
|
+
The project uses Pipenv for dependency management.
|
|
222
|
+
|
|
223
|
+
Install development dependencies:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
pipenv install --dev
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Run the application:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
pipenv run python -m bambu_mqtt
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Code quality
|
|
236
|
+
|
|
237
|
+
The project uses:
|
|
238
|
+
|
|
239
|
+
- Black for code formatting.
|
|
240
|
+
- Pylint for static code analysis.
|
|
241
|
+
|
|
242
|
+
Run the checks:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
pipenv run black --check bambu_mqtt
|
|
246
|
+
pipenv run pylint bambu_mqtt
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Project status
|
|
250
|
+
|
|
251
|
+
`bambu-mqtt` is under active development.
|
|
252
|
+
|
|
253
|
+
The initial release focuses on local MQTT connectivity, telemetry parsing and InfluxDB integration.
|
|
254
|
+
|
|
255
|
+
## License
|
|
256
|
+
|
|
257
|
+
See [LICENSE](LICENSE) for licensing information.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
bambu_mqtt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
bambu_mqtt/__main__.py,sha256=sH2FEwMRoUTevdijltRWBZlZMMo8wRE833B-nSUxF5k,6707
|
|
3
|
+
bambu_mqtt/logger.py,sha256=4YgPxMDLsnckgIddZAIClhzHJTY0rOmZN4iNmziIF8Y,1342
|
|
4
|
+
bambu_mqtt/mqtt_client.py,sha256=YqFub3gofCgu9oKVkpogRFgjjCRrcQz9FgH9L43meQo,996
|
|
5
|
+
bambu_mqtt/parser.py,sha256=34ogrDDVT8aT2FhYyRnEdqvNriV16uBJUHvNpfcyAoE,1327
|
|
6
|
+
bambu_mqtt/telemetry.py,sha256=rtJXnmK5aXSbYC53HYmsyF8bSZnRwcZ3cGmrrD1uGgk,5894
|
|
7
|
+
bambu_mqtt/utils.py,sha256=h2ZTV__daTGIKLCHimGRjj26KCIF6JTQTHM2E1SqT7M,3219
|
|
8
|
+
bambu_mqtt/writers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
bambu_mqtt/writers/base.py,sha256=DAMnpKzdhSAk9mm1M91F6VvcFyg4kL7-eQDM2pR9wIQ,1699
|
|
10
|
+
bambu_mqtt/writers/influxdb.py,sha256=jyJhZKNkk7ohBad5tEJhF9Xl5zPjT-IjLmRRkbl44BQ,7697
|
|
11
|
+
bambu_mqtt-0.1.0.dist-info/licenses/LICENSE,sha256=OXw8z_ZCkmzMLj-0ydLyMyFJrWEVFu_oVICreZ8Fq6c,1065
|
|
12
|
+
bambu_mqtt-0.1.0.dist-info/METADATA,sha256=PiGYH2vUX9pVr-c8nLa7t21a9KXk4gxJc4zCEQkJFlQ,6013
|
|
13
|
+
bambu_mqtt-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
14
|
+
bambu_mqtt-0.1.0.dist-info/top_level.txt,sha256=yRTcJp-gT6B2HWNcm7LR9hVAdN-hI6wk2pJwFJk2bm0,11
|
|
15
|
+
bambu_mqtt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 YourName
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bambu_mqtt
|