|
| 1 | +import json |
| 2 | +import ssl |
| 3 | +import time |
| 4 | +import socket |
| 5 | +from datetime import datetime, timedelta |
| 6 | +from slack_sdk import WebClient |
| 7 | +from slack_sdk.errors import SlackApiError |
| 8 | +import paho.mqtt.client as mqtt |
| 9 | + |
| 10 | +# ===================================================================== |
| 11 | +# GLOBAL CONFIGURATION |
| 12 | +# ===================================================================== |
| 13 | +from bambu_tracker_secrets import SLACK_BOT_TOKEN, TARGET_CHANNEL, PRINTER_CREDENTIALS |
| 14 | + |
| 15 | +# bambu_tracker_secrets.py Should look like this, but with real info: |
| 16 | +# SLACK_BOT_TOKEN = "xoxb-foo-foo-slack-token" # Replace with your xoxb token |
| 17 | +# TARGET_CHANNEL = "#the-channel" # Replace with your channel name/ID |
| 18 | +# |
| 19 | +# Dictionary to look up Access Codes by Serial Number during discovery |
| 20 | +# (Printers do not broadcast their private access code via SSDP for safety) |
| 21 | +#PRINTER_CREDENTIALS = { |
| 22 | +# # "Serial_Number": "LAN_Access_Code" |
| 23 | +# "000000000000000": "11111111", # MakeIt Right |
| 24 | +# "000000000000001": "22222222", # MakeIt Left |
| 25 | +# "000000000000003": "33333333", # Gamera |
| 26 | +# "000000000000004": "44444444" # Godzilla |
| 27 | +#} |
| 28 | + |
| 29 | + |
| 30 | +# ===================================================================== |
| 31 | +# NETWORK DISCOVERY LAYER (Bambu Custom SSDP Scanner) |
| 32 | +# ===================================================================== |
| 33 | +def run_lan_discovery(scan_duration=30): |
| 34 | + """Listens to the LAN for Bambu printers and returns a list of unique configs.""" |
| 35 | + multicast_group = "239.255.255.250" |
| 36 | + bambu_port = 2021 |
| 37 | + |
| 38 | + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) |
| 39 | + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 40 | + sock.bind(("", bambu_port)) |
| 41 | + |
| 42 | + mreq = socket.inet_aton(multicast_group) + socket.inet_aton("0.0.0.0") |
| 43 | + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) |
| 44 | + sock.settimeout(1.0) |
| 45 | + |
| 46 | + print(f"[*] Starting LAN scan. Listening {scan_duration} seconds for Bambu printers...") |
| 47 | + found_devices = {} # Keys: Serial Numbers, Values: Dict of data |
| 48 | + start_time = time.time() |
| 49 | + |
| 50 | + while time.time() - start_time < scan_duration: |
| 51 | + try: |
| 52 | + data, addr = sock.recvfrom(2048) |
| 53 | + payload = data.decode("utf-8", errors="ignore") |
| 54 | + |
| 55 | + if "bambu" in payload.lower(): |
| 56 | + lines = payload.splitlines() |
| 57 | + ip = addr[0] |
| 58 | + serial = None |
| 59 | + model = "Bambu Printer" |
| 60 | + name = "Bambu Printer" |
| 61 | + |
| 62 | + for line in lines: |
| 63 | + if "USN:" in line: |
| 64 | + serial = line.split(":")[-1].strip() |
| 65 | + elif "DevModel.bambu.com:" in line: |
| 66 | + model = line.split(":")[-1].strip() |
| 67 | + elif "DevName.bambu.com:" in line: |
| 68 | + name = line.split(":")[-1].strip() |
| 69 | + |
| 70 | + if serial and serial not in found_devices: |
| 71 | + found_devices[serial] = { |
| 72 | + "ip": ip, |
| 73 | + "model": model, |
| 74 | + "name": name, |
| 75 | + "serial": serial |
| 76 | + } |
| 77 | + print(f" [Found] {name} ({model}) at {ip} | SN: {serial}") |
| 78 | + |
| 79 | + except socket.timeout: |
| 80 | + continue |
| 81 | + except Exception as e: |
| 82 | + print(f"[-] Discovery error: {e}") |
| 83 | + break |
| 84 | + |
| 85 | + sock.close() |
| 86 | + print(f"[*] Scan finished. Found {len(found_devices)} total Bambu printer(s).\n") |
| 87 | + return list(found_devices.values()) |
| 88 | + |
| 89 | + |
| 90 | +# ===================================================================== |
| 91 | +# MQTT TELEMETRY & SLACK OUTBOUND TRACKER LAYER |
| 92 | +# ===================================================================== |
| 93 | +class BambuPrinterTracker: |
| 94 | + def __init__(self, ip, access_code, serial_number, friendly_name, slack_client=None, slack_channel=None): |
| 95 | + self.ip = ip |
| 96 | + self.access_code = access_code |
| 97 | + self.serial_number = serial_number |
| 98 | + self.friendly_name = friendly_name |
| 99 | + self.slack_client = slack_client |
| 100 | + self.slack_channel = slack_channel |
| 101 | + self.topic = f"device/{self.serial_number}/report" |
| 102 | + |
| 103 | + self.gcode_state = "UNKNOWN" |
| 104 | + self.progress = -1 |
| 105 | + self.remaining_time = -1 |
| 106 | + self.active_job = "UNKNOWN" |
| 107 | + |
| 108 | + self.last_slack_state = "UNKNOWN" |
| 109 | + self.last_slack_progress = -5 |
| 110 | + |
| 111 | + self.connected = False |
| 112 | + |
| 113 | + self.client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) |
| 114 | + self._configure_mqtt() |
| 115 | + |
| 116 | + def _configure_mqtt(self): |
| 117 | + self.client.username_pw_set(username="bblp", password=self.access_code) |
| 118 | + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) |
| 119 | + context.check_hostname = False |
| 120 | + context.verify_mode = ssl.CERT_NONE |
| 121 | + self.client.tls_set_context(context) |
| 122 | + |
| 123 | + self.client.on_connect = self._on_connect |
| 124 | + self.client.on_message = self._on_message |
| 125 | + |
| 126 | + def _on_connect(self, client, userdata, flags, reason_code, properties): |
| 127 | + if reason_code == 0: |
| 128 | + print(f"[+] [{self.friendly_name}] Connected to printer MQTT broker.") |
| 129 | + self.client.subscribe(self.topic) |
| 130 | + else: |
| 131 | + print(f"[-] [{self.friendly_name}] MQTT connection refused. Code: {reason_code}") |
| 132 | + |
| 133 | + def _on_message(self, client, userdata, msg): |
| 134 | + try: |
| 135 | + payload = json.loads(msg.payload.decode("utf-8")) |
| 136 | + if "print" in payload: |
| 137 | + print_data = payload["print"] |
| 138 | + |
| 139 | + self.gcode_state = print_data.get("gcode_state", self.gcode_state) |
| 140 | + self.progress = print_data.get("mc_percent", self.progress) |
| 141 | + self.remaining_time = print_data.get("mc_remaining_time", self.remaining_time) |
| 142 | + self.active_job = print_data.get("subtask_name", self.active_job) |
| 143 | + |
| 144 | + self.check_slack_conditions() |
| 145 | + |
| 146 | + except Exception as e: |
| 147 | + print(f"[-] [{self.friendly_name}] Telemetry parse failure: {e}") |
| 148 | + |
| 149 | + def check_slack_conditions(self): |
| 150 | + if not self.slack_client or not self.slack_channel: |
| 151 | + return |
| 152 | + |
| 153 | + if self.gcode_state == "UNKNOWN": |
| 154 | + # don't process if UNKNOWN |
| 155 | + return |
| 156 | + |
| 157 | + if (self.gcode_state == "RUNNING") and (self.remaining_time == 0) and (self.progress == 0): |
| 158 | + # when job first starts running, printer doesn't report |
| 159 | + # remaining time or percent completed, but does change state |
| 160 | + # force this to unknown |
| 161 | + self.gcode_state = "UNKNOWN" |
| 162 | + self.last_slack_state = "UNKNOWN" |
| 163 | + return |
| 164 | + |
| 165 | + #if state_changed |
| 166 | + if self.gcode_state != self.last_slack_state: |
| 167 | + self.send_to_slack() |
| 168 | + self.last_slack_state = self.gcode_state |
| 169 | + # self.last_slack_progress = self.progress |
| 170 | + |
| 171 | + def send_to_slack(self): |
| 172 | + timestamp = datetime.now().strftime("%I:%M %p %m-%d-%Y") |
| 173 | + finish_time = (datetime.now() + timedelta(minutes = self.remaining_time)).strftime("%I:%M %p %m-%d-%Y") |
| 174 | + |
| 175 | + emoji = "⚪" |
| 176 | + if self.gcode_state == "RUNNING": emoji = "🟢" |
| 177 | + elif self.gcode_state in ["PAUSE", "FAILED"]: emoji = "🔴" |
| 178 | + elif self.gcode_state == "FINISH": emoji = "🎉" |
| 179 | + |
| 180 | + if self.gcode_state == "RUNNING": |
| 181 | + block_payload = [ |
| 182 | + { |
| 183 | + "type": "header", |
| 184 | + "text": {"type": "plain_text", "text": f"{emoji} {self.friendly_name.upper()} UPDATE"} |
| 185 | + }, |
| 186 | + { |
| 187 | + "type": "section", |
| 188 | + "fields": [ |
| 189 | + {"type": "mrkdwn", "text": f"*State:* `{self.gcode_state}`"}, |
| 190 | + {"type": "mrkdwn", "text": f"*Time:* {timestamp}"}, |
| 191 | + {"type": "mrkdwn", "text": f"*End Time:* {finish_time}"}, |
| 192 | + {"type": "mrkdwn", "text": " "}, |
| 193 | + {"type": "mrkdwn", "text": f"*Job:* {self.active_job}"} |
| 194 | + ] |
| 195 | + } |
| 196 | + ] |
| 197 | + else: |
| 198 | + block_payload = [ |
| 199 | + { |
| 200 | + "type": "header", |
| 201 | + "text": {"type": "plain_text", "text": f"{emoji} {self.friendly_name.upper()} UPDATE"} |
| 202 | + }, |
| 203 | + { |
| 204 | + "type": "section", |
| 205 | + "fields": [ |
| 206 | + {"type": "mrkdwn", "text": f"*State:* `{self.gcode_state}`"}, |
| 207 | + {"type": "mrkdwn", "text": f"*Time:* {timestamp}"}, |
| 208 | + {"type": "mrkdwn", "text": f"*Job:* {self.active_job}"}, |
| 209 | + ] |
| 210 | + } |
| 211 | + ] |
| 212 | + |
| 213 | + fallback_text = f"{emoji} {self.friendly_name} is {self.gcode_state} ({self.remaining_time}%)" |
| 214 | + |
| 215 | + try: |
| 216 | + self.slack_client.chat_postMessage( |
| 217 | + channel=self.slack_channel, |
| 218 | + text=fallback_text, |
| 219 | + blocks=block_payload |
| 220 | + ) |
| 221 | + print(f"[+] [{self.friendly_name}] Slack status updated.") |
| 222 | + except SlackApiError as e: |
| 223 | + print(f"[-] [{self.friendly_name}] Slack API error details: {e.response['error']}") |
| 224 | + |
| 225 | + def start(self): |
| 226 | + try: |
| 227 | + self.client.connect(self.ip, 8883, keepalive=60) |
| 228 | + self.connected = True |
| 229 | + except: |
| 230 | + print(f"[-] [{self.friendly_name}] Failed to connect to client. Ignoring.") |
| 231 | + self.connected = False |
| 232 | + |
| 233 | + if self.connected: |
| 234 | + self.client.loop_start() |
| 235 | + |
| 236 | + def stop(self): |
| 237 | + if self.connected: |
| 238 | + self.client.loop_stop() |
| 239 | + self.client.disconnect() |
| 240 | + self.connected = False |
| 241 | + |
| 242 | +# ===================================================================== |
| 243 | +# MAIN RUNTIME ENGINE |
| 244 | +# ===================================================================== |
| 245 | +if __name__ == "__main__": |
| 246 | + # Initialize the centralized Slack OAuth connection |
| 247 | + shared_slack_client = WebClient(token=SLACK_BOT_TOKEN) |
| 248 | + |
| 249 | + # Step 1: Run the 30-second network scan |
| 250 | + discovered_printers = run_lan_discovery(scan_duration=30) |
| 251 | + |
| 252 | + active_trackers = [] |
| 253 | + |
| 254 | + # Step 2: Loop through found machines and spin up threads dynamically |
| 255 | + for device in discovered_printers: |
| 256 | + serial = device["serial"] |
| 257 | + |
| 258 | + # Pull matching access code from your security matrix |
| 259 | + if serial in PRINTER_CREDENTIALS: |
| 260 | + access_code = PRINTER_CREDENTIALS[serial] |
| 261 | + |
| 262 | + # Instantiate class tracker object dynamically |
| 263 | + tracker = BambuPrinterTracker( |
| 264 | + ip=device["ip"], |
| 265 | + access_code=access_code, |
| 266 | + serial_number=serial, |
| 267 | + friendly_name=device["name"], |
| 268 | + slack_client=shared_slack_client, |
| 269 | + slack_channel=TARGET_CHANNEL |
| 270 | + ) |
| 271 | + active_trackers.append(tracker) |
| 272 | + else: |
| 273 | + print(f"[!] Warning: Found printer {device['name']} ({serial}), but no matching access code was found in PRINTER_CREDENTIALS matrix. Skipping...") |
| 274 | + |
| 275 | + # Step 3: Boot up tracking loops concurrently |
| 276 | + if active_trackers: |
| 277 | + print(f"[*] Spawning background network threads for {len(active_trackers)} printer(s)...") |
| 278 | + for tracker in active_trackers: |
| 279 | + tracker.start() |
| 280 | + |
| 281 | + print("\n[+] Printer notification engine running. Press Ctrl+C to exit safely.") |
| 282 | + try: |
| 283 | + while True: |
| 284 | + time.sleep(1) |
| 285 | + except KeyboardInterrupt: |
| 286 | + print("\n[*] Shutting down tracking engines smoothly...") |
| 287 | + for tracker in active_trackers: |
| 288 | + if tracker.connected: |
| 289 | + tracker.stop() |
| 290 | + else: |
| 291 | + print("[-] No valid tracked printers found on the network or credential matching failed. Exiting.") |
0 commit comments