Untitled
- # mBot2 / CyberPi Wi-Fi MQTT listener — ALL-IN-ONE (upload via mBlock, "Upload mode")
- #
- # Single-file merge of mbot2_listener.py + simple_mqtt.py: upload just THIS file
- # to the CyberPi, nothing else. Functionally identical to the two-file setup.
- #
- # Turns the robot into a permanent Wi-Fi drone: it joins your LAN, connects to
- # the Mosquitto broker from the docker-compose stack, reacts to drive / LED /
- # mode commands, and streams encoder + color telemetry back. All piloting logic
- # lives on the PC.
- #
- # API notes (see robot/README.md for sources):
- # cyberpi.wifi.connect / is_connect, mbot2.drive_power, cyberpi.led.on,
- # mbuild.quad_rgb_sensor.get_red/green/blue.
- # The encoder-read and native line-offset function names are auto-detected at
- # startup (their exact spelling varies by firmware), each with a fallback.
- try:
- import usocket as socket
- except ImportError:
- import socket
- try:
- import ustruct as struct
- except ImportError:
- import struct
- import ujson as json
- import utime as time
- import cyberpi
- import mbot2
- import mbuild
- # ---------------- configuration: EDIT THESE ----------------
- WIFI_SSID = "Get yo virus myah" # /!\ FREQUENCY BAND MUST BE 2.4GHz
- WIFI_PASS = "<72260960>"
- BROKER_IP = "10.58.116.212" # IP of the machine running docker compose
- BROKER_PORT = 1883
- COLOR_PORT = "L1" # quad-RGB probe used for the color sensor / seek
- COLOR_MATCH_TOL = 40 # tolerance for RGB matching (lower = more sensitive, higher = more forgiving)
- LINE_L_PORT = "L2" # left probe for RGB-brightness line steering (outer probe, per hardware layout)
- LINE_R_PORT = "R2" # right probe for RGB-brightness line steering (outer probe, per hardware layout)
- LINE_BASE = 22 # forward power while line-following (lowered from 30: turns detected late, needs more reaction time)
- LINE_GAIN = 20 # steering strength (lowered from 30: overshooting/oscillating on turns)
- LINE_CORR_MAX = 100 # effectively unclamped, matching robot.py's proven reference for this map
- ENC_L_SIGN = 1 # flip to -1 if the left wheel reads backwards
- ENC_R_SIGN = 1 # flip to -1 if the right wheel reads backwards
- # The two motors are mounted mirrored: drive_power(l, r) with equal signs spins
- # the robot in place instead of driving straight. Confirmed on hardware via
- # /api/drive {"l":40,"r":40}. Flip to 1 if a unit turns out wired the other way.
- DRIVE_R_SIGN = -1
- # -----------------------------------------------------------
- # MQTT contract — keep in sync with backend/app/protocol.py
- CMD_DRIVE = "mbot2/cmd/drive"
- CMD_LED = "mbot2/cmd/led"
- CMD_MODE = "mbot2/cmd/mode"
- CMD_STOP = "mbot2/cmd/stop"
- TEL_ENC = "mbot2/tel/enc"
- TEL_COLOR = "mbot2/tel/color"
- TEL_STATUS = "mbot2/tel/status"
- TEL_MISSION = "mbot2/tel/mission"
- NAMED = {
- "red": (220, 40, 40), "green": (40, 180, 90), "blue": (50, 90, 220),
- "yellow": (235, 210, 60), "black": (20, 20, 20), "white": (235, 235, 235),
- }
- # mode: manual | color_seek | line_follow ; target: [r,g,b] or None ;
- # method: "rgb" (brightness steer) | "offset" (native line API) ;
- # distance_ticks/start_l/start_r/last_progress: color_seek mission tracking,
- # set when CMD_MODE arrives with a distance_ticks (see TEL_MISSION below).
- state = {"mode": "manual", "target": None, "method": "rgb", "pl": 0, "pr": 0,
- "distance_ticks": None, "start_l": 0.0, "start_r": 0.0, "last_progress": None}
- # =====================================================================
- # Vendored umqtt.simple — minimal MQTT 3.1.1 client for MicroPython.
- # From micropython-lib (github.com MIT license.
- # =====================================================================
- class MQTTException(Exception):
- pass
- class MQTTClient:
- def __init__(self, client_id, server, port=0, user=None, password=None,
- keepalive=0, ssl=False, ssl_params={}):
- if port == 0:
- port = 8883 if ssl else 1883
- self.client_id = client_id
- self.sock = None
- self.server = server
- self.port = port
- self.ssl = ssl
- self.ssl_params = ssl_params
- self.pid = 0
- self.cb = None
- self.user = user
- self.pswd = password
- self.keepalive = keepalive
- self.lw_topic = None
- self.lw_msg = None
- self.lw_qos = 0
- self.lw_retain = False
- def _send_str(self, s):
- if isinstance(s, str):
- s = s.encode()
- self.sock.write(struct.pack("!H", len(s)))
- self.sock.write(s)
- def _recv_len(self):
- n = 0
- sh = 0
- while 1:
- b = self.sock.read(1)[0]
- n |= (b & 0x7F) << sh
- if not b & 0x80:
- return n
- sh += 7
- def set_callback(self, f):
- self.cb = f
- def set_last_will(self, topic, msg, retain=False, qos=0):
- assert 0 <= qos <= 2
- assert topic
- self.lw_topic = topic
- self.lw_msg = msg
- self.lw_qos = qos
- self.lw_retain = retain
- def connect(self, clean_session=True):
- self.sock = socket.socket()
- addr = socket.getaddrinfo(self.server, self.port)[0][-1]
- self.sock.connect(addr)
- if self.ssl:
- import ussl
- self.sock = ussl.wrap_socket(self.sock, **self.ssl_params)
- premsg = bytearray(b"\x10\0\0\0\0\0")
- msg = bytearray(b"\x04MQTT\x04\x02\0\0")
- sz = 10 + 2 + len(self.client_id)
- msg[6] = clean_session << 1
- if self.user is not None:
- sz += 2 + len(self.user) + 2 + len(self.pswd)
- msg[6] |= 0xC0
- if self.keepalive:
- assert self.keepalive < 65536
- msg[7] |= self.keepalive >> 8
- msg[8] |= self.keepalive & 0x00FF
- if self.lw_topic:
- sz += 2 + len(self.lw_topic) + 2 + len(self.lw_msg)
- msg[6] |= 0x4 | (self.lw_qos & 0x1) << 3 | (self.lw_qos & 0x2) << 3
- msg[6] |= self.lw_retain << 5
- i = 1
- while sz > 0x7F:
- premsg[i] = (sz & 0x7F) | 0x80
- sz >>= 7
- i += 1
- premsg[i] = sz
- self.sock.write(premsg, i + 2)
- self.sock.write(msg)
- self._send_str(self.client_id)
- if self.lw_topic:
- self._send_str(self.lw_topic)
- self._send_str(self.lw_msg)
- if self.user is not None:
- self._send_str(self.user)
- self._send_str(self.pswd)
- resp = self.sock.read(4)
- assert resp[0] == 0x20 and resp[1] == 0x02
- if resp[3] != 0:
- raise MQTTException(resp[3])
- return resp[2] & 1
- def disconnect(self):
- self.sock.write(b"\xe0\0")
- self.sock.close()
- def ping(self):
- self.sock.write(b"\xc0\0")
- def publish(self, topic, msg, retain=False, qos=0):
- if isinstance(msg, str):
- msg = msg.encode()
- pkt = bytearray(b"\x30\0\0\0")
- pkt[0] |= qos << 1 | retain
- sz = 2 + len(topic) + len(msg)
- if qos > 0:
- sz += 2
- assert sz < 2097152
- i = 1
- while sz > 0x7F:
- pkt[i] = (sz & 0x7F) | 0x80
- sz >>= 7
- i += 1
- pkt[i] = sz
- self.sock.write(pkt, i + 1)
- self._send_str(topic)
- if qos > 0:
- self.pid += 1
- pid = self.pid
- struct.pack_into("!H", pkt, 0, pid)
- self.sock.write(pkt, 2)
- self.sock.write(msg)
- if qos == 1:
- while 1:
- op = self.wait_msg()
- if op == 0x40:
- sz = self.sock.read(1)
- assert sz == b"\x02"
- rcv_pid = self.sock.read(2)
- rcv_pid = rcv_pid[0] << 8 | rcv_pid[1]
- if pid == rcv_pid:
- return
- elif qos == 2:
- assert 0
- def subscribe(self, topic, qos=0):
- assert self.cb is not None, "Subscribe callback is not set"
- pkt = bytearray(b"\x82\0\0\0")
- self.pid += 1
- struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic) + 1, self.pid)
- self.sock.write(pkt)
- self._send_str(topic)
- self.sock.write(qos.to_bytes(1, "little"))
- while 1:
- op = self.wait_msg()
- if op == 0x90:
- resp = self.sock.read(4)
- assert resp[1] == pkt[2] and resp[2] == pkt[3]
- if resp[3] == 0x80:
- raise MQTTException(resp[3])
- return
- # Wait for a single incoming MQTT message and process it.
- # Subscribed messages are delivered via the callback.
- def wait_msg(self):
- res = self.sock.read(1)
- self.sock.setblocking(True)
- if res is None:
- return None
- if res == b"":
- raise OSError(-1)
- if res == b"\xd0": # PINGRESP
- sz = self.sock.read(1)[0]
- assert sz == 0
- return None
- op = res[0]
- if op & 0xF0 != 0x30:
- return op
- sz = self._recv_len()
- topic_len = self.sock.read(2)
- topic_len = (topic_len[0] << 8) | topic_len[1]
- topic = self.sock.read(topic_len)
- sz -= topic_len + 2
- if op & 6:
- pid = self.sock.read(2)
- pid = pid[0] << 8 | pid[1]
- sz -= 2
- msg = self.sock.read(sz)
- self.cb(topic, msg)
- if op & 6 == 2:
- pkt = bytearray(b"\x40\x02\0\0")
- struct.pack_into("!H", pkt, 2, pid)
- self.sock.write(pkt)
- elif op & 6 == 4:
- assert 0
- return op
- # Checks whether a pending message from server is available.
- # If not, returns immediately with None. Otherwise processes it.
- def check_msg(self):
- self.sock.setblocking(False)
- return self.wait_msg()
- # =====================================================================
- # Robot logic
- # =====================================================================
- def _valid_rgb(t):
- try:
- return len(t) == 3 and all(isinstance(v, (int, float)) for v in t)
- except TypeError:
- return False
- # ---- encoder reader: auto-detect the firmware's function name + port spelling ----
- # The Makeblock library defines mbot2.EM_get_angle(port) (UUID
- # fadd7b62ec703bbded9b9b63d464e425); examples never read encoders, so the port
- # argument spelling isn't documented — probe a few and prefer the confirmed name.
- def _make_encoder_reader():
- port_pairs = (("EM1", "EM2"), ("em1", "em2"), (1, 2), ("1", "2"))
- for name in ("EM_get_angle", "encoder_motor_get_angle",
- "encoder_motor_get_pos", "encoder_motor_get_distance"):
- fn = getattr(mbot2, name, None)
- if not fn:
- continue
- for pl, pr in port_pairs:
- try:
- fn(pl)
- # Remember which name/port worked so the UI can show whether
- # odometry is real or the open-loop fallback.
- global ENC_SRC, ENC_PORTS
- ENC_SRC = name + ":" + str(pl)
- ENC_PORTS = (pl, pr)
- return lambda a=pl, b=pr, f=fn: (f(a), f(b))
- except Exception:
- pass
- return None
- # ---- wheel-speed reader (diagnostics only) ----
- # mbot2.EM_get_speed(port) is confirmed in the Makeblock API table (UUID
- # 9c703762b00091af8ecdf503fab52745), same undocumented port argument as
- # EM_get_angle, so reuse whatever spelling the encoder probe settled on.
- def _make_speed_reader():
- fn = getattr(mbot2, "EM_get_speed", None)
- if not fn or not ENC_PORTS:
- return None
- pl, pr = ENC_PORTS
- try:
- fn(pl)
- return lambda a=pl, b=pr, f=fn: (f(a), f(b))
- except Exception:
- return None
- # ---- board health probes (all optional; absent APIs simply report None) ----
- # Verified in the Makeblock API table: cyberpi.get_battery() (3988e0aa…),
- # cyberpi.get_extra_battery() (e8e71442…), cyberpi.get_firmware_version()
- # (84130d80…), cyberpi.timer.get() (5535075a…). All take no arguments.
- # Note: the API exposes no Wi-Fi RSSI or IP-address call, so signal strength
- # cannot be reported — only the boolean wifi.is_connect().
- def _probe(fn_path, *args):
- obj = cyberpi
- for part in fn_path.split("."):
- obj = getattr(obj, part, None)
- if obj is None:
- return None
- try:
- return obj(*args)
- except Exception:
- return None
- # ---- native line-offset reader ----
- # mbuild.quad_rgb_sensor.get_offset_track(index) -> ~ -100..100 (line deviation:
- # negative = line left, positive = line right). `index` is the integer mBuild
- # port (1), not a probe string. Names vary slightly by firmware, so auto-detect.
- def _make_offset_reader():
- for name in ("get_offset_track", "get_line_offset", "get_offset"):
- fn = getattr(mbuild.quad_rgb_sensor, name, None)
- if fn:
- try:
- fn(1)
- global OFFSET_SRC
- OFFSET_SRC = name
- return lambda f=fn: f(1)
- except Exception:
- pass
- return None
- ENC_SRC = "open-loop" # set by _make_encoder_reader if a real encoder answers
- ENC_PORTS = None
- OFFSET_SRC = None # set by _make_offset_reader if the native API exists
- _read_enc = _make_encoder_reader()
- _read_offset = _make_offset_reader()
- _read_speed = _make_speed_reader()
- FIRMWARE = _probe("get_firmware_version")
- _pseudo_l = 0.0
- _pseudo_r = 0.0
- _last_ms = time.ticks_ms()
- def read_encoders():
- global _pseudo_l, _pseudo_r, _last_ms
- if _read_enc:
- l, r = _read_enc()
- return ENC_L_SIGN * l, ENC_R_SIGN * r
- # Open-loop fallback: integrate commanded power into pseudo-encoder degrees
- # (~286.5 deg/s per 100% power for an 80 mm wheel at ~200 mm/s). Apply the
- # same wheel-direction signs as the real-encoder path so odometry matches.
- now = time.ticks_ms()
- dt = time.ticks_diff(now, _last_ms) / 1000.0
- _last_ms = now
- _pseudo_l += state["pl"] / 100.0 * 286.5 * dt
- _pseudo_r += state["pr"] / 100.0 * 286.5 * dt
- return ENC_L_SIGN * _pseudo_l, ENC_R_SIGN * _pseudo_r
- def _brightness(port):
- return (int(mbuild.quad_rgb_sensor.get_red(port))
- + int(mbuild.quad_rgb_sensor.get_green(port))
- + int(mbuild.quad_rgb_sensor.get_blue(port)))
- def read_color():
- r = int(mbuild.quad_rgb_sensor.get_red(COLOR_PORT))
- g = int(mbuild.quad_rgb_sensor.get_green(COLOR_PORT))
- b = int(mbuild.quad_rgb_sensor.get_blue(COLOR_PORT))
- name = "background"
- best = 90 * 90 * 3
- for n, (cr, cg, cb) in NAMED.items():
- d = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2
- if d < best:
- best = d
- name = n
- return r, g, b, name
- LINE_DEBUG = {"raw": None, "corr": None, "lb": None, "rb": None} # surfaced via TEL_COLOR for live tuning
- def line_powers():
- """Return (left, right) power for line following per the selected method."""
- if state["method"] == "offset" and _read_offset:
- off = _read_offset() # expected ~ -100..100, 0 = centered
- # Sign flipped: the "negative = line left" assumption in get_offset_track's
- # docs doesn't hold on this unit — confirmed by hardware turning the wrong
- # way on a real corner.
- corr = -LINE_GAIN * (off / 100.0)
- corr = max(-LINE_CORR_MAX, min(LINE_CORR_MAX, corr))
- LINE_DEBUG["raw"], LINE_DEBUG["corr"] = off, corr
- return LINE_BASE + corr, LINE_BASE - corr
- # RGB-brightness steer: the darker probe is over the line.
- lb, rb = _brightness(LINE_L_PORT), _brightness(LINE_R_PORT)
- err = (rb - lb) / 765.0 # >0 => left darker => line on the left
- corr = LINE_GAIN * err
- LINE_DEBUG["lb"], LINE_DEBUG["rb"] = lb, rb
- corr = max(-LINE_CORR_MAX, min(LINE_CORR_MAX, corr))
- LINE_DEBUG["raw"], LINE_DEBUG["corr"] = round(err, 3), corr
- return LINE_BASE - corr, LINE_BASE + corr
- def drive(l, r):
- l = max(-100, min(100, int(l)))
- r = max(-100, min(100, int(r)))
- state["pl"], state["pr"] = l, r
- mbot2.drive_power(l, DRIVE_R_SIGN * r)
- def publish_status(client):
- """Status + board diagnostics for the web UI's debug panel (1 Hz).
- Every health field is best-effort: a firmware that lacks a call reports null
- rather than crashing the control loop.
- """
- spd = None
- if _read_speed:
- try:
- sl, sr = _read_speed()
- spd = [sl, sr]
- except Exception:
- spd = None
- client.publish(TEL_STATUS, json.dumps({
- "mode": state["mode"], "method": state["method"], "online": True,
- "battery": _probe("get_battery"),
- "extra_battery": _probe("get_extra_battery"),
- "fw": FIRMWARE,
- "uptime": _probe("timer.get"),
- "wifi": _probe("wifi.is_connect"),
- "enc_src": ENC_SRC,
- "offset_src": OFFSET_SRC,
- "spd": spd,
- }))
- def on_message(topic, payload):
- try:
- p = json.loads(payload) if payload else {}
- except Exception:
- return
- t = topic.decode() if isinstance(topic, bytes) else topic
- if t == CMD_DRIVE:
- if state["mode"] == "manual":
- drive(p.get("l", 0), p.get("r", 0))
- elif t == CMD_STOP:
- state["mode"] = "manual"
- state["target"] = None
- drive(0, 0)
- elif t == CMD_LED:
- r, g, b = int(p.get("r", 0)), int(p.get("g", 0)), int(p.get("b", 0))
- idx = int(p.get("index", 0))
- if idx == 0:
- cyberpi.led.on(r, g, b) # all LEDs
- else:
- cyberpi.led.on(r, g, b, id=idx) # single LED 1..5
- elif t == CMD_MODE:
- state["mode"] = p.get("mode", "manual")
- tgt = p.get("target")
- state["target"] = tgt if _valid_rgb(tgt) else None
- if p.get("method") in ("rgb", "offset"):
- state["method"] = p["method"]
- dist = p.get("distance_ticks")
- if state["mode"] in ("color_seek", "line_follow") and isinstance(dist, (int, float)) and dist > 0:
- state["distance_ticks"] = dist
- state["start_l"], state["start_r"] = read_encoders()
- state["last_progress"] = None
- else:
- state["distance_ticks"] = None
- if state["mode"] == "manual":
- drive(0, 0)
- def ensure_wifi():
- if not cyberpi.wifi.is_connect():
- cyberpi.wifi.connect(WIFI_SSID, WIFI_PASS)
- while not cyberpi.wifi.is_connect():
- cyberpi.console.println("wifi...")
- time.sleep(0.5)
- cyberpi.console.println("wifi ok")
- def connect_mqtt(client):
- ensure_wifi()
- client.connect()
- for topic in (CMD_DRIVE, CMD_LED, CMD_MODE, CMD_STOP):
- client.subscribe(topic)
- publish_status(client)
- cyberpi.console.println("mqtt ok")
- def main():
- client = MQTTClient("mbot2-" + str(time.ticks_ms()), BROKER_IP,
- port=BROKER_PORT, keepalive=30)
- client.set_callback(on_message)
- # Last-Will: broker publishes this if the robot drops, so the UI clears.
- client.set_last_will(TEL_STATUS, json.dumps({"mode": "offline", "online": False}))
- connect_mqtt(client)
- last_enc = last_col = last_status = last_ping = time.ticks_ms()
- while True:
- try:
- client.check_msg() # non-blocking; fires on_message for commands
- now = time.ticks_ms()
- mode = state["mode"]
- # Read the color sensor only when a mode needs it or telemetry is due.
- col_due = time.ticks_diff(now, last_col) >= 200
- color = read_color() if (mode != "manual" or col_due) else None
- if mode == "color_seek" and state["target"]:
- drive(25, 25)
- if state["distance_ticks"]:
- el, er = read_encoders()
- traveled = (abs(el - state["start_l"]) + abs(er - state["start_r"])) / 2.0
- pct = int(min(100, traveled / state["distance_ticks"] * 100))
- if pct != state["last_progress"]:
- state["last_progress"] = pct
- client.publish(TEL_MISSION, json.dumps(
- {"event": "progress", "progress": pct}))
- tr, tg, tb = state["target"]
- if color and abs(color[0] - tr) < COLOR_MATCH_TOL and abs(color[1] - tg) < COLOR_MATCH_TOL \
- and abs(color[2] - tb) < COLOR_MATCH_TOL:
- drive(0, 0)
- state["mode"] = "manual"
- was_mission = state["distance_ticks"] is not None
- state["distance_ticks"] = None
- publish_status(client) # report the stop immediately
- if was_mission:
- client.publish(TEL_MISSION, json.dumps({"event": "arrival"}))
- elif mode == "line_follow":
- l, r = line_powers()
- drive(l, r)
- if state["distance_ticks"]:
- el, er = read_encoders()
- traveled = (abs(el - state["start_l"]) + abs(er - state["start_r"])) / 2.0
- pct = int(min(100, traveled / state["distance_ticks"] * 100))
- if pct != state["last_progress"]:
- state["last_progress"] = pct
- client.publish(TEL_MISSION, json.dumps(
- {"event": "progress", "progress": pct}))
- if state["target"] and color: # optional stop-on-color
- tr, tg, tb = state["target"]
- if abs(color[0] - tr) < COLOR_MATCH_TOL and abs(color[1] - tg) < COLOR_MATCH_TOL \
- and abs(color[2] - tb) < COLOR_MATCH_TOL:
- drive(0, 0)
- state["mode"] = "manual"
- was_mission = state["distance_ticks"] is not None
- state["distance_ticks"] = None
- publish_status(client)
- if was_mission:
- client.publish(TEL_MISSION, json.dumps({"event": "arrival"}))
- if time.ticks_diff(now, last_enc) >= 50: # 20 Hz encoders
- el, er = read_encoders()
- client.publish(TEL_ENC, json.dumps({"l": el, "r": er, "t": now}))
- last_enc = now
- if col_due: # 5 Hz color
- if color is None:
- color = read_color()
- client.publish(TEL_COLOR, json.dumps(
- {"r": color[0], "g": color[1], "b": color[2], "name": color[3],
- "line_raw": LINE_DEBUG["raw"], "line_corr": LINE_DEBUG["corr"],
- "line_lb": LINE_DEBUG["lb"], "line_rb": LINE_DEBUG["rb"]}))
- last_col = now
- if time.ticks_diff(now, last_status) >= 1000:
- publish_status(client)
- last_status = now
- if time.ticks_diff(now, last_ping) >= 15000: # keep session alive
- client.ping()
- last_ping = now
- time.sleep(0.01)
- except Exception as e:
- # Safety first: stop the motors, then rebuild the Wi-Fi/MQTT link so a
- # transient drop can't leave the robot driving at the last power.
- try:
- mbot2.drive_power(0, 0)
- except Exception:
- pass
- cyberpi.console.println("link lost, reconnecting")
- try:
- client.disconnect()
- except Exception:
- pass
- while True:
- try:
- connect_mqtt(client)
- break
- except Exception:
- time.sleep(2)
- last_enc = last_col = last_status = last_ping = time.ticks_ms()
- main()
Pasted 2026-10-05 17:24:44
Short link:
Short link: