Files
gandalf/tests/test_modem.py
T
jaredandClaude Opus 5.5 e32499b7f6
Lint / Python (flake8) (pull_request) Successful in 2m6s
Lint / JS (eslint) (pull_request) Successful in 18s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m11s
Test / Python Tests (pytest) (pull_request) Successful in 2m20s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 1m58s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m58s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 58s
Add AT&T modem monitoring and device pages; replace Link Debug
Modem and WAN:
- Poll the BGW320 status pages read-only for light levels, PON and WAN
  state, with a WAN / Fiber panel, 7-day Rx trend, and alerts on WAN down,
  optical limits, and Rx drift
- Read WAN outages and Smart Power restarts from the UniFi system log,
  since outages can be over between 5 minute polls

Device pages:
- Add device search to the dashboard and a page per host, switch, router,
  AP, PDU, or modem
- Move all Link Debug data into expandable rows there; /links redirects
- Collect UDM ports and UniFi device details (firmware, load, WAN, outlets)
- Reorder the dashboard

Monitoring fixes:
- Alert when a configured host stops reporting: its series disappear
  rather than reporting down, so a dead host never raised an alert
- Alert on Prometheus outages instead of blanking the dashboard
- Treat a failed Pulse ping as unknown rather than host unreachable
- Stop new switch ports reporting lifetime bytes as one interval's rate
- Run database sessions in UTC: other server time zones shifted displayed
  ages and timed suppression expiries

Also log every avatar 404 reason, add a favicon, and correct the WAN link
labels (1G on Port 9, not 10G SFP+).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:40:17 -05:00

258 lines
11 KiB
Python

"""Tests for modem.py parsing. Fixtures copy the BGW320-505 (fw 6.35.8) markup with sanitized values."""
import os
import sys
from unittest.mock import MagicMock
import pytest
import requests
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import modem # noqa: E402
def dom_table(heading, current, flags=(0, 0, 0, 0), limits=(-292, -90, -279, -100)):
return f'''
<h1>{heading}&nbsp;&nbsp;Currently {current}</h1>
<table class="grid table100" summary="{heading} table">
<thead><tr><th scope="col" width="30%">&nbsp;</th><th scope="col">Low</th><th scope="col">High</th></tr></thead>
<tbody>
<tr class="a"><td class="heading" scope="row">Alarm</td>
<td>{flags[0]} (Threshold {limits[0]})</td>
<td>{flags[1]} (Threshold {limits[1]})</td>
</tr>
<tr class="b"><td class="heading" scope="row">Warning</td>
<td>{flags[2]} (Threshold {limits[2]})</td>
<td>{flags[3]} (Threshold {limits[3]})</td>
</tr>
</tbody></table>'''
def fiber_page(optical='Up', rx=-169, rx_flags=(0, 0, 0, 0), include_rx=True):
rx_table = dom_table('Rx Power', rx, rx_flags) if include_rx else ''
return f'''<html><title>Fiber Status</title>
<tr><th scope="row" width="47%">Optical WAN Operational Status</th>
<td class="col2">{optical} </td></tr>
<tr><th scope="row">Wave Length</th><td class="col2">1270 nm </td></tr>
<tr><th scope="row">Rx LOS State</th><td class="col2">1</td></tr>
<tr><th scope="row">Vendor PN</th><td class="col2">TESTPN</td></tr>
{dom_table('Temperature', 34, limits=(-50, 95, -45, 90))}
{dom_table('Vcc', 3, limits=(3, 3, 3, 3))}
{dom_table('Tx Bias', 6, limits=(0, 500, 0, 400))}
{dom_table('Tx Power', 33, limits=(-10, 59, 0, 49))}
{rx_table}</html>'''
def broadband_page(connection='Up', pon='OPERATION (O5)'):
return f'''<html><title>Broadband Status</title>
<tr><th scope="row" width="47%">Broadband Connection Source</th><td class="col2">FIBER</td></tr>
<tr><th scope="row" width="47%">Broadband Connection</th>
<td class="col2">
{connection}
</td></tr>
<tr><th scope="row">Broadband IPv4 Address</th><td class="col2">203.0.113.10</td></tr>
<tr class="a"><th scope="row" class="heading a">Current Speed (Mbps)</th><td>10000</td></tr>
<tr><th scope="row">Transmit Bytes</th><td>200</td></tr>
<tr><th scope="row">Receive Bytes</th><td>100</td></tr>
<tr><th scope="row">Receive Errors</th><td>1</td></tr>
<tr><th scope="row">Transmit Errors</th><td>2</td></tr>
<tr><th scope="row">Receive Drops</th><td>3</td></tr>
<tr><th scope="row">Transmit Drops</th><td>4</td></tr>
<tr><th scope="row">Transmit Packets</th><td>7</td></tr>
<tr><th scope="row">Transmit Packets</th><td>999</td></tr>
<tr><th scope="row">PON Link Status</th><td>{pon}</td></tr>
<tr><th scope="row">UNI Status</th><td>up</td></tr></html>'''
SYSINFO = '''<tr><th scope="row">Model Number</th><td class="col2">BGW320-505</td></tr>
<tr><th scope="row">Software Version</th><td class="col2">6.35.8</td></tr>
<tr><th scope="row">Time Since Last Reboot</th><td class="col2">28951</td></tr>'''
class TestBuildStatus:
def test_scales_optical_power_from_tenths_of_dbm(self):
d = modem.build_status(fiber_page(), broadband_page(), SYSINFO)
assert d['optics']['rx_power']['value'] == -16.9
assert d['optics']['tx_power']['value'] == 3.3
assert d['optics']['temperature']['value'] == 34.0
def test_scales_thresholds(self):
rx = modem.build_status(fiber_page(), broadband_page())['optics']['rx_power']
assert (rx['alarm_low'], rx['alarm_high'], rx['warn_low'], rx['warn_high']) == (-29.2, -9.0, -27.9, -10.0)
def test_unit_ambiguous_readings_are_dropped_but_flags_kept(self):
d = modem.build_status(fiber_page(), broadband_page())
assert d['optics']['tx_bias']['value'] is None
assert d['optics']['vcc']['alarm_low'] is None
assert d['optics']['vcc']['alarm_flag'] is False
def test_system_and_wan_fields(self):
d = modem.build_status(fiber_page(), broadband_page(), SYSINFO)
assert d['model'] == 'BGW320-505'
assert d['firmware'] == '6.35.8'
assert d['uptime_s'] == 28951
assert d['broadband_state'] == 'Up'
assert d['line_speed_mbps'] == 10000
assert d['counters'] == {
'rx_bytes': 100, 'tx_bytes': 200, 'rx_errors': 1,
'tx_errors': 2, 'rx_drops': 3, 'tx_drops': 4,
}
def test_repeated_label_keeps_first_value(self):
assert modem.parse_rows(broadband_page())['Transmit Packets'] == '7'
def test_sysinfo_is_optional(self):
d = modem.build_status(fiber_page(), broadband_page(), '')
assert d['model'] == '' and d['uptime_s'] is None
def test_unknown_page_is_invalid(self):
with pytest.raises(modem.ModemDataInvalid):
modem.build_status('<html>nope</html>', broadband_page())
with pytest.raises(modem.ModemDataInvalid):
modem.build_status(fiber_page(), '<html>nope</html>')
def test_up_link_without_rx_power_is_invalid(self):
with pytest.raises(modem.ModemDataInvalid):
modem.build_status(fiber_page(include_rx=False), broadband_page())
def test_cut_fiber_without_rx_power_is_not_invalid(self):
d = modem.build_status(fiber_page(optical='Down', include_rx=False), broadband_page(connection='Down'))
assert 'rx_power' not in d['optics']
assert modem.wan_problems(d)
class TestWanProblems:
def test_healthy_link_has_none(self):
assert modem.wan_problems(modem.build_status(fiber_page(), broadband_page())) == []
@pytest.mark.parametrize('field,value,expected', [
('broadband_state', 'Down', 'broadband connection is Down'),
('optical_state', 'Down', 'optical WAN is Down'),
('pon_state', 'INITIAL (O1)', 'PON link state is INITIAL (O1)'),
])
def test_each_failure_is_reported(self, field, value, expected):
d = {'broadband_state': 'Up', 'optical_state': 'Up', 'pon_state': 'OPERATION (O5)', field: value}
assert modem.wan_problems(d) == [expected]
def test_missing_pon_field_is_not_a_problem(self):
assert modem.wan_problems({'broadband_state': 'Up', 'optical_state': 'Up', 'pon_state': ''}) == []
def test_rx_los_pin_is_ignored(self):
# Real gateways report Rx LOS State=1 on a healthy link (see fixture).
assert modem.wan_problems(modem.build_status(fiber_page(), broadband_page())) == []
class TestMetricStatus:
@pytest.mark.parametrize('rx,expected', [
(-169, 'ok'),
(-279, 'warning'),
(-285, 'warning'),
(-292, 'alarm'),
(-400, 'alarm'),
(-100, 'warning'),
(-90, 'alarm'),
])
def test_rx_thresholds(self, rx, expected):
rx_metric = modem.parse_dom_metrics(fiber_page(rx=rx))['rx_power']
assert modem.metric_status(rx_metric) == expected
def test_module_flags_win_over_reading(self):
alarm = modem.parse_dom_metrics(fiber_page(rx_flags=(1, 0, 0, 0)))['rx_power']
warn = modem.parse_dom_metrics(fiber_page(rx_flags=(0, 0, 1, 0)))['rx_power']
assert modem.metric_status(alarm) == 'alarm'
assert modem.metric_status(warn) == 'warning'
def test_flag_only_metric(self):
assert modem.metric_status(modem.parse_dom_metrics(fiber_page())['vcc']) == 'ok'
class TestCounterRates:
def test_rate_from_delta(self):
assert modem.counter_rates({'rx_bytes': 300}, {'rx_bytes': 100}, 100)['rx_bytes'] == 2.0
def test_first_poll_is_none(self):
assert modem.counter_rates({'rx_bytes': 300}, None, 100)['rx_bytes'] is None
def test_reboot_counter_reset_is_none(self):
assert modem.counter_rates({'rx_bytes': 5}, {'rx_bytes': 100}, 100)['rx_bytes'] is None
class TestClient:
def _client(self, **response):
c = modem.ModemClient({'url': 'http://modem.test/'})
resp = MagicMock(status_code=response.get('status', 200), text=response.get('text', ''))
c.session.get = MagicMock(return_value=resp)
return c
def test_network_failure_is_unreachable(self):
c = self._client()
c.session.get = MagicMock(side_effect=requests.ConnectionError('boom'))
with pytest.raises(modem.ModemUnreachable):
c.poll()
def test_redirect_is_invalid_and_not_followed(self):
c = self._client(status=302)
with pytest.raises(modem.ModemDataInvalid):
c.poll()
assert c.session.get.call_args.kwargs['allow_redirects'] is False
def test_login_page_is_invalid(self):
with pytest.raises(modem.ModemDataInvalid, match='login'):
self._client(text='<html><title>Login</title></html>').poll()
class TestWanHistory:
def ev(self, key, ts, device='RPDU'):
return {'key': key, 'ts': ts, 'device': device, 'wan': 'WAN1'}
def test_outage_opens_and_closes(self):
h = {}
modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_FAILED, 100), self.ev(modem.LOG_WAN_RESTORED, 200)])
assert h['outages'] == [{'failed_at': 100, 'restored_at': 200, 'wan': 'WAN1'}]
def test_events_are_handled_in_time_order(self):
h = {}
modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_RESTORED, 200), self.ev(modem.LOG_WAN_FAILED, 100)])
assert h['outages'][0]['restored_at'] == 200
def test_outage_spanning_two_batches(self):
h = {}
modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_FAILED, 100)])
assert h['outages'][0]['restored_at'] is None
modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_RESTORED, 200)])
assert h['outages'][0]['restored_at'] == 200
def test_repeat_failure_does_not_open_a_second_outage(self):
h = {}
modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_FAILED, 100), self.ev(modem.LOG_WAN_FAILED, 150)])
assert len(h['outages']) == 1
def test_restored_without_failure_is_ignored(self):
h = {}
modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_RESTORED, 200)])
assert h['outages'] == []
def test_blip_is_not_actionable_but_restart_and_instability_are(self):
keys = [modem.LOG_WAN_FAILED, modem.LOG_WAN_RESTORED, modem.LOG_MODEM_RESTARTED, modem.LOG_WAN_UNSTABLE]
got = modem.apply_wan_events({}, [self.ev(k, i) for i, k in enumerate(keys)])
assert [e['key'] for e in got] == [modem.LOG_MODEM_RESTARTED, modem.LOG_WAN_UNSTABLE]
def test_history_is_bounded(self):
h = {}
for i in range(60):
modem.apply_wan_events(h, [
self.ev(modem.LOG_WAN_FAILED, i * 2), self.ev(modem.LOG_WAN_RESTORED, i * 2 + 1),
])
assert len(h['outages']) == 50
def test_uptime_reset_confirms_logged_restart(self):
h = {}
modem.apply_wan_events(h, [self.ev(modem.LOG_MODEM_RESTARTED, 1000)])
modem.record_uptime_reset(h, 1023)
assert h['restarts'] == [{'at': 1000, 'by': 'RPDU', 'confirmed': True}]
def test_unexplained_uptime_reset_is_recorded(self):
h = {}
modem.record_uptime_reset(h, 5000)
assert h['restarts'] == [{'at': 5000, 'by': None, 'confirmed': True}]