fix: non-disruptive startup checks + clean service lifecycle (v1.3.1)

- Env check is read-only when state is sane: no pineapd command-socket
  writes, no live pool-list commits, no wifi reload; pineapd restarts only
  when a runtime-sensitive UCI value changed or the daemon was down
- Failed monitor repairs now fail the startup contract instead of being
  reported as fixed; runtime pool state is read from active config
- Enterprise AP recovery runs only on device boot (PAGER_WEBUI_BOOT), not
  on every web-service restart
- serve() gates the HTTP port on startup checks with bounded retries and
  shuts down cleanly on SIGTERM/SIGINT; the recon watchdog waits
  interruptibly
- Recon uses a bounded userspace channel scheduler that drives both
  monitor radios over non-DFS channels, with preflight verification,
  serialized starts, and per-cycle error reporting
- payload.sh waits for real readiness on start, fully removes the boot
  service (stop + disable + delete) on stop, and surfaces a
  stopped-but-enabled boot service; deploy.sh refreshes the installed
  init script even when the service is stopped
- Bump version to 1.3.1

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-19 12:03:11 -05:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 6e3968c19a
commit cd26553d21
8 changed files with 451 additions and 68 deletions
+68 -1
View File
@@ -162,10 +162,56 @@ class FakeSock:
pass
class ReconHopperTest(unittest.TestCase):
def test_preflight_verifies_every_non_dfs_channel(self):
calls = []
with mock.patch.object(
server, '_set_monitor_channel',
side_effect=lambda interface, channel:
calls.append((interface, channel)) or (True, '')):
self.assertEqual(
server._recon_hopper_preflight(),
(True, 'monitor channel control ready'))
expected = [
(interface, channel)
for interface, channels in server.RECON_CHANNELS.items()
for channel in channels
]
self.assertEqual(calls, expected)
def test_preflight_stops_at_first_unusable_channel(self):
def set_channel(interface, channel):
if interface == 'wlan1mon' and channel == 44:
return False, 'wlan1mon channel 44: busy'
return True, ''
with mock.patch.object(
server, '_set_monitor_channel', side_effect=set_channel):
ok, detail = server._recon_hopper_preflight()
self.assertFalse(ok)
self.assertIn('wlan1mon channel 44', detail)
def test_set_channel_surfaces_iw_failure(self):
with mock.patch.object(
server, 'device_run',
return_value=(240, '', 'Device or resource busy')):
ok, detail = server._set_monitor_channel('wlan0mon', 6)
self.assertFalse(ok)
self.assertIn('wlan0mon channel 6', detail)
self.assertIn('Device or resource busy', detail)
class DaemonSockTest(unittest.TestCase):
def setUp(self):
# h_recon_start now reads shared scan state; keep these isolated.
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
preflight = mock.patch.object(
server, '_recon_hopper_preflight', return_value=(True, 'ready'))
start = mock.patch.object(server, '_start_recon_hopper')
preflight.start()
start.start()
self.addCleanup(preflight.stop)
self.addCleanup(start.stop)
def test_socket_call_posts_json_to_sock(self):
server.DAEMON_SOCK = '/tmp/api.sock'
@@ -201,6 +247,7 @@ class DaemonSockTest(unittest.TestCase):
status, data = server.h_recon_start(ctx)
self.assertEqual(status, 200)
self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 60}))
server._start_recon_hopper.assert_called_once_with(60)
def test_start_defaults_empty_body(self):
calls = []
@@ -228,6 +275,18 @@ class DaemonSockTest(unittest.TestCase):
self.assertEqual(data['daemon'], {'error': 'no radio'})
self.assertFalse(server._recon_scan_state['active'])
def test_start_reports_hopper_preflight_failure(self):
calls = []
server._recon_hopper_preflight.return_value = (
False, 'wlan1mon channel 36: Device or resource busy')
server.daemon_sock_call = lambda *args, **kwargs: calls.append(args)
status, data = server.h_recon_start(
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
self.assertEqual(status, 503)
self.assertEqual(data['error'], 'recon radio preflight failed')
self.assertIn('wlan1mon', data['detail'])
self.assertEqual(calls, [])
class ReconScanStateTest(unittest.TestCase):
"""The webui mirrors the duration of the Pager's native timed scan."""
@@ -236,6 +295,13 @@ class ReconScanStateTest(unittest.TestCase):
self.db = make_db()
server.RECON_DB = self.db
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
preflight = mock.patch.object(
server, '_recon_hopper_preflight', return_value=(True, 'ready'))
start = mock.patch.object(server, '_start_recon_hopper')
preflight.start()
start.start()
self.addCleanup(preflight.stop)
self.addCleanup(start.stop)
def tearDown(self):
os.unlink(self.db)
@@ -368,12 +434,13 @@ class ReconExtrasTest(unittest.TestCase):
status, data = server.h_recon_status(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertFalse(data['hopper_online'])
self.assertIn('hopper_error', data)
self.assertTrue(data['history_reset'])
def test_hopper_online_cached(self):
server._hopper_cache.update({'updated': 0, 'online': None})
with mock.patch.object(server, 'wifi_ifaces',
return_value=['wlan0mon', 'wlan1mon', 'wlan2mon']):
return_value=['wlan0mon', 'wlan1mon']):
self.assertTrue(server._hopper_online())
# Second call within the cache window must not re-run iwinfo.
with mock.patch.object(server, 'wifi_ifaces',