From 3c4354d55e6a00cb918afbd1cd03f837271a8989 Mon Sep 17 00:00:00 2001 From: Qurratul Quais Date: Wed, 2 Sep 2026 18:08:46 -0400 Subject: [PATCH 1/6] Add PicoFirmwareFlasher to copy cached MicroPython UF2 onto blank Picos in BOOTSEL mode --- src/pi/PicoFirmwareFlasher.py | 312 ++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100755 src/pi/PicoFirmwareFlasher.py diff --git a/src/pi/PicoFirmwareFlasher.py b/src/pi/PicoFirmwareFlasher.py new file mode 100755 index 0000000..e5bb0cb --- /dev/null +++ b/src/pi/PicoFirmwareFlasher.py @@ -0,0 +1,312 @@ +#!/usr/bin/python3 +"""Flash MicroPython onto a blank Pico that has enumerated in BOOTSEL mode. + +Phase A of issue #19. Complements PicoScriptDeployer.py, which handles the +*next* stage: 99-pico.rules only matches 2e8a:0005, the serial interface a +board presents once MicroPython is already running, so a factory-fresh board +is invisible to it. This script covers the step before that. + + blank board --(this script)--> MicroPython --(PicoScriptDeployer)--> main.py + +Standalone use, before udev is involved: + + sudo python3 src/pi/PicoFirmwareFlasher.py /dev/sda1 + +Everything is logged to /tmp/deployer.log in the same format +PicoScriptDeployer.py uses. +""" +import datetime +import errno +import os +import subprocess +import sys +import time + +# Defaults match the paths already hardcoded throughout this project. +FIRMWARE_DIR = os.environ.get('PICO_FIRMWARE_DIR', '/home/project/firmware') +LOG_PATH = os.environ.get('PICO_DEPLOYER_LOG', '/tmp/deployer.log') + +# Kill switch: this file must exist or the script does nothing. Deliberately +# opt-in -- this runs as root and writes to removable media. +KILL_SWITCH_NAME = 'autoflash-enabled' + +# BOOTSEL USB product IDs -> (UF2 filename, expected FAT label). +# The vendor ID is 2e8a (Raspberry Pi) in both cases. +BOOTSEL_TARGETS = { + '0003': ('RPI_PICO_W.uf2', 'RPI-RP2'), # RP2040: Pico / Pico H / Pico W + '000f': ('RPI_PICO2_W.uf2', 'RP2350'), # RP2350: Pico 2 / Pico 2 W +} + +# In BOOTSEL the ROM bootloader enumerates, not the board, so a plain Pico and +# a Pico W present the same product id. This script cannot tell them apart and +# always flashes the wireless image. A non-wireless board will boot MicroPython +# fine and then die in main.py at network.WLAN(), so say so in the log. +WIRELESS_AMBIGUITY = { + '0003': 'Pico, Pico H and Pico W all report 2e8a:0003 in BOOTSEL', + '000f': 'Pico 2 and Pico 2 W both report 2e8a:000f in BOOTSEL', +} + +# The FAT filesystem is not ready the instant the udev add event fires. +FS_READY_ATTEMPTS = 10 +FS_READY_DELAY = 0.5 # seconds, doubled each attempt up to FS_READY_MAX +FS_READY_MAX = 4.0 + +MOUNT_ATTEMPTS = 5 +MOUNT_DELAY = 0.5 + +COPY_CHUNK = 64 * 1024 + +# The Pico resets the moment it has the whole UF2, so the block device is torn +# out from under us mid-write. These errnos mean "it rebooted", not "it broke". +DEVICE_GONE_ERRNOS = { + errno.EIO, errno.ENODEV, errno.ENOENT, errno.ENXIO, + errno.ESHUTDOWN, errno.EPIPE, errno.EBADF, +} + + +def log_message(message): + """Append one line to the deployer log. + + PicoScriptDeployer.py's log_message() adds no timestamp and leaves it to + callers; one of its call sites forgets. Timestamping here means every line + this script writes has one. + """ + line = f'{datetime.datetime.now()} {message}' + try: + with open(LOG_PATH, 'a') as log_file: + log_file.write(f'{line}\n') + except OSError: + pass # never let logging failure abort a flash + print(line) + + +def run(cmd, timeout=30): + """Run a command, returning (returncode, stdout+stderr).""" + try: + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=timeout) + return proc.returncode, proc.stdout.decode(errors='replace').strip() + except subprocess.TimeoutExpired: + return -1, f'timed out after {timeout}s' + except FileNotFoundError: + return -1, f'{cmd[0]} not found' + + +def udev_properties(device): + """Read udev properties for a device as a dict. Empty if unavailable.""" + code, out = run(['udevadm', 'info', '--query=property', f'--name={device}']) + if code != 0: + return {} + props = {} + for line in out.splitlines(): + if '=' in line: + key, _, value = line.partition('=') + props[key] = value + return props + + +def resolve_device(): + """Device path from argv[1], else the udev DEVNAME env var.""" + if len(sys.argv) > 1 and sys.argv[1].strip(): + return sys.argv[1].strip() + return os.environ.get('DEVNAME', '').strip() or None + + +def resolve_product_id(device, props): + """Product ID from argv[2], else udev env, else the device's properties.""" + if len(sys.argv) > 2 and sys.argv[2].strip(): + return sys.argv[2].strip().lower() + for source in (os.environ, props): + value = source.get('ID_MODEL_ID', '').strip().lower() + if value: + return value + return None + + +def wait_for_filesystem(device): + """Block until udev reports a filesystem label, or give up. + + The add event fires before the FAT filesystem is probed, so acting on it + immediately finds a device with no mountable filesystem. + """ + delay = FS_READY_DELAY + for attempt in range(1, FS_READY_ATTEMPTS + 1): + props = udev_properties(device) + label = props.get('ID_FS_LABEL', '') + if label: + log_message(f'Filesystem ready on {device} after {attempt} attempt(s), label: {label}') + return label, props + if not os.path.exists(device): + log_message(f'{device} disappeared while waiting for its filesystem') + return None, props + time.sleep(delay) + delay = min(delay * 2, FS_READY_MAX) + log_message(f'Gave up waiting for a filesystem label on {device} ' + f'after {FS_READY_ATTEMPTS} attempts') + return None, udev_properties(device) + + +def parse_mount_point(output): + """Pull the mount point out of udisksctl's chatter. + + Handles both 'Mounted /dev/sda1 at /run/media/root/RPI-RP2.' and the + 'already mounted at `/run/media/...`' error form. + """ + marker = ' at ' + if marker not in output: + return None + tail = output.rsplit(marker, 1)[1].strip() + tail = tail.strip('`\'".') + return tail or None + + +def mount(device): + """Mount via udisksctl, retrying while the filesystem settles.""" + delay = MOUNT_DELAY + for attempt in range(1, MOUNT_ATTEMPTS + 1): + code, out = run(['udisksctl', 'mount', '-b', device, '--no-user-interaction']) + mount_point = parse_mount_point(out) + if code == 0 and mount_point: + log_message(f'Mounted {device} at {mount_point}') + return mount_point + if mount_point and 'already mounted' in out.lower(): + log_message(f'{device} was already mounted at {mount_point}') + return mount_point + log_message(f'Mount attempt {attempt}/{MOUNT_ATTEMPTS} for {device} failed: {out}') + if not os.path.exists(device): + log_message(f'{device} disappeared before it could be mounted') + return None + time.sleep(delay) + delay = min(delay * 2, FS_READY_MAX) + return None + + +def unmount(device): + """Best effort. After a successful flash the device is already gone.""" + if not os.path.exists(device): + log_message(f'{device} already gone, nothing to unmount') + return + code, out = run(['udisksctl', 'unmount', '-b', device, '--no-user-interaction']) + if code == 0: + log_message(f'Unmounted {device}') + else: + log_message(f'Unmount of {device} returned {code}: {out}') + + +def copy_firmware(uf2_path, mount_point): + """Copy the UF2 onto the mounted board. + + Returns True if the firmware landed. The Pico reboots as soon as it has the + whole image, which tears the block device away mid-write -- so an I/O error + *after bytes have been written* is what success looks like. Treating it as + a failure is the classic way to make a working flash look broken. + """ + destination = os.path.join(mount_point, os.path.basename(uf2_path)) + total = os.path.getsize(uf2_path) + written = 0 + log_message(f'Copying {uf2_path} ({total} bytes) to {destination}') + + try: + with open(uf2_path, 'rb') as src, open(destination, 'wb') as dst: + while True: + chunk = src.read(COPY_CHUNK) + if not chunk: + break + dst.write(chunk) + written += len(chunk) + dst.flush() + os.fsync(dst.fileno()) + except OSError as err: + if written > 0 and err.errno in DEVICE_GONE_ERRNOS: + log_message(f'Device vanished after {written}/{total} bytes ' + f'(errno {err.errno} {errno.errorcode.get(err.errno, "?")}) ' + f'- this is the Pico rebooting, treating as SUCCESS') + return True + log_message(f'Copy failed after {written}/{total} bytes: {err}') + return False + + log_message(f'Wrote {written}/{total} bytes without interruption') + code, out = run(['sync'], timeout=15) + if code != 0: + log_message(f'sync returned {code}: {out}') + return True + + +def flash(device, product_id): + uf2_name, expected_label = BOOTSEL_TARGETS[product_id] + uf2_path = os.path.join(FIRMWARE_DIR, uf2_name) + + if product_id in WIRELESS_AMBIGUITY: + log_message(f'WARNING: {WIRELESS_AMBIGUITY[product_id]}; cannot confirm this ' + f'board has WiFi. Flashing {uf2_name} regardless - a non-wireless ' + f'board will boot but main.py will fail at network.WLAN()') + + if not os.path.isfile(uf2_path): + log_message(f'Firmware image {uf2_path} not found - cache it first, ' + f'this script never downloads at runtime') + return False + + label, _ = wait_for_filesystem(device) + if label is None: + return False + + # Safety: this runs as root and writes to removable media. Refuse anything + # that is not a Pico bootloader volume. + if label != expected_label: + log_message(f'Refusing to touch {device}: label is {label!r}, ' + f'expected {expected_label!r} for product {product_id}') + return False + + mount_point = mount(device) + if mount_point is None: + return False + + try: + return copy_firmware(uf2_path, mount_point) + finally: + unmount(device) + + +def main(): + device = resolve_device() + if not device: + log_message('No device given. Pass one as an argument ' + '(sudo python3 src/pi/PicoFirmwareFlasher.py /dev/sda1) ' + 'or set DEVNAME, as udev does.') + return 2 + + kill_switch = os.path.join(FIRMWARE_DIR, KILL_SWITCH_NAME) + if not os.path.exists(kill_switch): + log_message(f'Auto-flash disabled, skipping {device}. ' + f'Create {kill_switch} to enable.') + return 0 + + props = udev_properties(device) + product_id = resolve_product_id(device, props) + vendor_id = (os.environ.get('ID_VENDOR_ID') + or props.get('ID_VENDOR_ID', '')).strip().lower() + + log_message(f'BOOTSEL candidate - Device: {device}, ' + f'Vendor: {vendor_id or "unknown"}, Product: {product_id or "unknown"}') + + if vendor_id and vendor_id != '2e8a': + log_message(f'Ignoring {device}: vendor {vendor_id} is not Raspberry Pi (2e8a)') + return 0 + + if product_id not in BOOTSEL_TARGETS: + log_message(f'Ignoring {device}: product {product_id!r} is not a known ' + f'BOOTSEL id ({", ".join(sorted(BOOTSEL_TARGETS))})') + return 0 + + if flash(device, product_id): + log_message(f'MicroPython flashed successfully to {device}. ' + f'The board will re-enumerate as 2e8a:0005 and ' + f'PicoScriptDeployer.py takes over from there.') + return 0 + + log_message(f'Flash FAILED for {device}') + return 1 + + +if __name__ == '__main__': + sys.exit(main()) From 81522c026114d229dcf591babbcdeca91a2b7189 Mon Sep 17 00:00:00 2001 From: Qurratul Quais Date: Wed, 2 Sep 2026 18:09:01 -0400 Subject: [PATCH 2/6] Add flasher unit tests covering device-vanished success path, and ignore Python bytecode --- src/pi/test_PicoFirmwareFlasher.py | 286 +++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 src/pi/test_PicoFirmwareFlasher.py diff --git a/src/pi/test_PicoFirmwareFlasher.py b/src/pi/test_PicoFirmwareFlasher.py new file mode 100644 index 0000000..e638192 --- /dev/null +++ b/src/pi/test_PicoFirmwareFlasher.py @@ -0,0 +1,286 @@ +"""Run with: python3 -m unittest discover -s src/pi -p 'test_*.py' + +No hardware: every external effect (udevadm, udisksctl, the block device) +is mocked. The point is to pin down the decisions the script makes, above +all the one in requirement 4 -- a device that vanishes mid-write is a +SUCCESSFUL flash, not a failed one. +""" +import builtins +import errno +import os +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import PicoFirmwareFlasher as F # noqa: E402 + + +class VanishingFile: + """Accepts writes until `fail_after` bytes, then behaves like a block + device whose board just rebooted underneath it.""" + + def __init__(self, fail_after): + self.written = 0 + self.fail_after = fail_after + + def write(self, chunk): + if self.written >= self.fail_after: + raise OSError(errno.EIO, 'Input/output error') + self.written += len(chunk) + + def flush(self): + raise OSError(errno.EIO, 'Input/output error') + + def fileno(self): + return 1 + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +class FlasherTestCase(unittest.TestCase): + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.firmware_dir = self.tmp.name + self.log_path = os.path.join(self.firmware_dir, 'deployer.log') + self._patches = [ + mock.patch.object(F, 'FIRMWARE_DIR', self.firmware_dir), + mock.patch.object(F, 'LOG_PATH', self.log_path), + mock.patch.object(F.time, 'sleep', lambda *_: None), # no real backoff waits + mock.patch.object(builtins, 'print', lambda *_, **__: None), + ] + for p in self._patches: + p.start() + + def tearDown(self): + for p in self._patches: + p.stop() + self.tmp.cleanup() + + # --- helpers ----------------------------------------------------------- + + def enable(self): + open(os.path.join(self.firmware_dir, F.KILL_SWITCH_NAME), 'w').close() + + def cache_firmware(self, name='RPI_PICO_W.uf2', size=300000): + path = os.path.join(self.firmware_dir, name) + with open(path, 'wb') as fh: + fh.write(b'U' * size) + return path + + def log(self): + if not os.path.exists(self.log_path): + return '' + with open(self.log_path) as fh: + return fh.read() + + def run_main(self, *argv, env=None): + with mock.patch.object(sys, 'argv', ['PicoFirmwareFlasher.py', *argv]), \ + mock.patch.dict(os.environ, env or {}, clear=False): + return F.main() + + +# --- argument and environment handling -------------------------------------- + +class TestInputs(FlasherTestCase): + + def test_no_device_exits_2(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(self.run_main(), 2) + self.assertIn('No device given', self.log()) + + def test_device_from_udev_env_when_no_argument(self): + with mock.patch.object(sys, 'argv', ['x']), \ + mock.patch.dict(os.environ, {'DEVNAME': '/dev/sdz1'}): + self.assertEqual(F.resolve_device(), '/dev/sdz1') + + def test_argument_beats_udev_env(self): + with mock.patch.object(sys, 'argv', ['x', '/dev/sda1']), \ + mock.patch.dict(os.environ, {'DEVNAME': '/dev/sdz1'}): + self.assertEqual(F.resolve_device(), '/dev/sda1') + + def test_product_id_argument_beats_env_and_udev(self): + with mock.patch.object(sys, 'argv', ['x', '/dev/sda1', '000F']), \ + mock.patch.dict(os.environ, {'ID_MODEL_ID': '0003'}): + self.assertEqual(F.resolve_product_id('/dev/sda1', {'ID_MODEL_ID': '0003'}), '000f') + + +# --- guards ----------------------------------------------------------------- + +class TestGuards(FlasherTestCase): + + def test_kill_switch_absent_skips_and_exits_0(self): + self.assertEqual(self.run_main('/dev/sda1', '0003'), 0) + self.assertIn('Auto-flash disabled', self.log()) + self.assertIn(F.KILL_SWITCH_NAME, self.log()) + + def test_unknown_product_ignored(self): + self.enable() + with mock.patch.object(F, 'udev_properties', return_value={}): + self.assertEqual(self.run_main('/dev/sda1', '0005'), 0) + self.assertIn('not a known BOOTSEL id', self.log()) + + def test_non_raspberry_pi_vendor_ignored(self): + self.enable() + with mock.patch.object(F, 'udev_properties', return_value={'ID_VENDOR_ID': '0781'}): + self.assertEqual(self.run_main('/dev/sda1', '0003'), 0) + self.assertIn('not Raspberry Pi', self.log()) + + def test_missing_firmware_image_fails_without_downloading(self): + self.enable() + with mock.patch.object(F, 'udev_properties', return_value={}): + self.assertEqual(self.run_main('/dev/sda1', '0003'), 1) + self.assertIn('never downloads', self.log()) + + def test_wrong_label_is_refused(self): + # A root script writing to removable media must not touch a USB stick. + self.enable() + self.cache_firmware() + with mock.patch.object(F, 'wait_for_filesystem', return_value=('MY_USB_STICK', {})), \ + mock.patch.object(F, 'mount') as mount: + self.assertFalse(F.flash('/dev/sda1', '0003')) + mount.assert_not_called() + self.assertIn('Refusing to touch', self.log()) + + def test_wireless_ambiguity_is_logged_for_both_families(self): + # Fleet composition is unknown, so a mis-flash must at least be visible. + self.enable() + self.cache_firmware('RPI_PICO_W.uf2') + self.cache_firmware('RPI_PICO2_W.uf2') + for product, label in (('0003', 'RPI-RP2'), ('000f', 'RP2350')): + with mock.patch.object(F, 'wait_for_filesystem', return_value=(label, {})), \ + mock.patch.object(F, 'mount', return_value='/mnt/x'), \ + mock.patch.object(F, 'unmount'), \ + mock.patch.object(F, 'copy_firmware', return_value=True): + F.flash('/dev/sda1', product) + log = self.log() + self.assertIn('2e8a:0003 in BOOTSEL', log) + self.assertIn('2e8a:000f in BOOTSEL', log) + self.assertIn('network.WLAN()', log) + + def test_rp2350_selects_pico2_image_and_label(self): + self.enable() + self.cache_firmware('RPI_PICO2_W.uf2') + with mock.patch.object(F, 'wait_for_filesystem', return_value=('RP2350', {})), \ + mock.patch.object(F, 'mount', return_value='/mnt/x'), \ + mock.patch.object(F, 'unmount'), \ + mock.patch.object(F, 'copy_firmware', return_value=True) as copy: + self.assertTrue(F.flash('/dev/sda1', '000f')) + copy.assert_called_once() + self.assertTrue(copy.call_args[0][0].endswith('RPI_PICO2_W.uf2')) + + +# --- requirement 4: the reboot mid-write ------------------------------------ + +class TestDeviceVanishes(FlasherTestCase): + + def _copy_with(self, fail_after): + uf2 = self.cache_firmware() + real_open = builtins.open + + def fake_open(path, mode='r', *a, **k): + if 'w' in mode and str(path).endswith('.uf2'): + return VanishingFile(fail_after) + return real_open(path, mode, *a, **k) + + with mock.patch.object(builtins, 'open', fake_open): + return F.copy_firmware(uf2, self.firmware_dir) + + def test_vanish_after_bytes_landed_is_success(self): + self.assertTrue(self._copy_with(fail_after=128 * 1024)) + self.assertIn('treating as SUCCESS', self.log()) + self.assertIn('EIO', self.log()) + + def test_vanish_before_any_bytes_is_failure(self): + self.assertFalse(self._copy_with(fail_after=0)) + self.assertIn('Copy failed after 0/', self.log()) + self.assertNotIn('SUCCESS', self.log()) + + def test_unrelated_oserror_is_failure_even_after_bytes(self): + # ENOSPC is a real problem, not a reboot, so it must not be excused. + uf2 = self.cache_firmware() + real_open = builtins.open + + class FullDisk(VanishingFile): + def write(self, chunk): + if self.written >= self.fail_after: + raise OSError(errno.ENOSPC, 'No space left on device') + self.written += len(chunk) + + def fake_open(path, mode='r', *a, **k): + if 'w' in mode and str(path).endswith('.uf2'): + return FullDisk(64 * 1024) + return real_open(path, mode, *a, **k) + + with mock.patch.object(builtins, 'open', fake_open): + self.assertFalse(F.copy_firmware(uf2, self.firmware_dir)) + self.assertIn('Copy failed', self.log()) + + def test_uninterrupted_copy_is_also_success(self): + uf2 = self.cache_firmware(size=1000) + dest_dir = os.path.join(self.firmware_dir, 'mnt') + os.mkdir(dest_dir) + with mock.patch.object(F, 'run', return_value=(0, '')): + self.assertTrue(F.copy_firmware(uf2, dest_dir)) + self.assertIn('without interruption', self.log()) + self.assertEqual(os.path.getsize(os.path.join(dest_dir, 'RPI_PICO_W.uf2')), 1000) + + +# --- requirement 5: the filesystem is not ready when udev fires --------------- + +class TestFilesystemReadiness(FlasherTestCase): + + def test_waits_through_unlabelled_polls_then_succeeds(self): + answers = [{}, {}, {'ID_FS_LABEL': 'RPI-RP2'}] + with mock.patch.object(F, 'udev_properties', side_effect=answers), \ + mock.patch.object(os.path, 'exists', return_value=True): + label, _ = F.wait_for_filesystem('/dev/sda1') + self.assertEqual(label, 'RPI-RP2') + self.assertIn('after 3 attempt(s)', self.log()) + + def test_gives_up_after_max_attempts(self): + with mock.patch.object(F, 'udev_properties', return_value={}), \ + mock.patch.object(os.path, 'exists', return_value=True): + label, _ = F.wait_for_filesystem('/dev/sda1') + self.assertIsNone(label) + self.assertIn('Gave up', self.log()) + + def test_stops_early_if_device_disappears_while_waiting(self): + with mock.patch.object(F, 'udev_properties', return_value={}), \ + mock.patch.object(os.path, 'exists', return_value=False): + label, _ = F.wait_for_filesystem('/dev/sda1') + self.assertIsNone(label) + self.assertIn('disappeared while waiting', self.log()) + + def test_mount_retries_then_succeeds(self): + answers = [(1, 'Error mounting: not ready'), (0, 'Mounted /dev/sda1 at /run/media/root/RPI-RP2.')] + with mock.patch.object(F, 'run', side_effect=answers), \ + mock.patch.object(os.path, 'exists', return_value=True): + self.assertEqual(F.mount('/dev/sda1'), '/run/media/root/RPI-RP2') + self.assertIn('Mount attempt 1/', self.log()) + + +# --- udisksctl output parsing ----------------------------------------------- + +class TestMountPointParsing(unittest.TestCase): + + def test_standard_output_with_trailing_period(self): + self.assertEqual(F.parse_mount_point('Mounted /dev/sda1 at /run/media/root/RPI-RP2.'), + '/run/media/root/RPI-RP2') + + def test_already_mounted_error_form(self): + out = "Error mounting /dev/sda1: GDBus.Error:org.freedesktop.UDisks2.Error.AlreadyMounted: Device /dev/sda1 is already mounted at `/media/pi/RPI-RP2'." + self.assertEqual(F.parse_mount_point(out), '/media/pi/RPI-RP2') + + def test_no_mount_point_returns_none(self): + self.assertIsNone(F.parse_mount_point('Error mounting: device not found')) + + +if __name__ == '__main__': + unittest.main() From 2ccf16b4ad23119fae6d11a11fccabf52dfebad8 Mon Sep 17 00:00:00 2001 From: Qurratul Quais Date: Wed, 2 Sep 2026 18:09:18 -0400 Subject: [PATCH 3/6] Draft systemd-run udev rule for BOOTSEL Picos, untested on hardware and not installed --- .gitignore | 4 ++++ src/pi/98-pico-bootsel.rules | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/pi/98-pico-bootsel.rules diff --git a/.gitignore b/.gitignore index a605e2f..41eea8b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ logs/ src/pi/config.yaml src/pi/ptyserver.service +# Python bytecode (a stale .pyc will happily hide a source change) +__pycache__/ +*.py[cod] + # Compiled output *.o *.out diff --git a/src/pi/98-pico-bootsel.rules b/src/pi/98-pico-bootsel.rules new file mode 100644 index 0000000..b692704 --- /dev/null +++ b/src/pi/98-pico-bootsel.rules @@ -0,0 +1,39 @@ +# 98-pico-bootsel.rules -- issue #19 Phase A. +# DRAFT: not installed by the installer, not yet tested against hardware. +# +# Companion to 99-pico.rules. That rule matches 2e8a:0005, the serial interface +# a board presents once MicroPython is running. This one matches the step +# before that: a Pico whose flash is blank (or held in BOOTSEL) enumerates as a +# USB mass-storage device instead, and needs MicroPython copied onto it first. +# +# blank board --(this rule + PicoFirmwareFlasher.py)--> MicroPython +# --(99-pico.rules + PicoScriptDeployer.py)--> main.py +# +# Why systemd-run rather than calling the script straight from RUN+=: +# udev kills RUN+= commands that outlive its event timeout and runs them in a +# stripped-down environment. A UF2 copy plus filesystem-readiness retries can +# take longer than that, and udisksctl wants a normal environment. Handing +# off to a transient unit sidesteps both, and the flash then shows up in +# `journalctl -u 'pico-flash-*'` as well as /tmp/deployer.log. +# +# Install (manual for now; the installer does not know about this yet): +# sudo mkdir -p /home/project/firmware +# sudo cp RPI_PICO_W-.uf2 /home/project/firmware/RPI_PICO_W.uf2 +# sudo cp RPI_PICO2_W-.uf2 /home/project/firmware/RPI_PICO2_W.uf2 +# sudo cp src/pi/98-pico-bootsel.rules /etc/udev/rules.d/ +# sudo udevadm control --reload-rules +# sudo touch /home/project/firmware/autoflash-enabled # kill switch: absent = do nothing +# +# Watch it: +# tail -f /tmp/deployer.log +# journalctl -f -u 'pico-flash-*' +# +# Matches the partition node (/dev/sda1), which is where the RPI-RP2 / RP2350 +# filesystem lives and what udisksctl mounts. If `lsblk` on a real board shows +# the filesystem directly on /dev/sda with no sda1, change DEVTYPE to "disk". +# 0003 is the RP2040 bootloader, 000f the RP2350 bootloader; 2e8a is Raspberry +# Pi. ID_MODEL_ID is passed as an argument because systemd-run does not forward +# udev's environment into the unit. System python3 is enough: the script uses +# only the standard library, so the rshell venv is not needed here. + +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="partition", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="0003|000f", RUN+="/usr/bin/systemd-run --no-block --collect --unit=pico-flash-%k /usr/bin/python3 /home/project/remote-serial-pico/src/pi/PicoFirmwareFlasher.py %E{DEVNAME} %E{ID_MODEL_ID}" From c66608810825f946b9bdf401a64cada834dffbb6 Mon Sep 17 00:00:00 2001 From: Qurratul Quais Date: Tue, 8 Sep 2026 19:55:00 -0400 Subject: [PATCH 4/6] Log the BOOTSEL wireless ambiguity as a note, since every board here is a Pico W --- src/pi/PicoFirmwareFlasher.py | 11 ++++++----- src/pi/test_PicoFirmwareFlasher.py | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pi/PicoFirmwareFlasher.py b/src/pi/PicoFirmwareFlasher.py index e5bb0cb..5431ead 100755 --- a/src/pi/PicoFirmwareFlasher.py +++ b/src/pi/PicoFirmwareFlasher.py @@ -39,8 +39,9 @@ # In BOOTSEL the ROM bootloader enumerates, not the board, so a plain Pico and # a Pico W present the same product id. This script cannot tell them apart and -# always flashes the wireless image. A non-wireless board will boot MicroPython -# fine and then die in main.py at network.WLAN(), so say so in the log. +# always flashes the wireless image. Every board in this deployment is a Pico W, +# so this is logged as a note rather than a warning; a non-wireless board would +# boot MicroPython fine and then fail in main.py at network.WLAN(). WIRELESS_AMBIGUITY = { '0003': 'Pico, Pico H and Pico W all report 2e8a:0003 in BOOTSEL', '000f': 'Pico 2 and Pico 2 W both report 2e8a:000f in BOOTSEL', @@ -237,9 +238,9 @@ def flash(device, product_id): uf2_path = os.path.join(FIRMWARE_DIR, uf2_name) if product_id in WIRELESS_AMBIGUITY: - log_message(f'WARNING: {WIRELESS_AMBIGUITY[product_id]}; cannot confirm this ' - f'board has WiFi. Flashing {uf2_name} regardless - a non-wireless ' - f'board will boot but main.py will fail at network.WLAN()') + log_message(f'note: {WIRELESS_AMBIGUITY[product_id]}; assuming a wireless board ' + f'and flashing {uf2_name} (a non-wireless board would boot but fail ' + f'in main.py at network.WLAN())') if not os.path.isfile(uf2_path): log_message(f'Firmware image {uf2_path} not found - cache it first, ' diff --git a/src/pi/test_PicoFirmwareFlasher.py b/src/pi/test_PicoFirmwareFlasher.py index e638192..ded8117 100644 --- a/src/pi/test_PicoFirmwareFlasher.py +++ b/src/pi/test_PicoFirmwareFlasher.py @@ -163,6 +163,7 @@ def test_wireless_ambiguity_is_logged_for_both_families(self): self.assertIn('2e8a:0003 in BOOTSEL', log) self.assertIn('2e8a:000f in BOOTSEL', log) self.assertIn('network.WLAN()', log) + self.assertNotIn('WARNING', log) # fleet is all Pico W: a note, not a warning def test_rp2350_selects_pico2_image_and_label(self): self.enable() From c61c148e89e084a714c93f19cfa050cfdf9c0217 Mon Sep 17 00:00:00 2001 From: Qurratul Quais Date: Tue, 8 Sep 2026 19:55:11 -0400 Subject: [PATCH 5/6] Mark the BOOTSEL udev rule as tested on hardware and the partition question settled --- src/pi/98-pico-bootsel.rules | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pi/98-pico-bootsel.rules b/src/pi/98-pico-bootsel.rules index b692704..aa86c31 100644 --- a/src/pi/98-pico-bootsel.rules +++ b/src/pi/98-pico-bootsel.rules @@ -1,5 +1,7 @@ # 98-pico-bootsel.rules -- issue #19 Phase A. -# DRAFT: not installed by the installer, not yet tested against hardware. +# Tested 2026-09-08 on a Pi 4 Model B Rev 1.1 running Raspberry Pi OS Lite 64-bit +# (trixie): a Pico W in BOOTSEL was flashed with no manual intervention. Not yet +# installed by the installer; see the install steps below. # # Companion to 99-pico.rules. That rule matches 2e8a:0005, the serial interface # a board presents once MicroPython is running. This one matches the step @@ -29,8 +31,8 @@ # journalctl -f -u 'pico-flash-*' # # Matches the partition node (/dev/sda1), which is where the RPI-RP2 / RP2350 -# filesystem lives and what udisksctl mounts. If `lsblk` on a real board shows -# the filesystem directly on /dev/sda with no sda1, change DEVTYPE to "disk". +# filesystem lives and what udisksctl mounts. Confirmed on hardware: the +# bootloader volume is a partition (DEVTYPE=partition), not the whole disk. # 0003 is the RP2040 bootloader, 000f the RP2350 bootloader; 2e8a is Raspberry # Pi. ID_MODEL_ID is passed as an argument because systemd-run does not forward # udev's environment into the unit. System python3 is enough: the script uses From 5461990f1526c2dc99a0b08d9ab1a3b0d982b7fc Mon Sep 17 00:00:00 2001 From: Qurratul Quais Date: Tue, 8 Sep 2026 19:55:22 -0400 Subject: [PATCH 6/6] Document how to enable auto-flashing of blank Picos --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 4ba54a6..bb9a745 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,35 @@ rule watches for. > work. Headers are only pre-soldered on the "H" variants; this project needs GP4, > GP5 and GND, so a plain board means soldering. +#### Or let the Pi do step 1 for you (auto-flash) + +`src/pi/PicoFirmwareFlasher.py` does the copy above by itself when a board in +BOOTSEL mode is plugged in. It is opt-in and off by default. To enable it on a Pi: + +```bash +# 1. cache the firmware, named exactly like this (the script never downloads) +sudo mkdir -p /home/project/firmware +sudo cp RPI_PICO_W-.uf2 /home/project/firmware/RPI_PICO_W.uf2 +sudo cp RPI_PICO2_W-.uf2 /home/project/firmware/RPI_PICO2_W.uf2 # if you have Pico 2 W boards + +# 2. install the udev rule that starts the script +sudo cp src/pi/98-pico-bootsel.rules /etc/udev/rules.d/ +sudo udevadm control --reload-rules + +# 3. the kill switch: nothing is ever flashed while this file is absent +sudo touch /home/project/firmware/autoflash-enabled +``` + +Then plug in a board in BOOTSEL mode (a fresh one is already in it; otherwise hold +**BOOTSEL** while plugging in, and let go once it is in). Within about fifteen +seconds it reboots as MicroPython and the existing `99-pico.rules` takes over. +Watch it with `tail -f /tmp/deployer.log` or `journalctl -f -u 'pico-flash-*'`. +Remove `autoflash-enabled` to switch it off again. + +The script only touches a volume labelled `RPI-RP2` or `RP2350`, and it treats +the board vanishing mid-copy as success, because that is the board rebooting. To +run it by hand for a specific device: `sudo python3 src/pi/PicoFirmwareFlasher.py /dev/sda1`. + ### Step 2: Plug it into the Pi With MicroPython on board, just plug the Pico into the Pi's USB port. The udev