mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-04 03:24:06 +08:00
announce galaxy on the local network
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user