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