# 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()