#!/usr/bin/env python # -*- coding: utf-8 -*- """ ble_ota_update.py - PC-side OTA updater for CAIIC-MCM devices, transport protocol per docs/ble_protocol.md section 6.6 (0xCA frames, OTA_BEGIN/DATA/ END/ABORT -> framed OTA_RSP acks). Two channels: BLE (default): dedicated OTA characteristic ...e0005 - one frame per write-with-response, the framed OTA_RSP is read back from the same characteristic (lockstep, no notify needed). UART (--uart): e.g. --uart COM4 - the CLI command "ota" switches the serial link to binary frame mode; same frames on the wire. Both channels select the payload by CUR_BANK (info item 0x07, read-only characteristic ...e0004 / CLI "devinfo"): OTA always writes the INACTIVE bank with the image linked for it, from the single-file package (mothercup_ble_ota.bin, 52B header + both payloads). usage: ble_ota.bat [mothercup_ble_ota.bin] ble_ota.bat --uart COM4 [mothercup_ble_ota.bin] Success = the device resets after OTA_END and comes back on the new bank with the new version (verified by a second CUR_BANK/FW_VERSION read). """ import asyncio import os import struct import sys import zlib NAME_PREFIX = "CAIIC-MCM-20260902" OTA_RW_UUID = "00002760-08c2-11e1-9073-0e8ac72e0005" # OTA: write frame, read RSP INFO_RD_UUID = "00002760-08c2-11e1-9073-0e8ac72e0004" # Read-only info TLV TYPE_OTA_BEGIN = 0x10 TYPE_OTA_DATA = 0x11 TYPE_OTA_END = 0x12 TYPE_OTA_ABORT = 0x13 TYPE_OTA_RSP = 0x1F COMBO_MAGIC = 0xCA10BA11 COMBO_HDR_LEN = 52 RSP_TIMEOUT_S = 5.0 REBOOT_WAIT_S = 20.0 _seq = [0] def crc32(data): return zlib.crc32(data) & 0xFFFFFFFF def crc16_ccitt(data): crc = 0xFFFF for b in data: crc ^= b << 8 for _ in range(8): crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF return crc def encode_frame(ftype, payload): out = bytearray([0xCA, ftype & 0xFF, _seq[0] & 0xFF, len(payload) & 0xFF, (len(payload) >> 8) & 0xFF]) out += bytes(payload) crc = crc16_ccitt(out[1:]) out += bytes([crc & 0xFF, (crc >> 8) & 0xFF]) _seq[0] = (_seq[0] + 1) & 0xFF return bytes(out) class FrameDecoder: """0xCA byte-stream reassembly (same rules as the firmware).""" def __init__(self): self.buf = bytearray() def feed(self, data): frames = [] self.buf += bytes(data) while True: while self.buf and self.buf[0] != 0xCA: del self.buf[0] if len(self.buf) < 5: break plen = self.buf[3] | (self.buf[4] << 8) if plen > 480: del self.buf[0] continue total = 5 + plen + 2 if len(self.buf) < total: break crc = crc16_ccitt(self.buf[1:5 + plen]) rx = self.buf[5 + plen] | (self.buf[5 + plen + 1] << 8) if crc != rx: del self.buf[0] continue frames.append((self.buf[1], self.buf[2], bytes(self.buf[5:5 + plen]))) del self.buf[:total] return frames def parse_rsp(payload): """OTA_RSP payload: {cmd_echo u8, status u8, offset u32 LE}.""" if len(payload) < 6: raise RuntimeError("short OTA_RSP") return payload[0], payload[1], int.from_bytes(payload[2:6], "little") # --------------------------------------------------------------------------- # Transports: xfer(frame_bytes) -> rsp frame bytes (lockstep request/ack) # --------------------------------------------------------------------------- class BleTransport: """BLE OTA characteristic ...e0005: write frame, then read the RSP. The ATT write confirmation can complete before the device's BLE task has finished processing the write indication (and a DATA frame may trigger a flash sector erase, tens of ms), so the RSP read polls until non-empty.""" def __init__(self, client): self.client = client async def xfer(self, frame): await self.client.write_gatt_char(OTA_RW_UUID, frame, response=True) deadline = asyncio.get_event_loop().time() + RSP_TIMEOUT_S while True: rsp = bytes(await self.client.read_gatt_char(OTA_RW_UUID)) if rsp: return rsp if asyncio.get_event_loop().time() > deadline: raise asyncio.TimeoutError("no OTA_RSP on BLE read") await asyncio.sleep(0.02) class UartTransport: """UART binary mode: frames on the wire, RSP frames come back on RX.""" def __init__(self, port, baud=115200): import serial self.ser = serial.Serial(port, baud, timeout=0.1) self.dec = FrameDecoder() async def enter_ota_mode(self): """Text CLI first: devinfo tells us the running bank ("cur bank: APPn"); then "ota" switches the frontend to binary frame mode.""" self.ser.write(b"devinfo\r") await asyncio.sleep(0.5) text = self.ser.read(self.ser.in_waiting or 1).decode("ascii", "replace") bank = 0 for line in text.splitlines(): if "cur bank:" in line: bank = 2 if "APP2" in line else 1 self.ser.write(b"ota\r") await asyncio.sleep(0.4) self.ser.read(self.ser.in_waiting or 1) # drain echo/notice text return bank async def xfer(self, frame): self.ser.write(frame) deadline = asyncio.get_event_loop().time() + RSP_TIMEOUT_S while True: data = await asyncio.to_thread(self.ser.read, 256) for ftype, seq, payload in self.dec.feed(data): if ftype == TYPE_OTA_RSP: # hand back a raw re-encoded frame (uniform with BLE) body = bytes([ftype, seq, len(payload) & 0xFF, (len(payload) >> 8) & 0xFF]) + payload crc = crc16_ccitt(body) return bytes([0xCA]) + body + bytes([crc & 0xFF, crc >> 8]) if asyncio.get_event_loop().time() > deadline: raise asyncio.TimeoutError("no OTA_RSP on UART") # --------------------------------------------------------------------------- # Common helpers # --------------------------------------------------------------------------- def parse_combo(path): """Parse the combined OTA package; returns (version, {bank: payload}). Header (52B LE): magic | hdr_len | version | total_size | payload_crc | count(=2) | b1_off/size/crc | b2_off/size/crc | hdr_crc.""" data = open(path, "rb").read() if len(data) < COMBO_HDR_LEN: raise SystemExit("bad package: too small") (magic, hdr_len, version, total_size, payload_crc, count, b1o, b1s, b1c, b2o, b2s, b2c, hcrc) = \ struct.unpack_from("> 16, (v >> 8) & 0xFF, v & 0xFF) def decode_tlv(data): items = {} i = 0 while i + 2 <= len(data): iid, ilen = data[i], data[i + 1] if i + 2 + ilen > len(data): break items[iid] = data[i + 2:i + 2 + ilen] i += 2 + ilen return items async def ota_session(xfer, chunk, blob, version, target): """Lockstep OTA: BEGIN -> DATA* -> END, every frame acked by OTA_RSP.""" async def call(ftype, payload): stage = {TYPE_OTA_BEGIN: "BEGIN", TYPE_OTA_DATA: "DATA", TYPE_OTA_END: "END", TYPE_OTA_ABORT: "ABORT"}.get(ftype, "?") # transport errors (TimeoutError/OSError) propagate unwrapped so the # END handler can tell "ack lost due to reboot" apart from rejection rsp = await xfer(encode_frame(ftype, payload)) frames = FrameDecoder().feed(rsp) if len(frames) != 1 or frames[0][0] != TYPE_OTA_RSP: raise RuntimeError("%s: bad RSP frame: %s" % (stage, rsp.hex(" "))) echo, status, offset = parse_rsp(frames[0][2]) if echo != ftype: raise RuntimeError("%s: RSP echo 0x%02X != 0x%02X" % (stage, echo, ftype)) return status, offset status, _ = await call(TYPE_OTA_BEGIN, struct.pack("