/ switch_g2_wifi_to_ble.py
switch_g2_wifi_to_ble.py
1 #!/usr/bin/env python3 2 """ 3 Switch Station G2 from WiFi to Bluetooth: disable WiFi and optionally start BLE pairing. 4 Use when the RNode is connected via USB. Ensure nothing else is using the serial port 5 (rnsd, rnodeconf, etc.). 6 7 IMPORTANT – When you see the PIN: 8 The RNode sends the PIN over USB only when Linux *initiates* pairing. So: 9 1. Run this script with --pair (or --pair-only). Leave it running. 10 2. On Linux, use the terminal: bluetoothctl → power on → scan on → pair <MAC> 11 3. When you run "pair <MAC>", the PIN will appear in this script's window. Enter it in bluetoothctl. 12 Many desktop Bluetooth GUIs do not show a PIN prompt for BLE; use bluetoothctl. 13 14 Usage: 15 Switch to BLE (WiFi off, start pairing, show PIN when you pair from Linux): 16 python3 switch_g2_wifi_to_ble.py /dev/ttyACM0 --pair 17 18 Only listen for PIN (device already in pairing mode; you pair from Linux now): 19 python3 switch_g2_wifi_to_ble.py /dev/ttyACM0 --listen-only 20 21 Only disable WiFi (no pairing): 22 python3 switch_g2_wifi_to_ble.py /dev/ttyACM0 23 """ 24 25 import argparse 26 import sys 27 import threading 28 import time 29 30 try: 31 import serial 32 except ImportError: 33 print("Need pyserial: pip install pyserial") 34 sys.exit(1) 35 36 # KISS 37 FEND = 0xC0 38 FESC = 0xDB 39 TFEND = 0xDC 40 TFESC = 0xDD 41 42 CMD_WIFI_MODE = 0x6A 43 CMD_BT_CTRL = 0x46 44 CMD_BT_PIN = 0x62 45 CMD_ROM_READ = 0x51 46 47 WR_WIFI_OFF = 0x00 48 BT_CTRL_OFF = 0x00 49 BT_CTRL_ON = 0x01 50 BT_CTRL_PAIR = 0x02 # start BLE and enable pairing 51 52 EEPROM_RESERVED = 200 53 ADDR_CONF_BT = 0xB0 54 ADDR_CONF_WIFI = 0xBA 55 BT_ENABLE_BYTE = 0x73 56 57 # Firmware pairing window (seconds); we listen a bit longer 58 PAIRING_TIMEOUT_SEC = 40 59 60 61 def send_kiss(ser: serial.Serial, cmd: int, payload: bytes) -> None: 62 """Send a KISS frame: FEND, cmd, escaped(payload), FEND.""" 63 out = bytearray([FEND, cmd]) 64 for b in payload: 65 if b == FESC: 66 out.extend([FESC, TFESC]) 67 elif b == FEND: 68 out.extend([FESC, TFEND]) 69 else: 70 out.append(b) 71 out.append(FEND) 72 ser.write(bytes(out)) 73 time.sleep(0.05) 74 75 76 def read_pin_from_frame(data: bytearray) -> int | None: 77 """Unescape and parse CMD_BT_PIN payload (4 bytes big-endian).""" 78 buf = [] 79 i = 0 80 while i < len(data): 81 if data[i] == FESC and i + 1 < len(data): 82 if data[i + 1] == TFEND: 83 buf.append(FEND) 84 elif data[i + 1] == TFESC: 85 buf.append(FESC) 86 else: 87 buf.append(data[i]) 88 i += 2 89 else: 90 buf.append(data[i]) 91 i += 1 92 if len(buf) >= 4: 93 return (buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3] 94 return None 95 96 97 def _unescape(data: bytearray) -> list[int]: 98 buf = [] 99 i = 0 100 while i < len(data): 101 if data[i] == FESC and i + 1 < len(data): 102 if data[i + 1] == TFEND: 103 buf.append(FEND) 104 elif data[i + 1] == TFESC: 105 buf.append(FESC) 106 else: 107 buf.append(data[i]) 108 i += 2 109 else: 110 buf.append(data[i]) 111 i += 1 112 return buf 113 114 115 def read_rom(ser: serial.Serial, timeout: float = 3.0) -> list[int] | None: 116 """Send CMD_ROM_READ, return 200-byte EEPROM or None. 117 Firmware only triggers the dump when it receives a payload byte after the command.""" 118 ser.write(bytes([FEND, CMD_ROM_READ, 0x00, FEND])) 119 time.sleep(0.15) 120 ser.timeout = 0.05 121 deadline = time.monotonic() + timeout 122 in_frame = False 123 escape = False 124 command = None 125 payload = bytearray() 126 while time.monotonic() < deadline: 127 raw = ser.read(512) 128 if not raw: 129 continue 130 for b in raw: 131 b = b & 0xFF 132 if b == FEND: 133 if in_frame and command == CMD_ROM_READ and len(payload) > 0: 134 dec = _unescape(payload) 135 if len(dec) >= EEPROM_RESERVED: 136 return dec[:EEPROM_RESERVED] 137 in_frame = True 138 escape = False 139 command = None 140 payload.clear() 141 continue 142 if not in_frame or command is None: 143 if in_frame and command is None: 144 command = b 145 continue 146 if escape: 147 if b == TFEND: 148 payload.append(FEND) 149 elif b == TFESC: 150 payload.append(FESC) 151 else: 152 payload.append(b) 153 escape = False 154 elif b == FESC: 155 escape = True 156 else: 157 payload.append(b) 158 return None 159 160 161 def listen_for_pin( 162 ser: serial.Serial, 163 timeout: float, 164 pin_received: threading.Event, 165 pin_value: list, 166 ) -> None: 167 """Listen for CMD_BT_PIN (0x62) and store/print the 6-digit PIN. Sets pin_received when done.""" 168 deadline = time.monotonic() + timeout 169 in_frame = False 170 escape = False 171 command = None 172 payload = bytearray() 173 174 ser.timeout = 0.1 175 while time.monotonic() < deadline and not pin_received.is_set(): 176 raw = ser.read(256) 177 if not raw: 178 continue 179 for b in raw: 180 b = b & 0xFF 181 if b == FEND: 182 if in_frame and command == CMD_BT_PIN and len(payload) >= 4: 183 pin = read_pin_from_frame(payload) 184 if pin is not None and 100000 <= pin <= 999999: 185 pin_value.append(pin) 186 print(f"\n>>> BLE pairing PIN: {pin:06d} <<<") 187 print("Enter this in bluetoothctl when it asks for the passkey.\n") 188 pin_received.set() 189 in_frame = True 190 escape = False 191 command = None 192 payload.clear() 193 continue 194 if not in_frame: 195 continue 196 if command is None: 197 command = b 198 continue 199 if escape: 200 if b == TFEND: 201 payload.append(FEND) 202 elif b == TFESC: 203 payload.append(FESC) 204 else: 205 payload.append(b) 206 escape = False 207 elif b == FESC: 208 escape = True 209 else: 210 payload.append(b) 211 212 213 def main() -> None: 214 parser = argparse.ArgumentParser( 215 description="Switch Station G2 from WiFi to Bluetooth (disable WiFi, optional BLE pairing)." 216 ) 217 parser.add_argument( 218 "port", 219 help="Serial port (e.g. /dev/ttyACM0)", 220 ) 221 parser.add_argument( 222 "--pair", 223 action="store_true", 224 help="Disable WiFi, start BLE pairing, then listen for PIN when you pair from Linux.", 225 ) 226 parser.add_argument( 227 "--pair-only", 228 action="store_true", 229 help="Only start BLE pairing (do not change WiFi); listen for PIN when you pair from Linux.", 230 ) 231 parser.add_argument( 232 "--listen-only", 233 action="store_true", 234 help="Only listen for PIN on serial (device already in pairing mode). Pair from Linux now.", 235 ) 236 parser.add_argument( 237 "--no-listen", 238 action="store_true", 239 help="With --pair/--pair-only: do not listen for PIN (rely on display only).", 240 ) 241 args = parser.parse_args() 242 243 if args.pair and args.pair_only: 244 parser.error("Use either --pair or --pair-only, not both") 245 sys.exit(1) 246 if args.listen_only and (args.pair or args.pair_only or args.no_listen): 247 parser.error("--listen-only cannot be combined with --pair, --pair-only, or --no-listen") 248 sys.exit(1) 249 250 try: 251 ser = serial.Serial(args.port, 115200, timeout=2) 252 except serial.SerialException as e: 253 print(f"Failed to open {args.port}: {e}") 254 sys.exit(1) 255 256 print("Station G2: switch to Bluetooth") 257 time.sleep(0.5) 258 259 if args.listen_only: 260 print("Listening for PIN only (device should already be in pairing mode).") 261 print("On Linux run: bluetoothctl → power on → scan on → pair <RNode MAC>") 262 print("The PIN will appear below when you run 'pair'. Ctrl+C to stop.\n") 263 pin_received = threading.Event() 264 pin_value: list = [] 265 try: 266 listen_for_pin(ser, 300.0, pin_received, pin_value) 267 except KeyboardInterrupt: 268 pass 269 ser.close() 270 return 271 272 if not args.pair_only: 273 send_kiss(ser, CMD_WIFI_MODE, bytes([WR_WIFI_OFF])) 274 print(" WiFi disabled (saved to EEPROM).") 275 print(" Waiting 2 s for WiFi to shut down before starting BLE...") 276 time.sleep(2.0) 277 else: 278 print(" Skipping WiFi (--pair-only).") 279 280 if args.pair or args.pair_only: 281 pin_received = threading.Event() 282 pin_value: list = [] 283 # Start listener *before* sending pair command so we don't miss the PIN 284 # (firmware sends PIN when Linux initiates pairing, not when we send CMD_BT_CTRL). 285 listener = threading.Thread( 286 target=listen_for_pin, 287 args=(ser, PAIRING_TIMEOUT_SEC, pin_received, pin_value), 288 daemon=True, 289 ) 290 listener.start() 291 292 send_kiss(ser, CMD_BT_CTRL, bytes([BT_CTRL_PAIR])) 293 print(" BLE pairing started (pairing window ~35 s).") 294 if not args.no_listen: 295 print() 296 print(" On your Linux PC run in a terminal:") 297 print(" bluetoothctl") 298 print(" power on") 299 print(" scan on") 300 print(" (wait until you see your RNode, note its MAC address)") 301 print(" pair XX:XX:XX:XX:XX:XX") 302 print() 303 print(" When you run 'pair', the PIN will appear HERE. Enter it in bluetoothctl.") 304 print(" (Many desktop Bluetooth GUIs do not show a PIN; use bluetoothctl.)") 305 print() 306 listener.join() 307 else: 308 print(" Check the device display for the 6-digit PIN.") 309 listener.join() 310 311 # Verify EEPROM was written 312 print("Verifying EEPROM...") 313 rom = read_rom(ser) 314 if rom is not None: 315 wifi_byte = rom[ADDR_CONF_WIFI] 316 bt_byte = rom[ADDR_CONF_BT] 317 wifi_ok = wifi_byte == WR_WIFI_OFF if not args.pair_only else True 318 bt_ok = (bt_byte == BT_ENABLE_BYTE) if (args.pair or args.pair_only) else True 319 print(f" WiFi 0xBA = 0x{wifi_byte:02X} (0x00=OFF) ", "OK" if wifi_ok else "NOT updated") 320 if args.pair or args.pair_only: 321 print(f" BT 0xB0 = 0x{bt_byte:02X} (0x73=ON) ", "OK" if bt_ok else "NOT updated") 322 else: 323 print(" Could not read EEPROM (device may not have responded).") 324 325 ser.close() 326 print("Done. Reboot or power-cycle the G2 so WiFi stays off.") 327 print("To pair: bluetoothctl → power on → scan on → pair <MAC> → enter PIN when prompted.") 328 329 330 if __name__ == "__main__": 331 main()