Untitled
  1. # mBot2 / CyberPi Wi-Fi MQTT listener — ALL-IN-ONE (upload via mBlock, "Upload mode")
  2. #
  3. # Single-file merge of mbot2_listener.py + simple_mqtt.py: upload just THIS file
  4. # to the CyberPi, nothing else. Functionally identical to the two-file setup.
  5. #
  6. # Turns the robot into a permanent Wi-Fi drone: it joins your LAN, connects to
  7. # the Mosquitto broker from the docker-compose stack, reacts to drive / LED /
  8. # mode commands, and streams encoder + color telemetry back. All piloting logic
  9. # lives on the PC.
  10. #
  11. # API notes (see robot/README.md for sources):
  12. # cyberpi.wifi.connect / is_connect, mbot2.drive_power, cyberpi.led.on,
  13. # mbuild.quad_rgb_sensor.get_red/green/blue.
  14. # The encoder-read and native line-offset function names are auto-detected at
  15. # startup (their exact spelling varies by firmware), each with a fallback.
  16.  
  17. try:
  18. import usocket as socket
  19. except ImportError:
  20. import socket
  21. try:
  22. import ustruct as struct
  23. except ImportError:
  24. import struct
  25.  
  26. import ujson as json
  27. import utime as time
  28.  
  29. import cyberpi
  30. import mbot2
  31. import mbuild
  32.  
  33. # ---------------- configuration: EDIT THESE ----------------
  34. WIFI_SSID = "Get yo virus myah" # /!\ FREQUENCY BAND MUST BE 2.4GHz
  35. WIFI_PASS = "<72260960>"
  36. BROKER_IP = "10.58.116.212" # IP of the machine running docker compose
  37. BROKER_PORT = 1883
  38.  
  39. COLOR_PORT = "L1" # quad-RGB probe used for the color sensor / seek
  40. COLOR_MATCH_TOL = 40 # tolerance for RGB matching (lower = more sensitive, higher = more forgiving)
  41. LINE_L_PORT = "L2" # left probe for RGB-brightness line steering (outer probe, per hardware layout)
  42. LINE_R_PORT = "R2" # right probe for RGB-brightness line steering (outer probe, per hardware layout)
  43. LINE_BASE = 22 # forward power while line-following (lowered from 30: turns detected late, needs more reaction time)
  44. LINE_GAIN = 20 # steering strength (lowered from 30: overshooting/oscillating on turns)
  45. LINE_CORR_MAX = 100 # effectively unclamped, matching robot.py's proven reference for this map
  46. ENC_L_SIGN = 1 # flip to -1 if the left wheel reads backwards
  47. ENC_R_SIGN = 1 # flip to -1 if the right wheel reads backwards
  48. # The two motors are mounted mirrored: drive_power(l, r) with equal signs spins
  49. # the robot in place instead of driving straight. Confirmed on hardware via
  50. # /api/drive {"l":40,"r":40}. Flip to 1 if a unit turns out wired the other way.
  51. DRIVE_R_SIGN = -1
  52. # -----------------------------------------------------------
  53.  
  54. # MQTT contract — keep in sync with backend/app/protocol.py
  55. CMD_DRIVE = "mbot2/cmd/drive"
  56. CMD_LED = "mbot2/cmd/led"
  57. CMD_MODE = "mbot2/cmd/mode"
  58. CMD_STOP = "mbot2/cmd/stop"
  59. TEL_ENC = "mbot2/tel/enc"
  60. TEL_COLOR = "mbot2/tel/color"
  61. TEL_STATUS = "mbot2/tel/status"
  62. TEL_MISSION = "mbot2/tel/mission"
  63.  
  64. NAMED = {
  65. "red": (220, 40, 40), "green": (40, 180, 90), "blue": (50, 90, 220),
  66. "yellow": (235, 210, 60), "black": (20, 20, 20), "white": (235, 235, 235),
  67. }
  68.  
  69. # mode: manual | color_seek | line_follow ; target: [r,g,b] or None ;
  70. # method: "rgb" (brightness steer) | "offset" (native line API) ;
  71. # distance_ticks/start_l/start_r/last_progress: color_seek mission tracking,
  72. # set when CMD_MODE arrives with a distance_ticks (see TEL_MISSION below).
  73. state = {"mode": "manual", "target": None, "method": "rgb", "pl": 0, "pr": 0,
  74. "distance_ticks": None, "start_l": 0.0, "start_r": 0.0, "last_progress": None}
  75.  
  76.  
  77. # =====================================================================
  78. # Vendored umqtt.simple — minimal MQTT 3.1.1 client for MicroPython.
  79. # From micropython-lib (github.com MIT license.
  80. # =====================================================================
  81. class MQTTException(Exception):
  82. pass
  83.  
  84.  
  85. class MQTTClient:
  86. def __init__(self, client_id, server, port=0, user=None, password=None,
  87. keepalive=0, ssl=False, ssl_params={}):
  88. if port == 0:
  89. port = 8883 if ssl else 1883
  90. self.client_id = client_id
  91. self.sock = None
  92. self.server = server
  93. self.port = port
  94. self.ssl = ssl
  95. self.ssl_params = ssl_params
  96. self.pid = 0
  97. self.cb = None
  98. self.user = user
  99. self.pswd = password
  100. self.keepalive = keepalive
  101. self.lw_topic = None
  102. self.lw_msg = None
  103. self.lw_qos = 0
  104. self.lw_retain = False
  105.  
  106. def _send_str(self, s):
  107. if isinstance(s, str):
  108. s = s.encode()
  109. self.sock.write(struct.pack("!H", len(s)))
  110. self.sock.write(s)
  111.  
  112. def _recv_len(self):
  113. n = 0
  114. sh = 0
  115. while 1:
  116. b = self.sock.read(1)[0]
  117. n |= (b & 0x7F) << sh
  118. if not b & 0x80:
  119. return n
  120. sh += 7
  121.  
  122. def set_callback(self, f):
  123. self.cb = f
  124.  
  125. def set_last_will(self, topic, msg, retain=False, qos=0):
  126. assert 0 <= qos <= 2
  127. assert topic
  128. self.lw_topic = topic
  129. self.lw_msg = msg
  130. self.lw_qos = qos
  131. self.lw_retain = retain
  132.  
  133. def connect(self, clean_session=True):
  134. self.sock = socket.socket()
  135. addr = socket.getaddrinfo(self.server, self.port)[0][-1]
  136. self.sock.connect(addr)
  137. if self.ssl:
  138. import ussl
  139. self.sock = ussl.wrap_socket(self.sock, **self.ssl_params)
  140. premsg = bytearray(b"\x10\0\0\0\0\0")
  141. msg = bytearray(b"\x04MQTT\x04\x02\0\0")
  142.  
  143. sz = 10 + 2 + len(self.client_id)
  144. msg[6] = clean_session << 1
  145. if self.user is not None:
  146. sz += 2 + len(self.user) + 2 + len(self.pswd)
  147. msg[6] |= 0xC0
  148. if self.keepalive:
  149. assert self.keepalive < 65536
  150. msg[7] |= self.keepalive >> 8
  151. msg[8] |= self.keepalive & 0x00FF
  152. if self.lw_topic:
  153. sz += 2 + len(self.lw_topic) + 2 + len(self.lw_msg)
  154. msg[6] |= 0x4 | (self.lw_qos & 0x1) << 3 | (self.lw_qos & 0x2) << 3
  155. msg[6] |= self.lw_retain << 5
  156.  
  157. i = 1
  158. while sz > 0x7F:
  159. premsg[i] = (sz & 0x7F) | 0x80
  160. sz >>= 7
  161. i += 1
  162. premsg[i] = sz
  163.  
  164. self.sock.write(premsg, i + 2)
  165. self.sock.write(msg)
  166. self._send_str(self.client_id)
  167. if self.lw_topic:
  168. self._send_str(self.lw_topic)
  169. self._send_str(self.lw_msg)
  170. if self.user is not None:
  171. self._send_str(self.user)
  172. self._send_str(self.pswd)
  173. resp = self.sock.read(4)
  174. assert resp[0] == 0x20 and resp[1] == 0x02
  175. if resp[3] != 0:
  176. raise MQTTException(resp[3])
  177. return resp[2] & 1
  178.  
  179. def disconnect(self):
  180. self.sock.write(b"\xe0\0")
  181. self.sock.close()
  182.  
  183. def ping(self):
  184. self.sock.write(b"\xc0\0")
  185.  
  186. def publish(self, topic, msg, retain=False, qos=0):
  187. if isinstance(msg, str):
  188. msg = msg.encode()
  189. pkt = bytearray(b"\x30\0\0\0")
  190. pkt[0] |= qos << 1 | retain
  191. sz = 2 + len(topic) + len(msg)
  192. if qos > 0:
  193. sz += 2
  194. assert sz < 2097152
  195. i = 1
  196. while sz > 0x7F:
  197. pkt[i] = (sz & 0x7F) | 0x80
  198. sz >>= 7
  199. i += 1
  200. pkt[i] = sz
  201. self.sock.write(pkt, i + 1)
  202. self._send_str(topic)
  203. if qos > 0:
  204. self.pid += 1
  205. pid = self.pid
  206. struct.pack_into("!H", pkt, 0, pid)
  207. self.sock.write(pkt, 2)
  208. self.sock.write(msg)
  209. if qos == 1:
  210. while 1:
  211. op = self.wait_msg()
  212. if op == 0x40:
  213. sz = self.sock.read(1)
  214. assert sz == b"\x02"
  215. rcv_pid = self.sock.read(2)
  216. rcv_pid = rcv_pid[0] << 8 | rcv_pid[1]
  217. if pid == rcv_pid:
  218. return
  219. elif qos == 2:
  220. assert 0
  221.  
  222. def subscribe(self, topic, qos=0):
  223. assert self.cb is not None, "Subscribe callback is not set"
  224. pkt = bytearray(b"\x82\0\0\0")
  225. self.pid += 1
  226. struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic) + 1, self.pid)
  227. self.sock.write(pkt)
  228. self._send_str(topic)
  229. self.sock.write(qos.to_bytes(1, "little"))
  230. while 1:
  231. op = self.wait_msg()
  232. if op == 0x90:
  233. resp = self.sock.read(4)
  234. assert resp[1] == pkt[2] and resp[2] == pkt[3]
  235. if resp[3] == 0x80:
  236. raise MQTTException(resp[3])
  237. return
  238.  
  239. # Wait for a single incoming MQTT message and process it.
  240. # Subscribed messages are delivered via the callback.
  241. def wait_msg(self):
  242. res = self.sock.read(1)
  243. self.sock.setblocking(True)
  244. if res is None:
  245. return None
  246. if res == b"":
  247. raise OSError(-1)
  248. if res == b"\xd0": # PINGRESP
  249. sz = self.sock.read(1)[0]
  250. assert sz == 0
  251. return None
  252. op = res[0]
  253. if op & 0xF0 != 0x30:
  254. return op
  255. sz = self._recv_len()
  256. topic_len = self.sock.read(2)
  257. topic_len = (topic_len[0] << 8) | topic_len[1]
  258. topic = self.sock.read(topic_len)
  259. sz -= topic_len + 2
  260. if op & 6:
  261. pid = self.sock.read(2)
  262. pid = pid[0] << 8 | pid[1]
  263. sz -= 2
  264. msg = self.sock.read(sz)
  265. self.cb(topic, msg)
  266. if op & 6 == 2:
  267. pkt = bytearray(b"\x40\x02\0\0")
  268. struct.pack_into("!H", pkt, 2, pid)
  269. self.sock.write(pkt)
  270. elif op & 6 == 4:
  271. assert 0
  272. return op
  273.  
  274. # Checks whether a pending message from server is available.
  275. # If not, returns immediately with None. Otherwise processes it.
  276. def check_msg(self):
  277. self.sock.setblocking(False)
  278. return self.wait_msg()
  279.  
  280.  
  281. # =====================================================================
  282. # Robot logic
  283. # =====================================================================
  284. def _valid_rgb(t):
  285. try:
  286. return len(t) == 3 and all(isinstance(v, (int, float)) for v in t)
  287. except TypeError:
  288. return False
  289.  
  290.  
  291. # ---- encoder reader: auto-detect the firmware's function name + port spelling ----
  292. # The Makeblock library defines mbot2.EM_get_angle(port) (UUID
  293. # fadd7b62ec703bbded9b9b63d464e425); examples never read encoders, so the port
  294. # argument spelling isn't documented — probe a few and prefer the confirmed name.
  295. def _make_encoder_reader():
  296. port_pairs = (("EM1", "EM2"), ("em1", "em2"), (1, 2), ("1", "2"))
  297. for name in ("EM_get_angle", "encoder_motor_get_angle",
  298. "encoder_motor_get_pos", "encoder_motor_get_distance"):
  299. fn = getattr(mbot2, name, None)
  300. if not fn:
  301. continue
  302. for pl, pr in port_pairs:
  303. try:
  304. fn(pl)
  305. # Remember which name/port worked so the UI can show whether
  306. # odometry is real or the open-loop fallback.
  307. global ENC_SRC, ENC_PORTS
  308. ENC_SRC = name + ":" + str(pl)
  309. ENC_PORTS = (pl, pr)
  310. return lambda a=pl, b=pr, f=fn: (f(a), f(b))
  311. except Exception:
  312. pass
  313. return None
  314.  
  315.  
  316. # ---- wheel-speed reader (diagnostics only) ----
  317. # mbot2.EM_get_speed(port) is confirmed in the Makeblock API table (UUID
  318. # 9c703762b00091af8ecdf503fab52745), same undocumented port argument as
  319. # EM_get_angle, so reuse whatever spelling the encoder probe settled on.
  320. def _make_speed_reader():
  321. fn = getattr(mbot2, "EM_get_speed", None)
  322. if not fn or not ENC_PORTS:
  323. return None
  324. pl, pr = ENC_PORTS
  325. try:
  326. fn(pl)
  327. return lambda a=pl, b=pr, f=fn: (f(a), f(b))
  328. except Exception:
  329. return None
  330.  
  331.  
  332. # ---- board health probes (all optional; absent APIs simply report None) ----
  333. # Verified in the Makeblock API table: cyberpi.get_battery() (3988e0aa…),
  334. # cyberpi.get_extra_battery() (e8e71442…), cyberpi.get_firmware_version()
  335. # (84130d80…), cyberpi.timer.get() (5535075a…). All take no arguments.
  336. # Note: the API exposes no Wi-Fi RSSI or IP-address call, so signal strength
  337. # cannot be reported — only the boolean wifi.is_connect().
  338. def _probe(fn_path, *args):
  339. obj = cyberpi
  340. for part in fn_path.split("."):
  341. obj = getattr(obj, part, None)
  342. if obj is None:
  343. return None
  344. try:
  345. return obj(*args)
  346. except Exception:
  347. return None
  348.  
  349.  
  350. # ---- native line-offset reader ----
  351. # mbuild.quad_rgb_sensor.get_offset_track(index) -> ~ -100..100 (line deviation:
  352. # negative = line left, positive = line right). `index` is the integer mBuild
  353. # port (1), not a probe string. Names vary slightly by firmware, so auto-detect.
  354. def _make_offset_reader():
  355. for name in ("get_offset_track", "get_line_offset", "get_offset"):
  356. fn = getattr(mbuild.quad_rgb_sensor, name, None)
  357. if fn:
  358. try:
  359. fn(1)
  360. global OFFSET_SRC
  361. OFFSET_SRC = name
  362. return lambda f=fn: f(1)
  363. except Exception:
  364. pass
  365. return None
  366.  
  367.  
  368. ENC_SRC = "open-loop" # set by _make_encoder_reader if a real encoder answers
  369. ENC_PORTS = None
  370. OFFSET_SRC = None # set by _make_offset_reader if the native API exists
  371.  
  372. _read_enc = _make_encoder_reader()
  373. _read_offset = _make_offset_reader()
  374. _read_speed = _make_speed_reader()
  375. FIRMWARE = _probe("get_firmware_version")
  376.  
  377. _pseudo_l = 0.0
  378. _pseudo_r = 0.0
  379. _last_ms = time.ticks_ms()
  380.  
  381.  
  382. def read_encoders():
  383. global _pseudo_l, _pseudo_r, _last_ms
  384. if _read_enc:
  385. l, r = _read_enc()
  386. return ENC_L_SIGN * l, ENC_R_SIGN * r
  387. # Open-loop fallback: integrate commanded power into pseudo-encoder degrees
  388. # (~286.5 deg/s per 100% power for an 80 mm wheel at ~200 mm/s). Apply the
  389. # same wheel-direction signs as the real-encoder path so odometry matches.
  390. now = time.ticks_ms()
  391. dt = time.ticks_diff(now, _last_ms) / 1000.0
  392. _last_ms = now
  393. _pseudo_l += state["pl"] / 100.0 * 286.5 * dt
  394. _pseudo_r += state["pr"] / 100.0 * 286.5 * dt
  395. return ENC_L_SIGN * _pseudo_l, ENC_R_SIGN * _pseudo_r
  396.  
  397.  
  398. def _brightness(port):
  399. return (int(mbuild.quad_rgb_sensor.get_red(port))
  400. + int(mbuild.quad_rgb_sensor.get_green(port))
  401. + int(mbuild.quad_rgb_sensor.get_blue(port)))
  402.  
  403.  
  404. def read_color():
  405. r = int(mbuild.quad_rgb_sensor.get_red(COLOR_PORT))
  406. g = int(mbuild.quad_rgb_sensor.get_green(COLOR_PORT))
  407. b = int(mbuild.quad_rgb_sensor.get_blue(COLOR_PORT))
  408. name = "background"
  409. best = 90 * 90 * 3
  410. for n, (cr, cg, cb) in NAMED.items():
  411. d = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2
  412. if d < best:
  413. best = d
  414. name = n
  415. return r, g, b, name
  416.  
  417.  
  418. LINE_DEBUG = {"raw": None, "corr": None, "lb": None, "rb": None} # surfaced via TEL_COLOR for live tuning
  419.  
  420.  
  421. def line_powers():
  422. """Return (left, right) power for line following per the selected method."""
  423. if state["method"] == "offset" and _read_offset:
  424. off = _read_offset() # expected ~ -100..100, 0 = centered
  425. # Sign flipped: the "negative = line left" assumption in get_offset_track's
  426. # docs doesn't hold on this unit — confirmed by hardware turning the wrong
  427. # way on a real corner.
  428. corr = -LINE_GAIN * (off / 100.0)
  429. corr = max(-LINE_CORR_MAX, min(LINE_CORR_MAX, corr))
  430. LINE_DEBUG["raw"], LINE_DEBUG["corr"] = off, corr
  431. return LINE_BASE + corr, LINE_BASE - corr
  432. # RGB-brightness steer: the darker probe is over the line.
  433. lb, rb = _brightness(LINE_L_PORT), _brightness(LINE_R_PORT)
  434. err = (rb - lb) / 765.0 # >0 => left darker => line on the left
  435. corr = LINE_GAIN * err
  436. LINE_DEBUG["lb"], LINE_DEBUG["rb"] = lb, rb
  437. corr = max(-LINE_CORR_MAX, min(LINE_CORR_MAX, corr))
  438. LINE_DEBUG["raw"], LINE_DEBUG["corr"] = round(err, 3), corr
  439. return LINE_BASE - corr, LINE_BASE + corr
  440.  
  441.  
  442. def drive(l, r):
  443. l = max(-100, min(100, int(l)))
  444. r = max(-100, min(100, int(r)))
  445. state["pl"], state["pr"] = l, r
  446. mbot2.drive_power(l, DRIVE_R_SIGN * r)
  447.  
  448.  
  449. def publish_status(client):
  450. """Status + board diagnostics for the web UI's debug panel (1 Hz).
  451.  
  452. Every health field is best-effort: a firmware that lacks a call reports null
  453. rather than crashing the control loop.
  454. """
  455. spd = None
  456. if _read_speed:
  457. try:
  458. sl, sr = _read_speed()
  459. spd = [sl, sr]
  460. except Exception:
  461. spd = None
  462. client.publish(TEL_STATUS, json.dumps({
  463. "mode": state["mode"], "method": state["method"], "online": True,
  464. "battery": _probe("get_battery"),
  465. "extra_battery": _probe("get_extra_battery"),
  466. "fw": FIRMWARE,
  467. "uptime": _probe("timer.get"),
  468. "wifi": _probe("wifi.is_connect"),
  469. "enc_src": ENC_SRC,
  470. "offset_src": OFFSET_SRC,
  471. "spd": spd,
  472. }))
  473.  
  474.  
  475. def on_message(topic, payload):
  476. try:
  477. p = json.loads(payload) if payload else {}
  478. except Exception:
  479. return
  480. t = topic.decode() if isinstance(topic, bytes) else topic
  481. if t == CMD_DRIVE:
  482. if state["mode"] == "manual":
  483. drive(p.get("l", 0), p.get("r", 0))
  484. elif t == CMD_STOP:
  485. state["mode"] = "manual"
  486. state["target"] = None
  487. drive(0, 0)
  488. elif t == CMD_LED:
  489. r, g, b = int(p.get("r", 0)), int(p.get("g", 0)), int(p.get("b", 0))
  490. idx = int(p.get("index", 0))
  491. if idx == 0:
  492. cyberpi.led.on(r, g, b) # all LEDs
  493. else:
  494. cyberpi.led.on(r, g, b, id=idx) # single LED 1..5
  495. elif t == CMD_MODE:
  496. state["mode"] = p.get("mode", "manual")
  497. tgt = p.get("target")
  498. state["target"] = tgt if _valid_rgb(tgt) else None
  499. if p.get("method") in ("rgb", "offset"):
  500. state["method"] = p["method"]
  501. dist = p.get("distance_ticks")
  502. if state["mode"] in ("color_seek", "line_follow") and isinstance(dist, (int, float)) and dist > 0:
  503. state["distance_ticks"] = dist
  504. state["start_l"], state["start_r"] = read_encoders()
  505. state["last_progress"] = None
  506. else:
  507. state["distance_ticks"] = None
  508. if state["mode"] == "manual":
  509. drive(0, 0)
  510.  
  511.  
  512. def ensure_wifi():
  513. if not cyberpi.wifi.is_connect():
  514. cyberpi.wifi.connect(WIFI_SSID, WIFI_PASS)
  515. while not cyberpi.wifi.is_connect():
  516. cyberpi.console.println("wifi...")
  517. time.sleep(0.5)
  518. cyberpi.console.println("wifi ok")
  519.  
  520.  
  521. def connect_mqtt(client):
  522. ensure_wifi()
  523. client.connect()
  524. for topic in (CMD_DRIVE, CMD_LED, CMD_MODE, CMD_STOP):
  525. client.subscribe(topic)
  526. publish_status(client)
  527. cyberpi.console.println("mqtt ok")
  528.  
  529.  
  530. def main():
  531. client = MQTTClient("mbot2-" + str(time.ticks_ms()), BROKER_IP,
  532. port=BROKER_PORT, keepalive=30)
  533. client.set_callback(on_message)
  534. # Last-Will: broker publishes this if the robot drops, so the UI clears.
  535. client.set_last_will(TEL_STATUS, json.dumps({"mode": "offline", "online": False}))
  536. connect_mqtt(client)
  537.  
  538. last_enc = last_col = last_status = last_ping = time.ticks_ms()
  539. while True:
  540. try:
  541. client.check_msg() # non-blocking; fires on_message for commands
  542. now = time.ticks_ms()
  543. mode = state["mode"]
  544.  
  545. # Read the color sensor only when a mode needs it or telemetry is due.
  546. col_due = time.ticks_diff(now, last_col) >= 200
  547. color = read_color() if (mode != "manual" or col_due) else None
  548.  
  549. if mode == "color_seek" and state["target"]:
  550. drive(25, 25)
  551. if state["distance_ticks"]:
  552. el, er = read_encoders()
  553. traveled = (abs(el - state["start_l"]) + abs(er - state["start_r"])) / 2.0
  554. pct = int(min(100, traveled / state["distance_ticks"] * 100))
  555. if pct != state["last_progress"]:
  556. state["last_progress"] = pct
  557. client.publish(TEL_MISSION, json.dumps(
  558. {"event": "progress", "progress": pct}))
  559. tr, tg, tb = state["target"]
  560. if color and abs(color[0] - tr) < COLOR_MATCH_TOL and abs(color[1] - tg) < COLOR_MATCH_TOL \
  561. and abs(color[2] - tb) < COLOR_MATCH_TOL:
  562. drive(0, 0)
  563. state["mode"] = "manual"
  564. was_mission = state["distance_ticks"] is not None
  565. state["distance_ticks"] = None
  566. publish_status(client) # report the stop immediately
  567. if was_mission:
  568. client.publish(TEL_MISSION, json.dumps({"event": "arrival"}))
  569. elif mode == "line_follow":
  570. l, r = line_powers()
  571. drive(l, r)
  572. if state["distance_ticks"]:
  573. el, er = read_encoders()
  574. traveled = (abs(el - state["start_l"]) + abs(er - state["start_r"])) / 2.0
  575. pct = int(min(100, traveled / state["distance_ticks"] * 100))
  576. if pct != state["last_progress"]:
  577. state["last_progress"] = pct
  578. client.publish(TEL_MISSION, json.dumps(
  579. {"event": "progress", "progress": pct}))
  580. if state["target"] and color: # optional stop-on-color
  581. tr, tg, tb = state["target"]
  582. if abs(color[0] - tr) < COLOR_MATCH_TOL and abs(color[1] - tg) < COLOR_MATCH_TOL \
  583. and abs(color[2] - tb) < COLOR_MATCH_TOL:
  584. drive(0, 0)
  585. state["mode"] = "manual"
  586. was_mission = state["distance_ticks"] is not None
  587. state["distance_ticks"] = None
  588. publish_status(client)
  589. if was_mission:
  590. client.publish(TEL_MISSION, json.dumps({"event": "arrival"}))
  591.  
  592. if time.ticks_diff(now, last_enc) >= 50: # 20 Hz encoders
  593. el, er = read_encoders()
  594. client.publish(TEL_ENC, json.dumps({"l": el, "r": er, "t": now}))
  595. last_enc = now
  596. if col_due: # 5 Hz color
  597. if color is None:
  598. color = read_color()
  599. client.publish(TEL_COLOR, json.dumps(
  600. {"r": color[0], "g": color[1], "b": color[2], "name": color[3],
  601. "line_raw": LINE_DEBUG["raw"], "line_corr": LINE_DEBUG["corr"],
  602. "line_lb": LINE_DEBUG["lb"], "line_rb": LINE_DEBUG["rb"]}))
  603. last_col = now
  604. if time.ticks_diff(now, last_status) >= 1000:
  605. publish_status(client)
  606. last_status = now
  607. if time.ticks_diff(now, last_ping) >= 15000: # keep session alive
  608. client.ping()
  609. last_ping = now
  610. time.sleep(0.01)
  611. except Exception as e:
  612. # Safety first: stop the motors, then rebuild the Wi-Fi/MQTT link so a
  613. # transient drop can't leave the robot driving at the last power.
  614. try:
  615. mbot2.drive_power(0, 0)
  616. except Exception:
  617. pass
  618. cyberpi.console.println("link lost, reconnecting")
  619. try:
  620. client.disconnect()
  621. except Exception:
  622. pass
  623. while True:
  624. try:
  625. connect_mqtt(client)
  626. break
  627. except Exception:
  628. time.sleep(2)
  629. last_enc = last_col = last_status = last_ping = time.ticks_ms()
  630.  
  631.  
  632. main()
Pasted 2026-10-05 17:24:44

Short link: