Skip to content

Commit fb6ae2a

Browse files
committed
Adds initial version
1 parent a4d0710 commit fb6ae2a

2 files changed

Lines changed: 305 additions & 0 deletions

File tree

bambu_tracker.py

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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.")

bambu_tracker_secrets.py.TEMPLATE

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# bambu_tracker_secrets.py Should look like this, but with real info:
2+
SLACK_BOT_TOKEN = "xoxb-foo-foo-slack-token" # Replace with your xoxb token
3+
TARGET_CHANNEL = "#the-channel" # Replace with your channel name/ID
4+
5+
#
6+
# Dictionary to look up Access Codes by Serial Number during discovery
7+
# (Printers do not broadcast their private access code via SSDP for safety)
8+
PRINTER_CREDENTIALS = {
9+
# "Serial_Number": "LAN_Access_Code"
10+
"000000000000000": "11111111", # One Printer
11+
"000000000000001": "22222222", # Another Printer
12+
"000000000000003": "33333333", # Some Other Printer
13+
"000000000000004": "44444444" # And Last Printer
14+
}

0 commit comments

Comments
 (0)