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 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)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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