Archives
Trending
Support
Login
clear text
XML
Django
JavaScript
MATLAB
C
C++
C#
Python
SQL
Shell
Bash
Markdown
YAML
JSON
HTML
CSS
PHP
Java
Ruby
Go
Rust
Swift
Kotlin
Arduino
TypeScript
Perl
Autohotkey
Lua
SQF
R
Scala
Haskell
Groovy
Dart
Clojure
VB.NET
Objective-C
PowerShell
Bash
CoffeeScript
Verilog
# 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/micropython/micropython-lib), 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()
Mark as private (unlisted)
for 30 minutes
for 6 hours
for 1 day
for 1 week
for 1 month
for 1 year
everlasting (like CCCP)