From 0defb30c6a01310328f8c9aa6d3a4d9eaa210307 Mon Sep 17 00:00:00 2001 From: natehuby76 <86365949+natehuby76@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:29:19 -0400 Subject: [PATCH] announce galaxy on the local network --- tools/galaxy_bluetooth/README.md | 23 ++++ tools/galaxy_bluetooth/bootstrap.py | 17 ++- .../bridge/galaxy-lan-discovery.service | 18 +++ tools/galaxy_bluetooth/bridge/install.sh | 3 +- .../galaxy_bluetooth/bridge/lan_discovery.py | 111 ++++++++++++++++++ .../galaxy_bluetooth/bridge/requirements.txt | 1 + .../galaxy_bluetooth/tests/test_bootstrap.py | 24 ++++ .../tests/test_lan_discovery.py | 66 +++++++++++ 8 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 tools/galaxy_bluetooth/bridge/galaxy-lan-discovery.service create mode 100644 tools/galaxy_bluetooth/bridge/lan_discovery.py create mode 100644 tools/galaxy_bluetooth/tests/test_lan_discovery.py diff --git a/tools/galaxy_bluetooth/README.md b/tools/galaxy_bluetooth/README.md index ab2a84a623..df30b8eb11 100644 --- a/tools/galaxy_bluetooth/README.md +++ b/tools/galaxy_bluetooth/README.md @@ -35,3 +35,26 @@ installed while parked. Bluetooth settings show setup status and Retry setup. `settings_backup.py` saves one private parameter/cache snapshot before existing launch migrations. Setup never resets StarPilot parameters or pairing keys. Do not share the backup; it contains credentials. See INSTALL-ON-COMMA.md. + + +## Find comma on Wi-Fi + +The iPhone app's **Settings → Find comma on Wi-Fi** searches for up to 12 seconds +without needing a Bluetooth connection. Allow the app's Local Network permission, +put iPhone and comma on the same Wi-Fi or hotspot, then select the named result. +The app verifies Galaxy's response and any previously paired device identity before +using the discovered address. Discovery does not create or replace pairing credentials. +Manual IP entry and Bluetooth discovery remain available. + +This requires the `galaxy-lan-discovery.service` included in this branch. On the +first update, park and give comma internet access so its bootstrap can install the +additional dependency in `/data/galaxy-ble/venv`. Existing settings and pairing keys +are preserved. The discovery service starts independently of Bluetooth and publishes +`_starpilot-galaxy._tcp.local.` on port 8082 only on private IPv4 Wi-Fi/Ethernet +interfaces while Galaxy is listening. TXT records contain a protocol version, never +pairing keys or Galaxy session tokens. It checks interface changes every 30 seconds; +there is no subnet scan, camera capture, or extra telemetry polling. + +Some hotspots and guest networks block multicast discovery or communication between +clients. If no result appears, enter comma's local IP manually. For troubleshooting, +inspect `journalctl -u galaxy-lan-discovery.service --no-pager -n 50` on comma. diff --git a/tools/galaxy_bluetooth/bootstrap.py b/tools/galaxy_bluetooth/bootstrap.py index e8778da01f..fdd234268c 100644 --- a/tools/galaxy_bluetooth/bootstrap.py +++ b/tools/galaxy_bluetooth/bootstrap.py @@ -11,6 +11,7 @@ import time DATA = Path('/data/galaxy-ble') BRIDGE = Path(__file__).resolve().parent / 'bridge' UNIT = 'galaxy-ble-fork.service' +LAN_UNIT = 'galaxy-lan-discovery.service' def write_json(path, value): @@ -45,6 +46,7 @@ class Bootstrap: self.verified = None self.next_attempt = 0 self.registered = False + self.discovery_registered = False def status(self, state, message): write_json(self.data / 'setup-status.json', {'state': state, 'message': message, 'updated': time.time()}) @@ -73,7 +75,7 @@ class Bootstrap: valid = python.exists() and receipt.exists() and receipt.read_text() == digest if valid: try: - self.command([str(python), '-c', 'import dbus_next; from Crypto.Cipher import AES'], + self.command([str(python), '-c', 'import dbus_next; import zeroconf; from Crypto.Cipher import AES'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except (subprocess.SubprocessError, OSError): valid = False @@ -82,7 +84,7 @@ class Bootstrap: self.status('waiting', 'Park to finish Bluetooth setup') return self.status('installing', 'Installing Bluetooth bridge. Comma needs internet.') - self.run(['sudo', '-n', 'systemctl', 'stop', UNIT], timeout=20, check=False, + self.run(['sudo', '-n', 'systemctl', 'stop', UNIT, LAN_UNIT], timeout=20, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) log_path = self.data / 'setup.log' if log_path.exists() and log_path.stat().st_size > 256 * 1024: @@ -100,6 +102,16 @@ class Bootstrap: self.systemctl('link', '--runtime', str(self.bridge / UNIT), stdout=subprocess.DEVNULL) self.systemctl('daemon-reload', stdout=subprocess.DEVNULL) self.registered = True + # Discovery remains available when Bluetooth is off or the BLE service retries. + # An optional discovery failure must not prevent Bluetooth startup. + try: + if not self.discovery_registered: + self.systemctl('link', '--runtime', str(self.bridge / LAN_UNIT), stdout=subprocess.DEVNULL) + self.systemctl('daemon-reload', stdout=subprocess.DEVNULL) + self.discovery_registered = True + self.systemctl('start', LAN_UNIT, stdout=subprocess.DEVNULL) + except (OSError, subprocess.SubprocessError): + pass self.systemctl('start', UNIT, stdout=subprocess.DEVNULL) try: pid = json.loads((self.data / 'bridge-ready.json').read_text())['pid'] @@ -130,3 +142,4 @@ def main(): if __name__ == '__main__': main() + diff --git a/tools/galaxy_bluetooth/bridge/galaxy-lan-discovery.service b/tools/galaxy_bluetooth/bridge/galaxy-lan-discovery.service new file mode 100644 index 0000000000..279e9cdd1a --- /dev/null +++ b/tools/galaxy_bluetooth/bridge/galaxy-lan-discovery.service @@ -0,0 +1,18 @@ +[Unit] +Description=Galaxy local Wi-Fi discovery +After=network.target +ConditionPathExists=/data/openpilot/tools/galaxy_bluetooth/bridge/lan_discovery.py +ConditionPathExists=/data/galaxy-ble/venv/bin/python + +[Service] +Type=simple +User=comma +WorkingDirectory=/data/openpilot/tools/galaxy_bluetooth/bridge +ExecStart=/data/galaxy-ble/venv/bin/python /data/openpilot/tools/galaxy_bluetooth/bridge/lan_discovery.py +Restart=on-failure +RestartSec=30 +NoNewPrivileges=true +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/tools/galaxy_bluetooth/bridge/install.sh b/tools/galaxy_bluetooth/bridge/install.sh index 91e388111c..8f102288f7 100755 --- a/tools/galaxy_bluetooth/bridge/install.sh +++ b/tools/galaxy_bluetooth/bridge/install.sh @@ -34,7 +34,7 @@ else printf 'No packages were installed into StarPilot or the system Python.\n' >&2 exit 1 fi -"$data_dir/venv/bin/python" -c 'import dbus_next; from Crypto.Cipher import AES' +"$data_dir/venv/bin/python" -c 'import dbus_next; import zeroconf; from Crypto.Cipher import AES' "$data_dir/venv/bin/python" - "$bridge_dir" "$data_dir/pairing.json" <<'KEY' import sys from pathlib import Path @@ -45,3 +45,4 @@ KEY if [ "${GALAXY_BLE_QUIET:-0}" != "1" ]; then printf '\nBridge installed. Pair this phone from Settings → Bluetooth → Pair phone.\n' fi + diff --git a/tools/galaxy_bluetooth/bridge/lan_discovery.py b/tools/galaxy_bluetooth/bridge/lan_discovery.py new file mode 100644 index 0000000000..f36081075d --- /dev/null +++ b/tools/galaxy_bluetooth/bridge/lan_discovery.py @@ -0,0 +1,111 @@ +"""Advertise Galaxy on local Ethernet/Wi-Fi independently of Bluetooth.""" +import ipaddress +import json +import logging +import re +import signal +import socket +import subprocess +import threading + +SERVICE = '_starpilot-galaxy._tcp.local.' +PORT = 8082 +LOG = logging.getLogger('galaxy-lan-discovery') + + +def private_addresses(interfaces): + addresses = set() + for interface in interfaces: + name = interface.get('ifname', '') + # Never advertise cellular, VPN, loopback, or virtual bridge addresses. + if not re.match(r'^(wlan|wl|eth|en)', name) or interface.get('operstate') != 'UP': + continue + for item in interface.get('addr_info', []): + if item.get('family') != 'inet' or item.get('scope') != 'global': + continue + try: + address = ipaddress.IPv4Address(item.get('local', '')) + except ipaddress.AddressValueError: + continue + if any(address in network for network in PRIVATE_NETWORKS): + addresses.add(str(address)) + return sorted(addresses) + + +PRIVATE_NETWORKS = tuple(ipaddress.IPv4Network(value) for value in ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16')) + + +def local_addresses(): + result = subprocess.run(['ip', '-j', '-4', 'addr', 'show'], check=True, capture_output=True, text=True, timeout=3) + return private_addresses(json.loads(result.stdout)) + + +def galaxy_listening(): + try: + with socket.create_connection(('127.0.0.1', PORT), timeout=0.3): + return True + except OSError: + return False + + +class Advertisement: + def __init__(self, factory=None, info_factory=None): + if factory is None or info_factory is None: + from zeroconf import Zeroconf, ServiceInfo, IPVersion + factory = lambda addresses: Zeroconf(interfaces=addresses, ip_version=IPVersion.V4Only) + info_factory = ServiceInfo + self.factory, self.info_factory = factory, info_factory + self.publisher = None + self.addresses = [] + + def update(self, addresses): + if addresses == self.addresses: + return + self.close() + if not addresses: + return + hostname = re.sub(r'[^A-Za-z0-9-]', '-', socket.gethostname()).strip('-')[:50] or 'comma' + publisher = self.factory(addresses) + try: + info = self.info_factory(SERVICE, f'{hostname}.{SERVICE}', port=PORT, + addresses=[socket.inet_aton(address) for address in addresses], + properties={'protocol': 'galaxy-lan-1'}, server=f'{hostname}.local.') + publisher.register_service(info, allow_name_change=True) + except Exception: + publisher.close() + raise + self.publisher, self.addresses = publisher, list(addresses) + LOG.info('Galaxy Wi-Fi discovery available on %s local interface(s)', len(addresses)) + + def close(self): + if self.publisher is not None: + # Zeroconf sends goodbye records and stops its own background threads. + self.publisher.close() + self.publisher = None + self.addresses = [] + + +def main(): + logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') + stopped = threading.Event() + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, lambda *_: stopped.set()) + advertisement = Advertisement() + last_error = None + try: + while not stopped.is_set(): + try: + advertisement.update(local_addresses() if galaxy_listening() else []) + last_error = None + except (OSError, ValueError, subprocess.SubprocessError) as error: + advertisement.close() + if type(error).__name__ != last_error: + LOG.warning('Wi-Fi discovery unavailable: %s', type(error).__name__) + last_error = type(error).__name__ + stopped.wait(30) + finally: + advertisement.close() + + +if __name__ == '__main__': + main() diff --git a/tools/galaxy_bluetooth/bridge/requirements.txt b/tools/galaxy_bluetooth/bridge/requirements.txt index 9fe702fd07..f6462df256 100644 --- a/tools/galaxy_bluetooth/bridge/requirements.txt +++ b/tools/galaxy_bluetooth/bridge/requirements.txt @@ -1,2 +1,3 @@ dbus-next==0.2.3 pycryptodome==3.23.0 +zeroconf==0.151.3 diff --git a/tools/galaxy_bluetooth/tests/test_bootstrap.py b/tools/galaxy_bluetooth/tests/test_bootstrap.py index 08b371f58d..9938f0df72 100644 --- a/tools/galaxy_bluetooth/tests/test_bootstrap.py +++ b/tools/galaxy_bluetooth/tests/test_bootstrap.py @@ -60,6 +60,29 @@ class BootstrapTests(unittest.TestCase): self.params.put_bool.assert_not_called() self.params.clear_all.assert_not_called() + def test_discovery_start_failure_does_not_block_bluetooth(self): + self.installed() + def run(args, **kwargs): + if boot.LAN_UNIT in args: + raise subprocess.CalledProcessError(1, args) + return SimpleNamespace(stdout='123\n') + self.run.side_effect = run + boot.write_json(self.data / 'bridge-ready.json', {'pid': 123}) + self.worker.step() + self.assertEqual(boot.setup_status(self.data), 'Ready to pair') + self.assertTrue(any(call.args[0] == ['sudo', '-n', 'systemctl', 'start', boot.UNIT] + for call in self.run.call_args_list)) + self.assertEqual(self.key.read_text(), 'existing private key') + + def test_discovery_unit_is_linked_once_and_started_independently(self): + self.installed() + self.worker.step() + self.worker.step() + calls = [call.args[0] for call in self.run.call_args_list] + links = [args for args in calls if 'link' in args and args[-1].endswith(boot.LAN_UNIT)] + self.assertEqual(len(links), 1) + self.assertEqual(calls.count(['sudo', '-n', 'systemctl', 'start', boot.LAN_UNIT]), 2) + def test_dependency_change_waits_until_parked(self): self.installed() (self.bridge / 'requirements.txt').write_text('new version') @@ -115,3 +138,4 @@ class BootstrapTests(unittest.TestCase): if __name__ == '__main__': unittest.main() + diff --git a/tools/galaxy_bluetooth/tests/test_lan_discovery.py b/tools/galaxy_bluetooth/tests/test_lan_discovery.py new file mode 100644 index 0000000000..87d31dc868 --- /dev/null +++ b/tools/galaxy_bluetooth/tests/test_lan_discovery.py @@ -0,0 +1,66 @@ +import importlib.util +from pathlib import Path +import socket +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('lan_discovery', ROOT / 'bridge/lan_discovery.py') +discovery = importlib.util.module_from_spec(spec) +spec.loader.exec_module(discovery) + + +def interface(name, address, state='UP'): + return {'ifname': name, 'operstate': state, 'addr_info': [{'family': 'inet', 'scope': 'global', 'local': address}]} + + +class DiscoveryTests(unittest.TestCase): + def test_only_up_private_wifi_and_ethernet_are_advertised(self): + interfaces = [interface('wlan0', '192.168.10.85'), interface('eth0', '10.0.0.2'), + interface('wlan1', '192.168.10.85'), interface('wlan2', '172.16.0.4', 'DOWN'), + interface('wwan0', '10.0.0.3'), interface('tailscale0', '10.0.0.4'), + interface('lo', '127.0.0.1'), interface('eth1', '8.8.8.8'), interface('eth2', 'bad')] + self.assertEqual(discovery.private_addresses(interfaces), ['10.0.0.2', '192.168.10.85']) + + def test_unchanged_network_does_not_republish_and_network_change_closes_old_service(self): + publishers = [] + def factory(addresses): + publisher = Mock() + publishers.append(publisher) + return publisher + info_factory = Mock(return_value='service-record') + advertisement = discovery.Advertisement(factory, info_factory) + with patch.object(socket, 'gethostname', return_value='comma-19517593'): + advertisement.update(['192.168.10.85']) + advertisement.update(['192.168.10.85']) + self.assertEqual(len(publishers), 1) + properties = info_factory.call_args.kwargs['properties'] + self.assertEqual(properties, {'protocol': 'galaxy-lan-1'}) + # Neither pairing keys nor Galaxy session tokens appear in public TXT records. + self.assertEqual(info_factory.call_args.kwargs['port'], 8082) + advertisement.update(['192.168.10.86']) + publishers[0].close.assert_called_once() + self.assertEqual(len(publishers), 2) + advertisement.update([]) + publishers[1].close.assert_called_once() + self.assertEqual(advertisement.addresses, []) + advertisement.close() + publishers[1].close.assert_called_once() + + def test_registration_failure_closes_failed_publisher_and_can_retry(self): + failed = Mock() + failed.register_service.side_effect = OSError('no interface') + good = Mock() + factory = Mock(side_effect=[failed, good]) + advertisement = discovery.Advertisement(factory, Mock()) + with self.assertRaises(OSError): + advertisement.update(['192.168.1.2']) + failed.close.assert_called_once() + self.assertEqual(advertisement.addresses, []) + advertisement.update(['192.168.1.2']) + good.register_service.assert_called_once() + advertisement.close() + + +if __name__ == '__main__': + unittest.main()