announce galaxy on the local network

This commit is contained in:
natehuby76
2026-10-10 12:29:19 -04:00
committed by firestarsdog
parent bcafa532a2
commit 0defb30c6a
8 changed files with 260 additions and 3 deletions
+23
View File
@@ -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.
+15 -2
View File
@@ -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()
@@ -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
+2 -1
View File
@@ -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
@@ -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()
@@ -1,2 +1,3 @@
dbus-next==0.2.3
pycryptodome==3.23.0
zeroconf==0.151.3
@@ -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()
@@ -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()