Changelog
EAS Station™ documentation
Changelog
All notable changes to this project are documented in this file. The format is based on Keep a Changelog.
[Unreleased]
- Nothing yet. Document changes here as they land; the next release cut moves them into a version heading.
[3.20.2] - 2026-09-19 - Add a "Re-sync Nginx Configuration" action to the Certbot admin page
Added
- A persistent "Re-sync Nginx Configuration" button on Settings -> Certbot, found missing while rolling out the v3.20.0 OCSP stapling fix to a production deployment:
update.shpulls new code and reloads nginx, but it doesn't re-run certificate installation -- the step that (re)writes/etc/nginx/snippets/ssl-letsencrypt.conf. The only existing UI path to re-run that step (the "Install Certificate Now" button) is conditionally rendered and only appears when a certificate has been obtained but never installed; once a certificate is already active -- the normal, common state -- there was no way through the UI to make it re-apply its nginx wiring, so an app update that adds a new SSL directive to the certbot-managed snippet (likessl_trusted_certificate, needed for stapling) had no way to actually reach an existing installation without hand-editing nginx config. The new button reuses the same existing, idempotent/admin/api/certbot/install-certificateroute -- no new certificate is requested, no Let's Encrypt/ACME call is made, it just rewrites the local nginx snippet for the currently-installed certificate and reloads nginx.
[3.20.1] - 2026-09-19 - Add HSTS preload flag to the nginx template
Added
preloadadded to theStrict-Transport-Securityheader in both HTTPS server blocks ofconfig/nginx-eas-station.conf(max-age=31536000; includeSubDomains->max-age=31536000; includeSubDomains; preload), following up on the same Qualys SSL Labs report that prompted the OCSP stapling fix (v3.20.0). Deliberately deferred in that earlier change since preload is close to permanent once a domain is accepted onto browsers' built-in list -- removal can take months to propagate to already-shipped browsers. This header change alone does not enroll any domain; that's still a separate, manual, one-time step the domain's owner takes at https://hstspreload.org/ once this config is live and serving HTTPS-only on every subdomain.
[3.20.0] - 2026-09-18 - Add OCSP stapling support to the nginx template
Added
- OCSP stapling, prompted by a Qualys SSL Labs report on a production deployment (easstation.com) showing "OCSP stapling: No" as the one real gap in an otherwise A+ rating.
config/nginx-eas-station.confnow setsssl_stapling on; ssl_stapling_verify on;plus aresolverin both HTTPS server blocks (443 main site, 8081 pgweb proxy) -- nginx attaches the CA's cached, signed "still valid" response to the TLS handshake itself, so clients skip their own live OCSP query to the CA (faster handshake; the CA no longer sees every visitor's browsing habits via those individual per-visitor lookups). webapp/admin/certbot/install.py's nginx snippet writer now also writesssl_trusted_certificate(from the issued cert'schain.pem, alongside thefullchain.pem/privkey.pemit already wrote) -- stapling verification needs the issuer chain to check the OCSP response against. Falls back to omitting the line (stapling silently stays inactive, nothing else breaks) ifchain.pemis missing, since certbot always produces one but an unusual manually-imported cert might not.- No-op and harmless for the default self-signed certificate (confirmed via
nginx -t: a clean "ssl_stapling ignored, issuer certificate not found" warning, not an error) -- self-signed certs have no real CA-backed OCSP responder to staple, so stapling only actually activates once a real Let's Encrypt certificate is installed through the existing certbot flow. - Investigated the same report's "fatal handshake" rows (a handful of pre-2014 clients: Windows XP-era IE/Chrome/Firefox, old Java, iOS 7/8 Safari) and the "HSTS preload not opted in" note -- both are the correct, intentional trade-off of the existing
TLSv1.2/TLSv1.3-only configuration (re-enabling legacy protocols to satisfy those ~15-20-year-old clients would drop the rating well below A+ and reopen real vulnerabilities for effectively zero real-world traffic), and HSTS preload submission is a separate, much harder-to-reverse decision left for a deliberate future call rather than bundled into this fix. Neither needed a code change.
[3.19.2] - 2026-09-18 - Fix: dead "Service Heartbeat Status" card on the Uptime Monitoring page
Fixed
- The "Service Heartbeat Status" card (Settings -> Uptime Monitoring, right column) always showed "Not created" / "Never," regardless of whether Tickstem's per-service heartbeats were actually pinging successfully. Root cause: it read
settings.service_heartbeat_id,settings.service_heartbeat_status,settings.last_service_heartbeat_at/_success/_error-- none of which exist on theTickstemSettingsmodel, and none of which any route ever sets. Jinja2 silently renders a missing attribute as falsy rather than raising, so the card has shown this same dead placeholder state since it was added in #2557, unrelated to anything in this session's healthchecks.io work. The real, working per-service heartbeat data (both Tickstem's and healthchecks.io's) is already shown in the "Per-Service Heartbeats" table directly above this card -- with per-row status and last-ping timestamps that do reflect reality -- so removed the dead card rather than inventing a new single-value aggregate concept that has no well-defined meaning across N independently-scheduled per-service heartbeats.
[3.19.1] - 2026-09-18 - Fix: outbound API calls could hang for ~60s per request on a black-holed IPv6 path
Fixed
- healthchecks.io's per-service heartbeat bulk-create ("Monitor Selected Services") appeared to hang indefinitely instead of completing in under a second. Root-caused on the live deployment: the host has a valid global IPv6 address and default route, but that path is silently black-holed to multiple unrelated destinations (confirmed via
ping6/curl -6timeouts against healthchecks.io, Tickstem's API, Google's public DNS, and Cloudflare alike -- not specific to any one of them).socket.create_connection()triesgetaddrinfo()'s results in order, and RFC 6724 sorts IPv6 first, so every outboundrequestscall hung for a full connect-timeout on the dead IPv6 address before falling back to the IPv4 address that actually works -- turning what should be a sub-second healthchecks.io API call into one that took about 60 seconds. The per-service bulk-create endpoint makes one such call per selected service (up to 12) in a single synchronous request, so the page's "Creating heartbeats..." status could sit for over ten minutes without any visible sign it was still working, rather than the seconds it should take. - Added
app_core.http_defaults.prefer_ipv4_for_outbound_requests(), called once atapp.pyimport time, which makesrequests/urllib3skip AAAA lookups for the whole process. Verified against the live failure: the same healthchecks.io call went from ~60s to ~0.5s, both with and without gevent's socket monkey-patching (gunicorn's--worker-class gevent). This is a resilience fix independent of whatever is actually black-holing this host's IPv6 path (a router/upstream issue, not something in this codebase) -- every outbound integration this station makes works fine over IPv4 alone.
[3.19.0] - 2026-09-18 - Add healthchecks.io per-service heartbeat integration
Added
- healthchecks.io Management API v3 support (
app_core/healthchecks_client.py), alongside the existing Tickstem integration, for outbound per-service dead-man's-switch monitoring. Requested: "Can we add healthchecks.io API support along with Tickstem?" One heartbeat check perapp_core.config.get_eas_services()entry (the same 11 EAS subsystems + poller Tickstem's per-service heartbeats already cover), each pinged by the sharedHeartbeatWorkerbackground thread only while that specific systemd service is actually running — so a missed ping on healthchecks.io's side identifies exactly which subsystem stalled, not just "something is down." NewHealthchecksSettings(encrypted account API key) andHealthchecksServiceHeartbeat(per-service check UUID/ping URL/interval/status) models, migration20260918_healthchecks_settings. - No new page. Per explicit direction ("No separate page though in the settings, update the tickstem page, maybe rename it") the integration has no standalone settings page or nav entry —
webapp/admin/healthchecks.pyis API-routes-only. The existing Tickstem settings page (webapp/admin/tickstem.py,templates/admin/tickstem.html) was renamed "Uptime Monitoring" and now renders both providers' settings and per-service heartbeat tables in one place, since both are the same kind of thing (outbound dead-man's-switch heartbeats) differing only in which third-party API they call. - Bulk "create all" endpoint (
/admin/healthchecks/service-heartbeats/create-all) stops immediately on an HTTP 403 (healthchecks.io's plan-quota-exhausted response) instead of retrying the same failure for every remaining service, and reports which services it did create before hitting the limit. tests/test_healthchecks_service_heartbeats.py— 15 tests covering the worker's active-and-due gating logic (mirroring the existingtest_tickstem_service_heartbeats.pyshape, since both providers shareHeartbeatWorker._ping_one_service_heartbeat()), the API client's request/error-handling shape, and the bulk-create route's subset/quota-stop/missing-key behavior.
[3.18.3] - 2026-09-18 - Fix: duplicate security headers silently disabled HSTS enforcement
Fixed
Strict-Transport-Security,X-Frame-Options,X-Content-Type-Options, andX-XSS-Protectionwere set in bothapp.py(Flask'safter_requesthook) andconfig/nginx-eas-station.conf(add_header ... always;), with nginx'sadd_headerunaware of and not deduplicating against whatever the proxied Flask response already set. Confirmed live viacurl -Iagainst the running deployment: the HTTPS response carried two separatestrict-transport-securityheader fields with differentmax-agevalues (63072000 from Flask, 31536000 from nginx). Per RFC 6797 §8.1, a browser that receives more than oneStrict-Transport-Securityheader field is required to ignore all of them — so despite looking configured in two places, HSTS was not actually being enforced by any compliant browser. Removed the four duplicated headers fromapp.py; nginx — the actual TLS-terminating edge — is now the single source of truth for all of them.Content-Security-Policystays inapp.pysince nginx never set it and its value is dynamic (depends on the configured Icecast origin, resolved per-request).- Regression test (
tests/test_security_headers.py) added totests/known_failures.txtas a documented xfail, matching the existingtest_support_smoke.py/test_upload_too_large_handler.pyentries: theapp_clientfixture forcesDATABASE_URLto sqlite unconditionally, and any real request through it hitsdb.create_all()against a JSONB column sqlite can't compile. Verified manually with livecurlinstead, the same way those existing entries document doing.
[3.18.2] - 2026-09-18 - Fix: missing dead-air around the pre/post MDC1200 chime burst
Fixed
- The 1-second dead-air gap that should bracket the pre-alert and post-alert chime/MDC1200 burst was only present on the inner side, not the outer side.
EASAudioGenerator.build_files()and.build_manual_components()(app_utils/eas/generator.py) both play an optional chime — which can be an MDC1200 selective-calling packet whenpre_alert_chime/post_alert_chimeis set tomdc1200— immediately before the first SAME header burst and immediately after the EOM sequence. The gap between that chime and the header/EOM it brackets already existed; the gap before the pre-chime burst (composite audio started the MDC1200 packet at sample 0) and after the post-chime burst (composite audio stopped dead at the end of the packet, no trailing silence at all) did not. Reported as "still not getting the second of dead air before the MDC1200 preceding the header... [and] the 1 second of dead air after the mdc1200 after the EOM." Added the missing leading and trailing 1-second silence in both methods, only when a chime is actually configured (unchanged, still zero added silence, whenpre_alert_chime/post_alert_chimeisnone).
[3.18.1] - 2026-09-18 - Fix: alert detail map and per-service coverage percentages weren't actually alert-scoped
Two related bugs found from a user report ("the affected boundaries... is confusing and doesn't work the way it should" / "the percentages... make no sense") against a live alert detail page.
Fixed
- The alert detail map's boundary layer toggles ("Counties," "Fire Districts," "Villages," etc., labeled "Affected Boundaries" in the legend) weren't scoped to the alert at all.
loadBoundaries()called/api/boundaries?type=X, the same unscoped endpoint the dashboard map uses, so toggling e.g. "Villages" drew every village in the configured service area — not just the ones this alert actually intersects (the sidebar's correct "22 of 126 affected" count comes from the sameIntersectionrows the map ignored). Added an optionalalert_idquery param toGET /api/boundariesthat joinsIntersectionwhen present; the map now passes the current alert's ID. Every other caller (dashboard, admin boundary management) is unaffected since the param is optional. - The per-service-type coverage percentages ("Villages: 27.3%", "Fire: 73.8%", ...) answered a different question than the county-level percentage (74.0%) displayed right next to them, with no indication of the difference.
calculate_coverage_percentages()scoped each per-type percentage's denominator to only the boundaries already flagged as affected — i.e. "of the villages that got touched at all, how much of their own combined area is covered" — rather than "how much of the county's villages are covered," which is what the adjacent 74.0% figure (and the badge styling implying a severity grading) led a reader to expect. Rescoped the denominator to every boundary of that type within the configured county's polygon (resolved via the same county-boundary lookup the county-level percentage already used, moved earlier in the function so both blocks can share it), making the two percentages directly comparable. Falls back to the old, narrower scoping when the county polygon can't be resolved. Deliberately does not scope to "every boundary of that type this deployment has ever uploaded" —Boundaryrows carry no county field and a deployment commonly holds a neighbouring county's fire districts/villages/etc. too, which is exactly the "misleadingly low percentage" bugtests/test_coverage_and_signature.py::TestCoverageCalculationLogicwas written to prevent (fixed for the county-level figure previously; this fix brings the per-type figures in line with the same principle rather than reintroducing that bug at a different scope).
[3.18.0] - 2026-09-18 - Refactor: extract poller/cap_poller.py's CAP-geometry collaborator (Large File Refactor Plan, Phase 4c continuation)
The first slice of CAPPoller's 50 remaining stateful methods (the highest-risk item left in the plan) to actually land, using the 2e characterization-harness technique: pin behavior with tests written against the pre-extraction bound methods first, extract, then verify the same tests pass against the extracted free functions.
Changed
- Extracted 12 methods (plus the module-level
_serialize_alert_for_sighelper and theMESSAGE_TYPE_PRIORITIESclass constant) intopoller/cap_geometry.py(664 lines):_parse_ipaws_xml_feed,_convert_cap_alert,_extract_cap_resources,_extract_area_details,_parse_cap_polygon,_parse_cap_circle,_approximate_circle_polygon,_message_type_priority,_alert_sort_key,_should_replace_alert,parse_cap_alert,_count_vertices. All 12 only ever touchedself.logger(set once in__init__, never reassigned) or each other — neverself.db_sessionor the poller's zone/SAME-code configuration — making them a genuinely low-risk collaborator, unlike the 50 methods that remain. loggeris threaded through as an explicit parameter rather than a fresh per-modulelogging.getLogger(__name__), specifically to avoid the module-level-logger hazarddocs/development/AGENTS.mddocuments from Phase 3e (a new logger here would silently rename every log record frompoller.cap_pollertopoller.cap_geometry).cap_poller.py's 4 remaining call sites passself.loggerexplicitly.- 59 characterization tests written against the pre-extraction bound methods first (
tests/test_cap_geometry.py, superseding a since-deletedtest_cap_geometry_characterization.py), 2 confirmed load-bearing via targeted mutation spot-checks (both caught immediately) before any code moved. Retargeted 3 existing test files (test_ipaws_event_code_extraction.py,test_cap_poller_batching.py,test_cap_poller_per_item_isolation.py) off the now-removed bound methods; one of those (test_parse_ipaws_xml_feed_one_malformed_alert_does_not_drop_the_others) needed a real monkeypatch retarget to intercept_parse_ipaws_xml_feed's internal same-module call to_convert_cap_alert, since an instance-attribute patch no longer has anything to intercept once both live incap_geometry.pyas free functions. cap_poller.py: 4800 → 4244 lines.CAPPolleritself is now ~3,183 of those lines across the remaining 38 methods — still the actual Phase 4c work, not started.- Full suite green: 3426 passed (was 3367), 0 failures.
[3.17.0] - 2026-09-18 - Refactor: split app_core/eas_storage.py into a package (Large File Refactor Plan)
Profiling confirmed eas_storage.py (2,825 lines, 57 top-level functions) has the same pure-motion shape as system.py (4a) and eas.py (4b) — mostly independent functions across ~10 topics, not a god-class — unlike poller/cap_poller.py (4c). app_core/eas_storage/, sdr_hardware_service.py and eas_monitoring_service.py were the three remaining unprofiled files the plan flagged; this closes out the first of the three (the other two are dominated by a single ~900-1100-line function each and need the 2e characterization-harness technique, not this technique).
Changed
- Split into 14 modules by topic:
audio_decode_log.py,file_cache.py,schema_migrations.py,backfill.py,delivery_records.py,delivery_trends.py,compliance_parsing.py,compliance_log.py,compliance_export.py,reports_common.py,reports_received_initiated.py,reports_summary.py,reports_export.py,precedence.py, plus a__init__.pyshim re-exporting all 40 public names (and theformat_local_datetime/utc_nowpass-throughs some callers import from this module) exactly as before. - All 69 top-level definitions (57 functions + 12 constants, plus the
PrecedenceLevel/PRECEDENCE_AVAILABLEtry/except import block) verifiedast.dump()-identical to their originals — no normalization needed this time, since nothing moved out of a class (unlike 4a-ii/4c'sself-stripping). - Kept
collect_compliance_log_entriesandcollect_compliance_dashboard_datain the same module (compliance_log.py) specifically because the latter calls the former as a same-module bare name — the same internal-call hazard shape aseas.py'sbuild_same_header/clear_broadcast_active, caught this time by tracing the call graph before laying out modules rather than after. - Dropped one confirmed-dead import (
ORIGINATOR_DESCRIPTIONSfromapp_utils.eas, imported but never referenced anywhere in the original file). compliance_log.pylands at 405 lines, a negligible, deliberate overage over the 400-line guideline to keep the internal-call pair above together rather than split them across files.- Full suite green: 3367 passed, 0 failures — identical pass count to before the split.
[3.16.1] - 2026-09-18 - Fix: lightweight Flask bootstrap for CLI/timer scripts (GitHub issue #2581)
Fixed
scripts/ingest_security_perimeter_log.pybooted the entire Flask app just to tail a log and insert a few DB rows.create_app()imports ~260 routes and initializes every subsystem (TTS, Icecast, hardware proxies, ...) regardless of what the caller needs, and this script runs every 2 minutes forever viasecurity-perimeter-ingest.timer— aNice=10/IOSchedulingClass=idlemitigation (see that unit's own comment, now updated) kept the ~6s-per-run CPU burst from starving the real-time SDR/demod/SAME-decode path, but the underlying cost was still paid every run. Measured 7.2s → 1.8s per invocation after the fix (639 registered routes → 1, Flask's own defaultstaticendpoint).
Added
app_core/minimal_app.py: acreate_minimal_app()helper that builds a bare Flask app bound only to the shareddbextension (app_core.extensions.db) — no route registration, no subsystem init, no schema-migration sweep — for standalone scripts that only ever touch the ORM. Loads environment variables the same wayapp.pydoes (CONFIG_PATHor the default.env, bothoverride=True), and sizes the SQLAlchemy engine pool for a one-shot process (pool_size=1) instead ofapp.py's two-gunicorn-worker sizing (pool_size=10).tests/test_minimal_app.pycovers theDATABASE_URL-required contract, the zero-routes guarantee, and the sqlite/postgres engine-option split.- Points
scripts/ingest_security_perimeter_log.py,scripts/fix_admin_roles.py, andscripts/create_example_screens.pyat the new bootstrap instead ofapp.py'screate_app()— all three only ever neededdb.sessionand ORM models, confirmed by tracing their imports (app_core.analytics.security_blocks,app_core.auth.roles,app_core.models) back to see none of them touchapp.py.
[3.15.0] - 2026-09-18 - GitHub repo hygiene: fix the Release workflow, add SECURITY.md/CODEOWNERS/templates
Fixed
.github/workflows/release.yml's "Validate release metadata" step was broken and had been since 2026-09-17, silently: it only ever installed barepytest, not the project's own dependencies.tests/conftest.py's autouse_isolate_eas_stream_injectionfixture (added that day, see the 3.10.3 entry above) importsapp_core.audio.redis_commandsfor every test unconditionally, which needs Flask/SQLAlchemy/etc. importable even fortests/test_release_metadata.py's own dependency-light tests. Manually re-running the workflow (last run: 2026-08-31,v2.207.4— three weeks and many version bumps stale) surfaced the failure immediately. Fixed by installingrequirements.txtbefore running pytest, matchingtests.yml's pattern; no service containers needed since nothing in this step does real DB/Redis I/O.- Re-ran the Release workflow after the fix to catch main up to the current
VERSION.
Added
- Branch protection on
main:CodeQL,analyze,lint, andpytest (3.13)must all pass before a PR can merge; force-pushes and branch deletion are blocked; open PR conversations must be resolved. No required-review rule, so a maintainer (or an agent working under one) can still merge once checks are green. allow_auto_mergeenabled at the repo level, anddelete_branch_on_mergeenabled — a PR can now be set to merge itself the moment checks pass, and its branch is deleted automatically afterward.- Private vulnerability reporting enabled, with
.github/SECURITY.mddocumenting how to use it, what's in scope, and the (rolling-release, latest-only) support policy. .github/CODEOWNERS: a single global rule for now (one-maintainer repo); add path-specific rules as that changes..github/PULL_REQUEST_TEMPLATE.md: prompts for a summary, test plan, and the release/versioning checklistdocs/development/AGENTS.md§9 already requires of every change..github/ISSUE_TEMPLATE/: structured bug-report and feature-request forms (GitHub's YAML issue-forms format), plus aconfig.ymldisabling blank issues and redirecting security reports to private vulnerability reporting instead.
[3.16.0] - 2026-09-18 - Refactor: extract poller/cap_poller.py's stateless methods (Large File Refactor Plan, Phase 4c partial)
Profiling poller/cap_poller.py (4,933 lines) confirmed it really is the god-class the plan originally assumed for Phase 4 — unlike system.py and eas.py, both of which turned out to be mostly independent functions once actually profiled. CAPPoller is ~3,978 of the file's lines across 59 methods.
Changed
- Extracted
CAPPoller's 9 stateless methods (zeroselfreferences, 143 lines) intopoller/cap_alert_parsing.py:_select_cap_info,_extract_cap_event_codes,_extract_cap_parameters,_summarise_geometry,_apply_cancellation_status,_validate_ugc_code,_normalize_same_code,_coords_equal,_safe_json_copy. Rewrote all 19 internalself.method(...)call sites to bare function calls.cap_poller.py: 4933 → 4800 lines — still far over the guidance; the other 50 methods (3,711 lines) are the actual remaining work, not started, and are now the highest-risk item left in this plan. - All 9 moved functions verified
ast.dump()-identical to their originals (after normalizing awayselfand one level of docstring indentation — the same two normalizations Phase 2d needed forGPSManager's stateless methods). - Retargeted
tests/test_ipaws_event_code_extraction.py'sTestExtractCapEventCodesoff a liveCAPPollerinstance (object.__new__(CAPPoller), built solely to reach the now-moved method) to call_extract_cap_event_codesdirectly. - Full test suite green.
Fixed (tooling)
ruff(not available in this environment by default) installed into a scratch venv for a real lint pass, catching twoF821s thatpy_compileand even a realimportof the new module had both stayed silent about:ET.Element/CAPAlerttype hints relying onfrom __future__ import annotationsto dodge real imports, which defers evaluation but doesn't exempt a name from needing to resolve. Fixed by importingETthe same waycap_poller.pyitself does (get_element_tree_module()) andCAPAlertfromapp_core.models.
🔒 GitHub's PR-scoped CodeQL analysis flagged 3 "new" alerts on the app_utils/eas.py package PR that merged just before this one — a polynomial-regex pattern and a log-injection pattern in files that split out of eas.py, and a stack-trace-exposure pattern in webapp/admin/pending_alerts.py, a file that PR never touched at all. All three confirmed pre-existing (byte-identical or ast.dump()-identical to code already on main) rather than fixed — a security-flagged regex or log call is a behaviour change and doesn't belong in a pure-motion refactor commit. main has no branch protection requiring CodeQL to pass and already carries ~100 open alerts of the same rule categories elsewhere in the tree. Noted here for visibility, not silently dropped.
[3.14.0] - 2026-09-18 - Refactor: split app_utils/eas.py into a package (Large File Refactor Plan, Phase 4)
The single largest file in the tree at the time (4,246 lines, grown from the 3,848 lines the plan was originally scoped against). Unlike the plan's original note calling this "a very large class," the file had actually grown into 48 mostly-independent top-level functions plus two large classes (EASAudioGenerator, EASBroadcaster) — the 2a/2b pure-motion shape, not the god-class shape Phase 4 was expected to need.
Changed
- Split into 14 topic modules under
app_utils/eas/:indicators.py(Redis-backed broadcast/incoming-alert state),config.py(load_eas_config),same_header_constants.py,same_header_decode.py,same_header_build.py,tts_normalize.py,tts_compose.py,tone_generation.py,chime.py(split out separately to stay under the line guidance),wav_io.py,broadcast_pid.py,audio_conversion.py,generator.py(EASAudioGenerator),broadcaster.py(EASBroadcaster).app_utils/eas.pyis now a package; the old single-file module is gone, andapp_utils/eas/__init__.pyis the compatibility shim re-exporting every name external code ever imported from it. - All 48 top-level functions/classes and all 32 module-level constants are
ast.dump()-identical before and after — pure motion, not restructuring. - 12 of 14 modules are within the 400-line guidance.
generator.py(848,EASAudioGenerator) andbroadcaster.py(489,EASBroadcaster) are known exceptions — each is one god-class; bringing them under the cap needs collaborators extracted from the class bodies, a behavioural change requiring its own characterization pass, tracked as a follow-up.config.py(408) is dominated by one 369-line function (load_eas_config) for the same reason. - A confirmed internal-cross-call hazard was found and fixed before landing:
EASBroadcaster.handle_alert()callsbuild_same_header()andclear_broadcast_active()as same-module bare names today;tests/test_gpio_centralized_keying.pymonkeypatches both at theapp_utils.easmodule level expecting to intercept that internal call. OnceEASBroadcastermoved to its own module importing those by value, the patch would have silently stopped reaching the call — confirmed by temporarily reverting the retarget and watching the test fail withKeyError: 'present'(the recording wrapper never got called). Retargeted toeas.broadcaster.build_same_header/eas.broadcaster.clear_broadcast_active. subprocessandtimeare re-exported from the package__init__(import subprocess,import time) sotests/test_gpio_dump_broadcast.py'seas_module.subprocess.Popenpatch andtests/test_airchain_fringe_cases.py'spatch('app_utils.eas.time.sleep', ...)continue to resolve — both are shared singleton stdlib modules, so patching either one's attributes affects every module that imports it, regardless of which one the patch is aimed at.- Verified against the full test suite: 3,362 passed, 282 skipped, 62 xfailed, 9 xpassed, 0 failures. 37 distinct names imported from
app_utils.easacross the whole tree (production + tests) were enumerated first and confirmed to still resolve; every production consumer (app_core.audio.*,poller.cap_poller,scripts.*,services.gpio.alert_indicators,webapp.*) was imported directly to confirm the shim resolves. grep -n "__file__"across all 14 new modules is empty — no path-depth hazard.
[3.13.0] - 2026-09-18 - Refactor: split webapp/admin/certbot/routes_obtain_execute.py (Large File Refactor Plan, Phase 3e-ii)
obtain_certificate_execute was one 387-line try block — the one module Phase 3e left over the 400-line guidance, and with zero test coverage beforehand.
Changed
- Extracted pre-flight validation into
obtain_validation.py(_validate_obtain_request,_check_certbot_installed) and the three certbot methods (standalone, nginx plugin, webroot) intoobtain_methods.py.routes_obtain_execute.pyis now 99 lines: parse the request, validate, check prerequisites, handle a staging→production cert switch, and dispatch to one method via a small lookup table. - The route's public behavior, URL, and response shapes are unchanged.
- Added
tests/test_certbot_obtain_execute.py: 28 characterization tests written and run green against the pre-refactor handler first (the module's first-ever test coverage), reaching the handler through__wrapped__to bypass the permission decorator rather than standing up a full authenticated test client. A 20-mutation sweep across all three new/changed files confirmed the suite is discriminating — all caught after two isolation fixes (an assertion that matched raw pre-augmentation error text as readily as the augmented message, and a missing test for the webroot method's own permission-denied augmentation branch). - Updated
tests/test_certbot_package.py's size-guidance guard:routes_obtain_execute.pyis no longer a known exception.
[3.12.0] - 2026-09-18 - Refactor: split app_utils/system/smart.py (Large File Refactor Plan, Phase 4a-ii)
Companion to the same phase's snapshot.py split. _collect_smart_health was one 425-line function: locate smartctl, then for each device build a query command, run it, validate/parse its JSON output, infer an overall health status from the exit code when smartctl's own verdict is absent, and populate ~35 result fields from the report.
Changed
- Extracted the per-concern pieces into their own modules:
smart_command.py(locating smartctl, building its command line),smart_query.py(running smartctl, validating/parsing its output),smart_status.py(the exit-code health-inference fallback),smart_attributes.py(identity fields, SMART attributes, NVMe extended fields).smart.pyis now 191 lines of per-device orchestration. _collect_smart_health's public signature and return shape are unchanged; still resolves fromapp_utils.systemand is used unchanged bywebapp/admin/api/routes_smart.pyandscripts/diagnose_smart.sh.- Added
tests/test_smart_health_package.py: 24 characterization tests written and run green against the pre-refactor function first, covering smartctl discovery, every subprocess/output-validation failure mode, and field-extraction wiring (the existingtests/test_smart_health.pyalready covered the exit-code status inference in depth and needed only itssubprocess.run/os.pathpatch targets retargeted to the modules that now call them). An 18-mutation sweep confirmed the suite is discriminating, including a follow-up test added directly against the newly-extracted_build_smartctl_command()for a branch (-n standbyfor ATA/SAT device types) that_detect_device_type()currently never actually produces, so the full_collect_smart_health()path can't reach it end to end.
[3.11.0] - 2026-09-18 - Refactor: split app_utils/system/snapshot.py (Large File Refactor Plan, Phase 4a-ii)
Pure-motion follow-up to Phase 4a (docs/development/LARGE_FILE_REFACTOR_PLAN.md). build_system_health_snapshot was one 480-line function collecting CPU, memory, disk, network, process-table, load-average and database figures inline before computing an overall status — the one piece of app_utils/system/ Phase 4a left over the 400-line guidance.
Changed
- Extracted the inline collection blocks into their own modules, each independently testable:
cpu.py,memory.py,disk_usage.py,processes.py,loadavg.py,db_health.py, and a_collect_network_info()added to the existingnetwork.py. The overall-status computation moved to a newstatus.py.snapshot.pyis now 158 lines of orchestration calling these collectors and assembling the response dict — every module inapp_utils/system/is now within the 400-line guidance. build_system_health_snapshot's public signature and return shape are unchanged;app_utils.build_system_health_snapshotandapp_utils.system.build_system_health_snapshotstill resolve.- Added
tests/test_system_health_snapshot_package.py: 18 characterization tests written and run green against the pre-refactor function first (per the plan's ground rules), covering every branch through psutil, the process table and the database probe, with the twelve already-extracted sibling collectors (systemd, hardware, SMART, temperature, dependencies, GPS, RTC, clock sync, Raspberry-Pi health, OS details, shields badges, distro logo) stubbed so the tests exercise only the logic that moved. A 14-mutation sweep across the new modules confirmed the suite is discriminating (all caught after two isolation fixes — the initial CPU/DB critical-status test conflated the two triggers, and the disk-permission-error test couldn't distinguish "correctly skipped" from "silently fell back to/").
[3.10.4] - 2026-09-17 - Fix raw HTML tags rendering as visible text on the Alert Details page
Reported via a user-submitted PDF of the Alert Details page for a Van Wert County, OH Flood Warning (Database ID #1108): the Description and Instructions sections showed literal, visible text like </p><p class="mt-2 mb-0"> and <br> instead of real paragraph breaks — even though the alert's description column, verified directly against the database, is clean plain text with real \n\n breaks and contains no HTML at all.
Fixed
- Root cause:
templates/alert_detail.html,audio_detail.html, andmanual_eas_print.htmlspelled the "escape untrusted CAP text, then insert paragraph/line-break HTML" transform out inline as{{ text | e | replace('\n\n', '</p><p ...>') | replace(...) | safe }}.| eproduces a Jinja/MarkupSafeMarkupobject; Jinja's|replacefilter, given an already-Markupvalue, routes toMarkup.replace()— which HTML-escapes its own replacement argument too, as part of the invariant that keeps aMarkupvalue safe to pass around elsewhere. So the<p>/<br>tags each template meant to insert came out double-escaped: present in the string, but as the literal text<p ...>rather than a real tag, which a browser then displays as visible<p ...>text once the whole thing is marked| safeat the end. - Moved the transform into Python (
webapp/template_helpers.py): fixed the existing, previously-unusednl2brfilter to escape input before substituting (building the finished HTML as a plainstr, never touchingMarkup.replace(), then wrapping the result inMarkupexactly once at the end), and added a newcap_paragraphsfilter for the paragraph+bullet variant used by alert descriptions/instructions. All 5 call sites across the 3 templates now use one of these two filters instead of the inline chain. - Added
tests/test_cap_text_html_filters.py: unit tests for both filters (including the exact reported text) plus a structural regression test that scans every template for the| e | ... replace(...)shape and fails the build if it reappears.
[3.10.3] - 2026-09-17 - Fix CAP/IPAWS auto-forwarded alerts never reaching Icecast
A live Flash Flood Warning (Putnam County, OH, 2026-09-17 07:44 EDT) was auto-forwarded via IPAWS, logged "Auto-forwarded ... to air chain," and forwarded again minutes later by the forwarding catch-up sweep after eas-station-poller restarted mid-cycle — but a listener monitoring the stream live heard nothing either time.
Fixed
- Root cause:
EASBroadcaster.handle_alert()(app_utils/eas.py) pushed generated broadcast audio into Icecast by callingapp_core.audio.eas_stream_injector.inject_eas_audio()directly, in-process. That function is a no-op unlesseas_stream_injector.set_controller()was called earlier in the same process — which only happens ineas_monitoring_service.py, the entry point ofeas-station-audio.service.handle_alert()is also invoked frompoller/cap_poller.py(the primary CAP/IPAWS ingest path, the gated-alert auto-release timer, and the forwarding catch-up sweep — all running aseas-station-poller.service) and fromwebapp/admin/pending_alerts.py's gated-alert "Approve" action (eas-station-web.service). In every one of those processes the injection call silently did nothing: theEASMessageDB row was still written and the alert still logged as forwarded, but no audio ever reached Icecast or local playback. The direct call was added in commitff640d3(2026-03-25), which — ironically — cited the PR that fixed this identical class of bug for Manual Send/RWT by routing through Redis, but added a new unguarded direct call for the auto-forward path instead of reusing that fix. The existing structural regression test (tests/test_broadcast_reaches_icecast_audit.py) is AST-based and only checks that a broadcast-trigger function calls something namedinject_eas_audio(), so it could not detect that the call was a cross-process no-op. handle_alert()now checkseas_stream_injector.has_controller()and, when no controller is registered in the current process, falls back to the same cross-process Redis command Manual Send already uses (AudioCommandPublisher.inject_raw_eas_audio), askingeas-station-audio.service— the process that actually owns the liveIcecastStreamerthreads — to perform the injection. A failed fallback now logs an ERROR naming the problem instead of failing silently.- The injection outcome is recorded on the
EASMessagerow (metadata_payload['icecast_injected']) so a transient failure (audio-service down, Redis unreachable at exactly that moment) isn't a permanent, silent loss. A newCAPPoller.retry_failed_icecast_injections()sweep, run every poll cycle alongside the existing forwarding catch-up sweep, re-sends any recent failed injection via the same resend command the EASMessage detail page's manual "Resend" button uses, up to 3 attempts, and raises a system-log ERROR if it still hasn't gone out after that. - Added
tests/test_eas_broadcaster_injection_fallback.pyandtests/test_icecast_injection_retry_sweep.py.
Fixed (test infrastructure)
- While verifying this fix, a test run that exercised
handle_alert()'s new fallback without mocking it sent 7 realinject_raw_eas_audiocommands to the actual liveeas-station-audio.serviceon the dev box, each one audibly injecting a short synthetic test WAV into the live Icecast streams. The existingREDIS_DB=15test isolation (added after an earlier incident where the test suite keyed a live GPIO relay) did not protect against this: RedisPUBLISH/SUBSCRIBEare global across logical databases regardless of which DB a client hasSELECTed, unlike key-value commands. Added an autouse fixture (tests/conftest.py::_isolate_eas_stream_injection) that stubsAudioCommandPublisherfor every test by default, so no test can reach the real audio-command channel without explicitly opting in.
[3.10.2] - 2026-09-17 - Pin fsspec/s3fs to stop dependency-bump PRs from failing CI
A routine Dependabot boto3 patch bump (1.43.56 → 1.43.92) failed CI's dependency install step with pip: ResolutionImpossible, even though the bump touched nothing related to the reported conflict. Root cause: arm_pyart requires both fsspec and s3fs, but only loosely (fsspec>=2021.11.0), and neither was otherwise pinned in requirements.txt. Older s3fs releases hard-pin an exact matching fsspec version (e.g. s3fs==2025.3.1 requires fsspec==2025.3.1.*), so pip's resolver could wander into an old, mutually-incompatible s3fs/fsspec pair while re-resolving the whole file for an unrelated bump. Pinned both to 2026.7.0, the versions already verified working together in production, removing the ambiguity.
[3.10.1] - 2026-09-16 - Fix missing LED degree glyph and a fail2ban leader-lock race
A code review of the two preceding commits (the Icecast/fail2ban leader-lock fix and the LED screen editor WYSIWYG fix) turned up two follow-on bugs.
- Fixed a missing
°glyph in the LED screen editor's client-side font table:LED_GLYPH_5x7instatic/js/screen-editor.jsomitted the degree symbol thatscripts/dotmatrix_preview_font.py's server-sideFONT_5x7defines, so a legacy LED text line containing°(e.g. a temperature message) rendered as a blank space on the live editing canvas while the "pixel-accurate" preview modal rendered the real glyph -- the exact WYSIWYG mismatch the prior commit set out to fix, reintroduced for one character. - Fixed a non-atomic leader-lock renewal race in
app_core/fail2ban_sync.py:_acquire_or_renew_leader_lock()renewed its Redis lease with a separateget()thenexpire()call, so the lock key could expire and be claimed by another worker in the gap between the two, letting the renewing worker wrongly believe it still held leadership and briefly runfail2ban-client/systemctlalongside the new leader in the same cycle. Renewal now uses a small atomic Lua script (_RENEW_LOCK_SCRIPT, run viaEVAL) that checks ownership and extends the TTL in one round-trip.
[3.10.0] - 2026-09-16 - LED screen editor: honest WYSIWYG for the Alpha font/colour selection
An audit of the Alpha M-protocol driver and its "WYSIWYG" screen editor (prompted by suspicion the editor was sloppy even though the protocol driver itself checked out as solid and well-tested) found the editor's live canvas and the "pixel-accurate" preview modal both rendered every Alpha Font selection at the same fixed size -- picking a larger font changed nothing an operator could see. Tracing it further turned up a second, related class of bug: the legacy 4-line LED text mode reused the same free-form draggable-element canvas as OLED/VFD, which let an operator set several per-element fields (font size, alignment, invert, etc.) that were silently discarded and had zero effect on the real 4-line sign message, and building the saved message used raw element-insertion order rather than the order lines actually appeared top-to-bottom on the canvas.
- Font selection now actually changes the preview, both the always-visible editing canvas and the server-rendered modal:
scripts/dotmatrix_preview_font.pygainedblit_text_scaled(), which approximates a larger/smaller Alpha font by scaling the same shared 5x7 glyph shapes (declared font dimensions, e.g.FONT_32x16, aren't verified against real sign firmware -- see the existing protocol audit's §3.2 -- so this is a best-effort size approximation, not pixel-perfect).scripts/led_preview_render.py'srender_led_preview()now stacks each line at its own scaled height (message-level or per-line font/colour override) instead of a fixed grid, and the client canvas mirrors it with a matching round-dot-plus-glow renderer (drawLedDotText()) instead of generic monospace text. - Removed inert per-element fields (font size, align, max width, wrap, invert, allow-empty) from the properties panel when editing a legacy LED text line -- none of them ever reached
render_led_screen()'slinespayload, so showing them let an operator "set" something that silently did nothing on real hardware. - Fixed line order:
buildTemplateData()now sorts LED text elements by Y position before building thelinesarray, so the order lines appear top-to-bottom in the editor is the order they're actually sent -- previously this used raw element-insertion order, which silently diverged from the visual layout as soon as a line was dragged past another. - Fixed a stale-canvas-size bug on load:
loadScreen()was setting theled-fontdropdown's value after sizing the canvas and staggering line positions from it, so opening a saved screen briefly showed the wrong-sized canvas until the operator touched the font dropdown themselves. - Added a 4-line cap (with a toast) when adding LED text elements, matching the sign's actual line limit instead of silently truncating on save with no UI feedback.
- Generalized
_render_led_glow()'s single-colour dot rasterizer to a per-pixel colour callback so per-line colour overrides render correctly, and both existing LED preview call sites (render_led_preview,render_led_elements_preview) were updated to the new signature. - No JS test harness exists in this repo, so the client-side change was verified by hand: a standalone static harness (the real
templates/screen_editor.htmlmarkup + the actualscreen-editor.js/utils.js) driven over the Chrome DevTools Protocol confirmed font selection resizes the canvas (120x32 atFONT_7x9-> 720x64 atFONT_32x16), colour selection changes the glow colour, inert fields are hidden, the 4-line cap holds, and OLED/VFD/LED-graphics modes are all unaffected -- zero console errors or uncaught exceptions across the whole pass. - 9 new Python regression tests across
tests/test_display_preview_render.py(font-size-changes-the-render, per-line font/colour override, unknown-font fallback) andtests/test_dotmatrix_preview_font.py(scaled-blit unit tests, including a 1x-scale-matches-legacy-output equivalence check).
[3.9.3] - 2026-09-16 - Fix false-positive Icecast buffer warnings and duplicate fail2ban sync cycles
Found while reviewing service logs for real issues: two long-running, log-visible-but-benign-looking problems turned out to be genuine bugs once traced to their code.
- Icecast buffer health check always reported near-zero:
IcecastStreamer._feed_loopreadbuffer_level = len(buffer)afterbuffer.popleft()in the same iteration -- since one chunk is read and one is popped per iteration, this measured the post-pop remainder (0-1) rather than the actual banked cushion, so the low-watermark check fired continuously regardless of real audio health. In production this logged ~120 "Icecast buffer running low ... 0/600 chunks" warnings per hour, per mount, on all four streams, non-stop -- pure log noise with zero diagnostic value, easy to mistake for a real audio problem (or to mask one). Fixed by snapshottingbuffer_levelbefore the pop, so it reflects the true depth of audio banked ahead of playout. - fail2ban sync scheduler ran once per Gunicorn worker instead of once per app:
app_core/fail2ban_sync.py's background thread is started at module import time inapp.py, which every Gunicorn worker process imports independently (no--preload) -- so a 4-worker deployment ran 4 independent copies of the 60s sync loop, visible in the journal as bursts of privilegedfail2ban-client/systemctlsudo calls every ~15-20s instead of once a minute. Fixed with the same Redis SETNX cross-worker leader-lock patternapp_core/rwt_scheduler.pyalready uses for its own per-worker duplication problem: a renewable lease (TTL = 3x the sync interval) so exactly one worker runs the cycle, and another takes over automatically if the leader dies; falls back to the historical every-worker behavior if Redis is unreachable rather than silently stopping the sync. - Icecast local jitter buffer could never recover from a stall: once the pre-pop fix above started reporting real numbers, live monitoring caught a second, deeper bug it had been masking:
IcecastStreamer._feed_loopreads and writes exactly one chunk per iteration, so a normal iteration nets zero change in buffer depth and a read timeout nets -1 -- there was no path that could ever net positive. That makes the buffer a one-way ratchet: any network stall on an internet-relay source drains it by exactly the chunks lost, permanently, since even a source that catches up afterward could only refill one chunk per iteration, matched chunk-for-chunk by that same iteration's single pop. Confirmed live: two internet-relay mounts (WNCI, ERN-LUC) ratcheted down in discrete steps (150 -> 149 -> 147 -> 146 chunks) over several hours and never recovered. Fixed by opportunistically draining any further chunks already queued (non-blocking) right after a successful read, so a post-stall backlog burst can add many chunks in one iteration while FFmpeg still only receives one -- letting the buffer actually climb back toward its target depth instead of only ever shrinking. - 5 new regression tests:
test_buffer_health_check_reflects_pre_pop_depthandtest_buffer_recovers_after_a_stall_once_source_catches_up(both fail without their respective fixes, reproducing the exact production symptoms) plus threetest_leader_lock_*tests covering single-leader election, lease takeover after expiry, and the Redis-unreachable fallback.
[3.9.2] - 2026-09-14 - Fix Identify Stations never completing a PS name
3.9.1's ring-buffer fix was necessary but not sufficient: re-tested live afterward with a diagnostic build, Identify Stations was still 0/9 -- but this time with real evidence of why. RBDS sync itself was working correctly (confirmed live: PI=5862, call sign WBKS resolved, sync confirmed ~1.8s in) -- the bug was in how the result was read afterward, and in how much time was budgeted.
- Root cause: the 8-character PS marquee text (
ps_name) is assembled from 4 separate segments broadcast in different RDS groups over several seconds -- sync succeeding is necessary but not sufficient for a complete name._run_bandscan_identify's defaultdwell_sec=2.5left barely any runway after the ~1.8s sync itself, sops_namewas reliably still''(incomplete, not decoded) when the dwell ended on every peak, every time -- including a station live-verified to sync fine every single pass. - Fixed:
call_sign(derived from the PI code carried in every synced group, available within ~1-2s of sync, no multi-group assembly needed) is now captured throughout the dwell as a fast, reliable fallback. The early-stop-on-decode path still only fires on a completeps_name, so a lucky fast station still finishes quickly -- but a result that syncs without ever completing the marquee text now reports the call sign instead of silently reporting nothing. - Raised the default dwell budget from 2.5s to 6.0s to give PS assembly more real runway on top of the call-sign fallback.
webapp/radio_settingsresults list now showsps_name || call_sign, preferring the fuller marquee text when it's actually complete.- 1 new test (
test_identify_falls_back_to_call_sign_when_ps_name_never_completes) plus updated coverage across the existing identify tests for the newcall_signfield. - This whole investigation (3.9.1 and 3.9.2 together) happened because the feature was tested against real hardware immediately after shipping, at the user's prompt, rather than trusting the unit tests' mocked demodulator -- see 3.9.1's own entry for why that specific bug class needed live testing to catch at all.
[3.9.1] - 2026-09-14 - Fix Identify Stations decoding zero RDS (ring-buffer race)
Live-tested against real hardware immediately after 3.9.0 shipped, at the user's prompt: Identify Stations decoded 0 of 9 real detected peaks -- including a station (93.9 MHz) the persistent live monitoring receiver decodes as "KISS" on every pass. This was a genuine functional bug, not bad luck.
- Root cause:
_run_bandscan_identify(and_run_bandscan_sweep, and_auto_gain_calibratebefore it) callreceiver.get_samples()directly from their own thread, same aspublish_samples_and_metrics()'s own live publisher thread does continuously for every running receiver. Both threads read from the same underlying ring buffer -- per that function's own existing comment, the publisher is normally the only thread draining it, and a second concurrent consumer fragments the stream between them. A coarse RMS level measurement (what the sweep and auto-gain need) tolerates that fine; RDS decode does not --RBDSWorker's Costas/M&M loops need an unbroken, phase-continuous stream across many consecutive reads to ever reach lock, so every one of the 9 peaks silently failed to decode. - Fixed:
publish_samples_and_metrics()now skips reading (and publishing telemetry for) a receiver for the duration of any Bandscan sweep or Identify Stations pass against it (identifier in _state.active_bandscans), leaving the scan thread as the sole consumer of that receiver's samples for its duration -- consistent with, not a regression from, that receiver already being retuned away, muted, and dead-air-suppressed during the same window. - Re-verified live after the fix (see commit/PR for the before/after real-hardware results).
- No test added for
publish_samples_and_metrics()itself -- it has no existing test harness (an infinitewhile _state.runningloop against live threading/hardware state) and this specific bug class (a cross-thread hardware race) is exactly the kind a mock cannot catch; live testing is what caught it, and is the right tool for verifying the fix too.
[3.9.0] - 2026-09-14 - Bandscan: Identify Stations (RDS station-name labeling of peaks)
Closes the second item explicitly deferred out of Bandscan's original 3.7.0 changelog entry: "RDS station labeling of detected peaks." A peak used to only ever show its bare frequency (e.g. "93.9"); this adds a second, explicit pass that retunes to each detected peak long enough to attempt an RDS PS (station name) decode.
- Why a second pass: RDS sync needs real dwell time --
RBDSWorker's own timing puts a decent signal's first PS decode at ~1 second end-to-end ("the same time a car radio takes"), unlike the sweep's near-instant per-channel level measurement. Sweeping 206 channels in ~150ms each can never decode RDS, so this can't be folded into the sweep itself. - Added: a new "Identify Stations" button on SDR Diagnostics, shown once a sweep finishes with at least one detected peak. Its own
confirm()states the added time (~2.5s per peak) and that the receiver still won't monitor for real alerts during it -- same risk framing as Bandscan itself. - Added:
sdr_hardware_service.py's_run_bandscan_identify, dispatched via a newbandscan_identifySDR-service action -- retunes to each target frequency and dwells (default 2.5s, stopping early the moment a PS name decodes) via a fresh, purpose-builtFMDemodulatorper frequency, so a stale lock from one peak can never leak into the next. Shares_state.active_bandscans(the sweep's own concurrency guard) andRedisChannels.BANDSCAN_ACTIVE_PREFIX(the audio-mute/dead-air-suppression flag shipped in 3.8.0) with the sweep -- either kind of scan blocks starting the other, and the existing Cancel button/endpoint works for both with no new code. - Added:
POST/GET /api/radio/bandscan/<id>/identify/{start,progress}(webapp/radio_settings/routes_bandscan.py) --startvalidates the submitted peak frequencies fall within the Bandscan band before dispatching; progress is its own Redis key (BANDSCAN_IDENTIFY_PROGRESS_PREFIX) so a still-visible sweep result is never overwritten by an identify pass that follows it. - Results render as a list below the chart (frequency, level, station name or "No RDS decoded") rather than on the canvas -- the peak-label collision/staggering logic tuned in 3.7.4 was sized for short frequency-only text, not an 8-character station name.
- 24 new tests across
tests/test_bandscan.py(early-stop-on-decode, full-dwell-on-no-decode, per-frequency demodulator isolation, frequency restore on every exit path, shared mute flag, separate progress key) and newtests/test_bandscan_routes.py(frequency validation, permission gate, command dispatch, progress shape) -- there was no existing route-level test file for the other bandscan endpoints either.
[3.8.0] - 2026-09-14 - Bandscan: mute audio during the sweep
Closes the "audio muting during the scan (a real side effect, noted rather than silently ignored)" item explicitly deferred out of Bandscan's original 3.7.0 changelog entry. Until now a sweep's rapid channel-hopping across the whole FM band streamed live to Icecast and the archiver -- roughly 30-45 seconds of static/noise on air for a routine diagnostic action.
- Added:
RedisChannels.BANDSCAN_ACTIVE_PREFIX(app_core/config/redis_config.py) -- a lightweight, short-TTL'd presence marker, separate from the existing (heavier, growing-JSON)BANDSCAN_PROGRESS_PREFIXkey, set and refreshed every channel bysdr_hardware_service.py's_run_bandscan_sweepand explicitly deleted (not just left to expire) once the sweep ends, however it ends. - Added:
services/demod/worker.py'sDemodWorkernow checks this flag (cached ~0.5s to avoid a Redis round-trip on every ~32ms audio chunk) and publishes silence instead of the real demodulated audio for the sweep's duration -- the demodulator itself keeps running normally on the real swept IQ so its internal DSP/RBDS state isn't torn down and rebuilt around the mute. - Added: a
_dead_air_suppressed()hook onAudioSourceAdapter(app_core/audio/ingest.py, defaultFalse), overridden byRedisSDRSourceAdapterto check the same flag. Without this, the deliberate silence fed by the mute above would itself cross the dead-air monitor's duration threshold and raise a false alarm (tower light/buzzer) for a routine, operator-initiated diagnostic scan --app_core/audio/silence.py'sSilenceMonitor.process()call is skipped (not reset) while suppressed, so a real outage that was already accruing before the scan resumes counting from where it left off once the scan ends. - 14 new tests across
tests/test_bandscan.py,tests/test_demod_service.py,tests/test_redis_audio_pipeline.py, andtests/test_audio_ingest.py-- the active flag is set while running and explicitly cleared on every sweep exit path (completion/cancellation/error); published audio is verified as real vs. all-zero silence depending on the flag; the mute check is cached, not per-chunk; the dead-air hook is verified suppressed/not-suppressed/fail-safe-on-Redis-error, cached, and actually skipsSilenceMonitor.process()when suppressed.
[3.7.4] - 2026-09-14 - Bandscan plot: fix a suppressed close peak and Y-axis label wrapping
Two more bugs reported immediately after 3.7.3 shipped, both from live use: a visually distinct peak sitting close (under ~0.5 MHz) to a taller one wasn't labelled, and the new Y-axis dBFS column wrapped its text onto two lines and overlapped the neighbouring gridline's own label.
- Fixed:
PEAK_MIN_PIXEL_GAP(the minimum on-screen distance between two kept peak labels) was 30px (~1.2 MHz), wide enough to suppress a real, visually distinct second peak -- its own local minimum/notch clearly separating it from its neighbour -- just because it sat under 1.2 MHz from a taller one. Lowered to 12px (~0.5 MHz), withbandscanRender()now staggering a label's vertical position instead of dropping it outright when two kept peaks are close enough for their text to still overlap. - Fixed: the new Y-axis column (added in 3.7.3) used the Bootstrap
smallclass, which resolves against the page's rem base and was wide enough to wrap "-19 dBFS" onto two lines inside the 44px-wide column -- since each label is positioned assuming one line, the wrapped second lines overlapped the neighbouring gridline's own label into unreadable overlapping text. Switched to an explicit 10px monospace font (matching the plot's own peak-label font) withwhite-space: nowrap, and widened the column to 54px. - No backend changes;
tests/test_bandscan.pyunaffected.
[3.7.3] - 2026-09-14 - Bandscan plot: fix peak detection and dBFS-label collisions
Two bugs reported immediately after 3.7.2 shipped: only 2 of the ~15 visually obvious peaks in a live scan got labelled, and the leftmost peak's label was drawn on top of (and unreadable against) the Y-axis dBFS text.
- Fixed peak detection:
bandscanFindPeaks()used to compare every bump against one global noise-floor number (the sweep's own 25th-percentile level). On a live scan a single wide, deep fade between two strong stations dragged that whole-band percentile far below the ambient level on the rest of the band, so the effective threshold ended up above most of the other genuinely-present bumps -- only the two loudest peaks in the whole sweep qualified. Prominence is now measured against each peak's own local minimum (+/- 10 channels, ~1 MHz), so a modest local bump is still labelled even while some other stretch of the band is much louder or quieter. - Fixed the Y-axis/peak-label collision: the dBFS scale is no longer drawn as text inside the canvas at all (where it could sit under the plotted line or a peak's own label). It now renders in a dedicated column beside the canvas (
panel.yAxisCol), matching how the X-axis ticks already sit outside the plot below it. - No backend changes;
tests/test_bandscan.pyunaffected.
[3.7.2] - 2026-09-14 - Bandscan plot: label detected peaks directly
- Added: the band plot now labels detected signal peaks in place, right next to the signal they belong to, instead of only labelling the shared X-axis ticks -- reading a station's frequency off the axis meant eyeballing an interpolation between two ticks that were often nowhere near it.
bandscanFindPeaks()finds local maxima at least 6 dB above the sweep's own 25th-percentile noise floor, picks the strongest first, and keeps a candidate only when it's at least 34 canvas pixels from every peak already kept (capped at 12 labels) -- so a single station's skirt doesn't get one label per bin and labels never overlap. - Axis ticks are unchanged and still shown for overall scale.
[3.7.1] - 2026-09-14 - Bandscan plot: axis labels and persistent results
Follow-up bug fix on the Bandscan sweep shipped in 3.7.0, reported immediately after use: the band plot had no readable Y axis and the finished result vanished almost as soon as the scan completed.
- Fixed: the band plot now draws horizontal dBFS gridlines with value labels, and the X axis shows several evenly-spaced round-MHz ticks (87.5/92/96/100/104/108 MHz) instead of only the two band edges -- previously there was no way to read a signal level or an arbitrary peak's frequency off the plot at all.
- Fixed: a finished scan's result no longer disappears within seconds. Root cause:
templates/admin/radio_diagnostics.html's SDR Diagnostics page fully re-renders#receiversContainer(and every.bandscan-containerinside it, which the template always rendersstyle="display:none;") on its normal 15s auto-refresh; the frontend was only re-populating that container for receivers still actively polling (bandscanActiveReceivers), so as soon as a scan finished and polling stopped, the very next 15s refresh silently re-hid the result with nothing to redraw it. The frontend now keeps the last-known progress payload per receiver (bandscanLastPayload) andbandscanRebuildActive()redraws a finished scan's plot from it whenever the periodic refresh recreates the container, so the result stays visible until a new scan is started. - No backend changes;
tests/test_bandscan.py(5 tests, unaffected) still passes.
[3.7.0] - 2026-09-14 - SDR Diagnostics: Bandscan (full FM-band sweep)
Final item from the post-brochure FM-analyzer follow-up, and the one genuinely different from everything else in the set: every prior addition (MPX spectrum, oscilloscopes, deviation histogram, modulation power, stereo balance) is a passive view of whatever a receiver is already doing. Bandscan is active -- it retunes the receiver across the whole FM broadcast band to survey what's on the air, the way the reference hardware's own "Bandscan" view does.
- Added: a "Bandscan" button on SDR Diagnostics (styled and risk-tiered like the existing "Restart Receiver" button) that sweeps 87.5-108.0 MHz in 100 kHz steps (206 channels, ~30-45 seconds measured live), measuring signal level at each stop and rendering a live-filling band plot. A confirmation dialog states up front that the receiver will not monitor for real alerts until the scan finishes -- this is a deliberate, disruptive action, not a passive one.
- Added:
bandscan_sweep/bandscan_cancelactions insdr_hardware_service.py, running the sweep on its own background thread rather than inline in the shared SDR command queue -- a full sweep (minutes) would otherwise block every other receiver's tune/restart/diagnostics commands for its whole duration, unlike the existingauto_gainsweep's already-accepted ~90s worst case. The receiver is always retuned back to its assigned frequency when the scan ends, whether it completes normally, is cancelled, or hits an error -- the database's assigned frequency is never touched, only the live tuner, mirroringtune_frequency's own raw retune call without its DB-persistence step. - Added:
GET/POST /api/radio/bandscan/<id>/{start,progress,cancel}(webapp/radio_settings/routes_bandscan.py) --startonly waits for a fast "did it start" acknowledgment, not the whole sweep; the frontend pollsprogress(a transient Redis key,RedisChannels.BANDSCAN_PROGRESS_PREFIX) separately on its own ~1.5s cadence. tests/test_bandscan.py: 5 new tests against the sweep loop directly -- the channel list matches the expected band/step math exactly, and the original frequency is restored in all three ways the sweep can end (normal completion, mid-sweep cancellation, mid-sweep exception).- Deliberately out of scope for this pass: RDS station labeling of detected peaks, audio muting during the scan (a real side effect, noted rather than silently ignored), and custom band/step configuration.
[3.6.0] - 2026-09-13 - SDR Diagnostics: modulation power, stereo balance, deviation histogram, MPX/audio oscilloscopes
Tier 1 of the post-brochure FM-analyzer follow-up: after reviewing the PIRA P275's full brochure and FM Scope software manual, five measurements were cheap enough to add in one pass because they only needed to plot or bin data the demod pipeline already computes every chunk (or one more same-cost RMS pass alongside it) -- no new capture mechanism, no new DSP concept.
- Added:
modulation_power_dbrandstereo_balance_dbonDemodulatorStatus(app_core/radio/demod/fm.py/types.py), computed every chunk exactly like the existingpeak_deviation_hz/pilot_injection_hz/rds_injection_hzfields. Modulation power is RMS power of the composite MPX signal in dBr, referenced to a 75 kHz-full-scale sine (0 dBr = 100% modulation, the same convention the MPX spectrum's FFT already documents). Stereo balance is20*log10(RMS(R)/RMS(L)), computed in the same kHz-deviation domain as the MPX oscilloscope below rather than normalized playback-audio loudness. Both thread through the existing Signal Quality history pipeline (redis_sdr_adapter.py->AudioSourceMetrics->routes_signal_quality.py-> two new Chart.js charts) with zero new infrastructure -- the same 4-hop pattern proven three times already in 3.4.0. - Added: a frequency-deviation histogram (
GET /api/audio/sources/<name>/signal_quality/deviation_histogram), binningpeak_deviation_hzvalues already sitting in the Signal Quality history table over the selected 1h/6h/24h window, with a% of samples above 75 kHzcompliance readout. Unlike the reference hardware (which needs a manual "Acquire" pass over a live N-minute capture), this is available instantly over any window already being logged continuously -- no separate acquisition step. - Added: two new oscilloscope traces bundled into the already-shipped MPX Scope's existing payload/route (
demod:mpx_spectrum:<id>,/api/radio/mpx_spectrum/<id>) rather than new keys -- an "MPX oscilloscope" (the raw composite multiplex waveform, not the FFT the panel already showed) and an "audio oscilloscope" (decoded L/R channels, only present once stereo is locked). Both captured on the same ~2 Hz gate the spectrum FFT already uses. tests/test_fm_deviation_injection_metrics.py: 5 new tests -- modulation power reads ~0 dBr at exactly the 75 kHz reference and ~-6 dBr at half scale, floors safely for a silent carrier, stereo balance matches a known 2:1 L/R ratio (~+6 dB) and defaults to 0.0 when mono.tests/test_mpx_spectrum.py: 4 new tests -- the MPX waveform is captured and length-capped alongside the spectrum, the audio-scope trace is present exactly when stereo is locked and absent (not stale) otherwise.tests/test_signal_quality_history_api.py: 6 new tests -- the two new history fields round-trip, the histogram bins known synthetic samples correctly, computespct_above_limitcorrectly, handles an empty/unrelated window without erroring, and clamps an out-of-range reading into the last bin instead of raising.
[3.5.0] - 2026-09-13 - SDR Diagnostics: MPX (demodulated baseband) spectrum scope
Final piece of the FM-broadcast-analyzer feature set started in 3.3.0: a real demodulated-baseband spectrum showing the 19 kHz stereo pilot, 38 kHz stereo subcarrier, and 57 kHz RDS subcarrier as visible spectral features -- the actual "analyzer screen" look from the PIRA P275 reference screenshot that kicked off this whole set. The existing Spectrum Scope/Live Waterfall show the RF spectrum around the tuned carrier (pre-demodulation IQ); this is a second, genuinely new spectrum computed from the demodulator's own multiplex output, which cannot show this picture.
- Added:
app_core/radio/spectrum.py--compute_spectrum(RF, complex IQ, moved here verbatim fromsdr_hardware_service.pywith no behavior change) plus a newcompute_real_spectrum(MPX, real-valued multiplex vianumpy.fft.rfft, one-sided). - Added:
FMDemodulator.demodulate()(app_core/radio/demod/fm.py) computes the MPX spectrum every ~0.5s (2 Hz, rate-limited the same wayDemodWorker._publish()already throttles its status key) from the raw discriminator output and exposes it vialast_mpx_spectrum/last_mpx_spectrum_fresh. - Added:
services/demod/worker.pypublishes a fresh MPX spectrum to a new dedicated Redis key (RedisChannels.MPX_SPECTRUM_PREFIX, 5s TTL) -- its own key, not folded into the existing status channel, matching how RF spectrum and audio already have their own channels distinct from status. - Added:
GET /api/radio/mpx_spectrum/<id>(webapp/radio_settings/routes_mpx_spectrum.py) -- returnsstatus: "unavailable"(200) for AM/IQ-passthrough receivers or before the demod service has published one yet, rather than a 404/500. - Added: "MPX Scope" toggle on the SDR Diagnostics page (Admin -> Radio -> Diagnostics), next to the existing Spectrum Scope button -- a fixed 0-100 kHz baseband view with dashed marker lines at 19/38/57 kHz, polled independently at ~2 Hz. Deliberately kept on its own simple MutationObserver-based refresh (mirroring
radio_diagnostics_trends.js) rather than integrated into the RF Spectrum Scope/Live Waterfall's more complex shared zoom/pan/live-DOM-node-preservation system, to avoid introducing risk into that already-proven code. tests/test_mpx_spectrum.py: 5 new tests -- synthetic multiplex tones at exactly 19 kHz and 57 kHz land within one FFT bin of the expected peaks, one-sided output length, the too-few-samples and 0-1 bounding edge cases, and the 0.5s rate-limit gate.- Live-verified on
sdr-wbks(a real over-the-air FM station): the MPX Scope shows a sharp, correctly-positioned pilot spike right at the 19 kHz marker and visible elevated structure at the 38k/57k subcarrier markers. - Also did a full authenticated click-through of the existing RF Spectrum Scope's zoom (button zoom in/out, mouse-wheel zoom, drag-to-pan, double-click reset, and persistence through the page's own ~1s auto-refresh, including a drag gesture spanning a live refresh) after a user report that zoom "doesn't work correctly" -- every interaction measured out exactly per the documented design (anchor-preserving zoom, precise pan-fraction math) with no reproducible defect found.
[3.4.0] - 2026-09-12 - SDR Diagnostics: peak deviation and pilot/RDS injection level (kHz)
Third and final planned step (after Signal Quality history in 3.3.0 and the button-audit fixes in 3.3.1) toward FM-broadcast-analyzer-style monitoring on the existing SDR stack, following a PIRA P275 hardware analyzer screenshot the user shared as reference. The one genuinely new piece of DSP in the set -- unlike stereo pilot strength / click rate, which only needed wiring an already-computed value through, these three are new measurements.
- Added:
peak_deviation_hz,pilot_injection_hz,rds_injection_hzonDemodulatorStatus(app_core/radio/demod/types.py), computed inFMDemodulator.demodulate()(app_core/radio/demod/fm.py). All three are the inverse of the existing_audio_gainscale factor (raw discriminator output in radians/sample timessample_rate / (2*pi)is real Hz) -- not new calibration, just exposing a conversion the code already implicitly does for audio normalization. Peak deviation and pilot injection reuse values (multiplex,pilot_rms) already computed every chunk regardless; RDS injection needed one new FIR bandpass filter (55.6-58.4 kHz), built only when RBDS decoding is enabled so receivers that don't use RDS pay nothing extra. - Threaded through the same pattern already proven for
click_rate(PR #2621):app_core/audio/redis_sdr_adapter.py's metadata dict,webapp/admin/audio_ingest/routes_signal_quality.py's existing history endpoint (no new route), three more Chart.js charts in the already-shipped Signal Quality modal. - Applied the axis-headroom lesson from 3.3.1: the two new percentage-like metrics use plain
beginAtZero: truewith nomax/suggestedMaxat all, rather than fighting Chart.js's auto-scale heuristics again -- real audio content makes these genuinely vary (confirmed live: peak deviation swung 80-92 kHz across a few seconds), so there's no constant-value-at-a-hard-ceiling failure mode to guard against here the way there was for a rock-solid 100% pilot lock. tests/test_fm_deviation_injection_metrics.py: 6 new tests synthesizing FM signals at known deviations (a pure tone at exactly 50 kHz peak deviation, a pilot at exactly 6.75 kHz peak, a 57 kHz tone at exactly 3.5 kHz peak) and asserting the computed values land close to the expected Hz/RMS values -- this is the one part of the feature where "the field threads through end to end" isn't sufficient coverage on its own, since the math itself is new.- Live-verified on
sdr-wbks(a real over-the-air FM station, not a synthetic signal): peak deviation ~80-92 kHz, pilot injection ~4.2-4.3 kHz, RDS injection ~3.3-3.4 kHz -- all in plausible real-world ranges (FCC full-scale reference is +/-75 kHz; typical healthy pilot/RDS injection is ~2-7.5 kHz depending on station configuration). tests/test_signal_quality_history_api.py: extended with a test covering the three new fields in the history response.
[3.3.1] - 2026-09-12 - SDR Diagnostics: fix Snapshot Waterfall crash and two invisible-line chart bugs
Found by systematically clicking through every control on the SDR Diagnostics page (logged in as a real admin) rather than trusting that "no console error" meant "actually works" -- three real, independent bugs surfaced this way.
- Fixed: "Snapshot Waterfall" returned a 500 on every click --
AttributeError: 'NpzFile' object has no attribute 'size'.routes_diagnostics_waterfall.pycallednp.load(path).sizedirectly, butsdr_hardware_service.pyactually writes captures as.npz(key'iq');np.load()on a.npzreturns a lazyNpzFilecontainer, not an array.routes_diagnostics_analyze.pyalready handled this correctly (checks the extension, opens the archive, extractsarchive["iq"]) --routes_diagnostics_waterfall.pyhad just never been exercised with this in mind. Fixed identically; also corrected the route's docstring, which still said.npy. - Fixed: the "Lock %" chart in the per-receiver Historical Trends panel (
static/js/radio_diagnostics_trends.js) rendered its line invisible for a receiver locked 100% of the window -- the common, healthy case. Same root cause as the Signal Quality pilot-strength chart fixed in 3.3.0, but worse: with the pilot chart,suggestedMaxhappened to work because that data had slight variance (99-100%); here the data is a literal constant 100.0 with zero variance, sosuggestedMax: 100computesmax(100, 100) = 100-- no headroom at all, confirmed live via the actual Chart.js instance. Re-fixed both charts (Lock % and pilot strength) with a hardmax: 105, which holds regardless of data variance. - Fixed: the "Sample Rate Ratio" chart auto-scaled to a nonsensical -1 to 1.5 range for a constant, real 0.25 ratio -- Chart.js has no sensible default for a zero-variance series with no configured bounds.
sample_rate_ratio(effective/configured) is always in [0, 1] by construction; added{ min: 0, suggestedMax: 1 }. tests/test_spectrum_frequency_axis.py: two new regression tests -- one reproducing the exact.npz/NpzFile.sizebug and confirming the fixed extraction works, one guarding the source against a revert to the barenp.load(path).sizepattern.
[3.3.0] - 2026-09-12 - SDR Diagnostics: Signal Quality history (stereo pilot + click rate)
- Added: a "Signal Quality" history modal on the SDR Diagnostics page (
/admin/radio/diagnostics), next to the existing RBDS History button, charting stereo pilot lock strength and discriminator click rate (a multipath/impulse-noise indicator) over the last hour/6h/24h. First step toward FM-broadcast-analyzer-style monitoring (PIRA P275-style) built on the existing SDR receiver stack rather than dedicated hardware. app_core/audio/redis_sdr_adapter.py:click_rate/click_suppression_enabled(computed in the demodulator,app_core/radio/demod/fm.py) are now written into the audio source's metadata dict, mirroring the existingrf_signal_strengthpattern.stereo_pilot_strengthwas already flowing through; this was the one missing field. Nothing else needed for persistence --eas_monitoring_service.py's once/sec metrics snapshotter already dumps the whole metadata dict to theaudio_source_metricstable.- New endpoint
GET /api/audio/sources/<name>/signal_quality/history(webapp/admin/audio_ingest/routes_signal_quality.py), copyingroutes_rbds.py's query/downsample pattern against the same table. Deliberately excludes RF signal strength: that's already charted from a different, hardware-level source in this page's existing "Historical Trends" panel (static/js/radio_diagnostics_trends.js, backed byapp_core/radio/trends.py) -- a second, differently-sourced "signal strength" series next to it would invite "why don't these agree" confusion, not add information. tests/test_signal_quality_history_api.py: new tests (5) covering point extraction, the deliberate RF-signal-strength exclusion, skip-on-neither-field, unknown source/bad query param handling, and downsampling -- mirrorstests/test_rbds_history_api.py's existing coverage shape for the sibling endpoint.
[3.2.6] - 2026-09-12 - Fix AudioArchiver dropping chunks on every segment flush
- Fixed:
AudioArchiverforsdr-wbks(and every other archived source) was dropping hundreds to thousands of audio chunks on the source'sBroadcastQueuesubscription around every segment boundary ("consuming slower than real time"), observed live climbing past 92,000 total dropped chunks. Root cause:_flush_segment()ran inline in_archive_loop(), and encoding a full segment via FFmpeg (measured live: ~14s wall time for a 600s 48kHz-stereo segment on this Raspberry Pi) blocked the loop from callingaudio_queue.get_nowait()for that entire window -- so on every single flush, the upstreamBroadcastQueuefilled and started dropping chunks for this subscriber only. - Fixed in
app_core/audio/archiver.py:_start_flush_async()now snapshots and resets the in-memory chunk buffer synchronously, then hands the actual concatenate/encode/write/prune work to a background thread -- the archive loop keeps draining the queue in real time regardless of how long encoding takes.stop()now waits (bounded, 60s) for the loop thread, which itself waits for the final flush thread, so a shutdown can't cut off the last segment mid-write. tests/test_audio_archiver_async_flush.py: new regression test asserting the chunk buffer is reset (and can accept new audio) while a simulated slow encode is still running in the background, plus a no-op check when there's nothing to flush. Also manually end-to-end verified (realBroadcastQueue+ real WAV encode) that segments still write correctly with intact audio and accurate stats.
[3.2.5] - 2026-09-12 - Fix stereo SDR audio doubling: slow Icecast playback and broken SAME/RMT decode
- Fixed: The live Icecast relay for stereo SDR receivers (e.g.
sdr-wbks.mp3, WFM+RBDS) played back audibly slow -- deep, dragging pitch -- confirmed by measuring the running FFmpeg encoder's actual stdin byte rate (/proc/<pid>/io) against real elapsed time: it was reading raw PCM at ~1.95x the byte rate-ar 48000 -ac 2calls for in real time, whileservices/demod/worker.py's Redis publish rate was independently verified to be exact real-time (bisected by subscribing to thedemod:audio:<id>channel directly and comparing decoded sample count to wall-clock elapsed time). - Root cause:
services/demod/worker.pypublishes stereo audio asnp.column_stack((left, right))-- a(frames, 2)array -- which.tobytes()flattens to interleavedL,R,L,Rfloats on the wire; the wire format carries no shape metadata, soRedisSDRSourceAdapter._unpack_audio_envelope()could only hand back a flat 1-D array.IcecastOutputStreamer._samples_to_pcm_bytes()(app_core/audio/icecast_output.py) sawndim == 1and treated every one of the resulting 2x-as-many flat values as an independent mono sample, then upmixed each to fake stereo -- doubling the frame count handed to FFmpeg per real second of audio and destroying the true L/R image in the process. FFmpeg still assumedconfig.sample_rateframes/sec, so the encoded stream represented ~2x the declared audio-duration per real second, which plays back at roughly half speed. - Same root cause also corrupted SAME/EAS decoding on any stereo-enabled SDR receiver:
AudioSourceAdapter._resample_for_eas()(app_core/audio/ingest.py) only downmixes stereo to mono when it receives a(frames, 2)array (if audio_chunk.ndim == 2: audio_chunk = audio_chunk.mean(axis=1)). Since the flat, mis-shaped array was alwaysndim == 1, that downmix never ran -- the interleaved L,R,L,R values were fed straight into the 48kHz-to-16kHz EAS resampler as if they were sequential mono samples, both scrambling the channels together and carrying the same ~2x time-base error into the SAME decoder's input. SAME/RMT header detection depends on precise AFSK tone frequencies at an exact 520.83 baud rate, so this would reliably have prevented the decoder from locking onto a real SAME preamble (Required Monthly Test or otherwise) on this receiver. - Fixed in
app_core/audio/redis_sdr_adapter.py:_redis_subscriber_loop()now reshapes the flat array to(-1, config.channels)immediately after unpacking, using the adapter's own known channel count, before it ever reaches the broadcast queue -- fixing it for every consumer (Icecast, the archiver, EAS monitoring's SAME decoder), not just the one mount that surfaced it. tests/test_redis_audio_pipeline.py::test_subscriber_loop_reshapes_stereo_audio_envelope: regression test publishing a real(frames, 2)stereo envelope (matching whatfm.py's demodulator actually returns) and asserting the dequeued chunk keeps its(frames, 2)shape rather than coming back flat.
[3.2.4] - 2026-09-12 - Fix CAP poller hanging for minutes per cycle on broken IPv6
- Fixed:
poller/cap_poller.pypoll cycles were taking minutes instead of seconds. Root cause: this host's router advertises an IPv6 default route via RA, but has no working IPv6 upstream (SYN packets to public IPv6 addresses get no reply, confirmed viass -tnpshowing a stuckSYN-SENTsocket andping -6to a public host timing out).apps.fema.govandapi.weather.govare dual-stack, so Python'srequests/urllib3resolved and attempted each unreachable IPv6 address in turn, burning the full per-connection timeout before ever falling back to the working IPv4 address. Forced IPv4-only DNS resolution for the poller process viaurllib3.util.connection.allowed_gai_family, which avoids the IPv6 attempts entirely regardless of what the network advertises. - Investigated a companion report of "wrong rtlsdr sample rate" causing slowness: the active
wbksreceiver is configured for 1,024,000 Hz (a standard, driver-recommended RTL-SDR rate), decimated by design to 256,000 Hz effective for FM+RBDS processing (app_core/radio/decimation.py).eas-station-sdr.servicelogs show steady, drop-free throughput at the configured rate with buffer utilization under 2% -- no evidence of a sample-rate-driven slowdown in the capture pipeline itself. Not changed pending clarification of what symptom was observed.
[3.2.3] - 2026-09-11 - Close out the two items flagged after Phase 4
- Investigated, not a bug:
data_management.html's generic boundary uploader offers acountytype alongside electric/fire/school/etc; checked whether it duplicates/admin/county_boundaries. It writes toBoundary(tableboundaries, arbitrarytypestring, map-overlay/intersection use) while/admin/county_boundarieswrites toUSCountyBoundary(tableus_county_boundaries, FIPS/GEOID-keyed, Census TIGER/Line data for SAME-code alert matching) -- different tables, different purpose. Documented indocs/roadmap/SITE_REORGANIZATION.md; no code change. - Fixed: CodeQL's
py/command-line-injectionalert on the System Upgrade ref handling (webapp/admin/maintenance/routes_operations.py). The regex-validate-then-re-derive pattern introduced for this in v3.2.0 didn't satisfy CodeQL when the validation lived behind a shared helper function (_validate_git_ref) -- its sanitizer recognition doesn't reliably trace a value re-derived inside a called function back to the caller. Inlined the same_GIT_REF_PATTERN.match()+match.group(0)re-derivation directly into both call sites (check_for_upgrade,run_one_click_upgrade); removed the now-unused helper. tests/test_upgrade_progress.py: added regression coverage for both call sites rejecting invalid refs (leading-dash flag injection, range expressions, shell metacharacters) before any subprocess call is made, plus new coverage forrun_one_click_upgradegenerally (previously untested) -- replaces the deletedtests/test_git_ref_validation.py, which tested the removed helper directly.
[3.2.2] - 2026-09-11 - Site reorganization Phase 4: remove duplicate Zone Catalog tab from Data Management
Completes a further pass of docs/roadmap/SITE_REORGANIZATION.md. data_management.html's "Zone Catalog" tab called the exact same /admin/zones/* endpoints (webapp/admin/zones.py) as the pre-existing, separately nav-registered /admin/zones page -- confirmed by grepping for every route string both templates' JS calls and finding exactly one backend implementation of each. An earlier extraction's docstring had claimed this was "confirmed not a duplicate" -- true then or not, it didn't hold up under inspection today.
Fixed
templates/admin/data_management.html: removed the duplicate Zone Catalog tab and its now-unusedzone-catalog.jsinclude; added a link to the pre-existing/admin/zonespage instead. The Boundaries + Manage tabs (a genuinely cohesive upload-then-browse/delete workflow) are untouched.webapp/admin/dashboard.py: corrected thedata_management_page()docstring's stale "confirmed not a duplicate" claim.webapp/navigation/registry_settings.py: updated the Data ManagementNavItemdescription to drop the zone-catalog mention.- Noted but not fixed (see the roadmap doc):
data_management.html's generic boundary uploader also offers acountytype, which may or may not overlap with/admin/county_boundaries' dedicated loader -- unconfirmed, flagged for later. tests/test_data_management_zone_dedup.py(2 tests): the tab and dead references are gone; the page links to/admin/zones; the remaining tabs are untouched.
[3.2.1] - 2026-09-11 - Site reorganization Phase 3: merge Custom Screens documentation into Help, fix a dead link
Completes docs/roadmap/SITE_REORGANIZATION.md's original scope. templates/screens.html's "Documentation" tab (template variables, data sources, LED/VFD JSON examples) was reference material for a different audience than the operational screen-management UI it shared a page with -- and it linked to /static/docs/guides/CUSTOM_DISPLAY_SCREENS.md, a file that has never existed in this repo. help.html already had a higher-level "Custom Display Screens" section with the same dead link.
Fixed
templates/help.html: the "Custom Display Screens" accordion section now includes the Template Variables table, Available Data Sources list, and LED/VFD JSON examples that used to live only on/screens' Documentation tab. The deadCUSTOM_DISPLAY_SCREENS.mdlink is removed from both places it appeared (there was never a corresponding file to fix instead).templates/screens.html: removed the redundant "Documentation" tab/pane and its now-unused.doc-cardCSS; added a "Documentation" button in the page header linking to/help.tests/test_screens_help_docs_merge.py(2 tests):/screensno longer embeds the removed tab or the dead link;/helphas the merged reference content and the dead link is gone there too.
[3.2.0] - 2026-09-11 - Site reorganization Phase 2: System Upgrade gets its own page, duplicate boundary-recalc card removed
Continues docs/roadmap/SITE_REORGANIZATION.md. templates/admin/operations.html's "Alert Boundary Coverage" card turned out to be a pure duplicate of functionality already on the existing, already-linked /admin/intersections page (same two endpoints, plus that page has more tools besides) — removed rather than split. System Upgrade is on the same backend blueprint as DB Health/Backup but is a much bigger, riskier operation with its own live progress/log-streaming UI, confusing enough to deserve full-page attention.
Changed
- New page System Upgrade (
/admin/system-upgrade,maintenance.system_upgrade_page), with a newNavItemunder Settings → Data & Storage.operations.htmllinks to it instead of embedding the upgrade wizard/progress UI. operations.html's "Alert Boundary Coverage" card now links to/admin/intersectionsinstead of duplicating its recalculation buttons.operations.htmlkeeps DB Health and the quick Backup trigger. (That Backup trigger is itself a near-duplicate of/admin/backups' own "Create New Backup" section, via a different API — flagged in the roadmap doc as a possible future consolidation, not addressed in this phase.)tests/test_system_upgrade_page.py(3 tests): permission gate, the new page renders the upgrade UI, the main page no longer embeds either removed panel.
[3.1.0] - 2026-09-11 - Site reorganization Phase 1: split Bad Actor Blocklist out of Application Settings
Continues the initiative from docs/roadmap/SITE_REORGANIZATION.md. The Bad Actor Blocklist panel is a fully independent subsystem -- own nginx geo-map config files, own systemd refresh timer, own JSON API (webapp/admin/bad_actors.py) -- that only shared a page with logging/storage/branding/password-policy settings for lack of a better home. None of its inputs were even part of the settings <form> it visually lived inside.
Added
- New page Settings → Bad Actor Blocklist (
/admin/security/bad-actors/), on the existingbad_actorsblueprint.templates/admin/application_settings.htmllinks out to it instead of embedding the panel; Project Honeypot (a genuine two-field settings toggle saved through the normal settings form, not an independent subsystem) stays on the main page. - New
NavIteminregistry_settings.py's "Security & Access" group. tests/test_bad_actor_blocklist_page.py(3 tests): permission gate, the new page renders the panel, the main page no longer does.
[3.0.1] - 2026-09-11 - Fix stale "not a public subscription service" wording on /sms-compliance
Fixed
templates/sms_compliance.html: the top disclaimer still read "Messages are sent only to phone numbers explicitly configured by the system administrator... This is not a public subscription service" -- stale from before the self-serve/sms-opt-indouble opt-in flow shipped (2.232.0), and directly contradicted by Section 2 further down the same page, which describes that public page and its QR code for signage. A carrier/Twilio reviewer reading the filed policy URL and then clicking through to/sms-opt-inwould see the mismatch immediately. Reworded to describe both opt-in paths accurately.
[3.0.0] - 2026-09-11 - Site reorganization: split SMS out of Notifications
The first landing of a broader initiative to split pages that bundle multiple unrelated features into their own dedicated pages -- see docs/roadmap/SITE_REORGANIZATION.md for the full scope and rationale. templates/admin/notifications.html had grown to 1,029 lines (email + SMS + SNMP + Postfix, plus SMS's own Consent Records and Message Log audit trails); SMS is a fully separate feature from Email/SNMP and only shared the page for lack of a better home.
Changed
- Breaking (URL addition, not a removal): SMS notification settings, the opt-in QR/link callout, Consent Records, and the SMS Message Log moved from
/admin/notifications/to a new page,/admin/notifications/sms(Settings → SMS Notifications)./admin/notifications/still exists and works -- it now shows Email + SNMP + Postfix only. Each page links to the other. webapp/admin/notifications.py: newsms_settings()view andupdate_sms_settings()POST route (/admin/notifications/sms/update), split out ofnotification_settings()/update_notification_settings(). Kept as genuinely separate routes rather than one shared form-with-defaults handler -- that handler blanks out any field it doesn't see in the posted form, so a page that only submits SMS fields would have silently cleared Email/SNMP settings (and vice versa) had the routes stayed shared.webapp/navigation/registry_settings.py: new "SMS Notifications"NavItemalongside the existing "Notifications" item.docs/guides/SMS_OPT_IN.md,docs/policies/SMS_MESSAGING.md,docs/guides/notifications.md,templates/sms_compliance.html: updated to reference the new page location.docs/roadmap/SITE_REORGANIZATION.md(new): the prioritized plan for splitting the next few pages that bundle unrelated features -- explicitly scoped to not duplicatedocs/development/LARGE_FILE_REFACTOR_PLAN.md's already-tracked backend-module-split and frontend-JS-extraction work.tests/test_sms_settings_page.py(new, 4 tests): the two update routes stay disjoint, the new page renders SMS content, the main page no longer does.
[2.233.0] - 2026-09-11 - Searchable SMS message log
An operator asked whether there's a record of SMS messages actually sent, searchable by
number. There wasn't -- Twilio sends only ever produced a logger.info() line, so the
only way to find "did we text this number" was to grep journalctl for a service you'd
have to guess.
Added
app_core/_models_sms_log.py(SmsMessageLog, migration20260911_add_sms_message_log): one row per outbound SMS send attempt, recorded right next to each Twilio call inapp_core/notifications/sms.py(not at the call sites) so every current and future send path is covered automatically. Records the recipient number, message type (alert/verification/test), event code (for alerts), success/failure, the Twilio SID, and the error on failure. Recording is best-effort and never raises -- a logging hiccup can't be mistaken for a send failure. Verification codes themselves are never stored, only the outcome.- Settings → Notifications → SMS Message Log: the last 200 send attempts, most recent first, with a search box that filters by recipient phone number (
?sms_log_search=). tests/test_sms_message_log.py(7 tests): logging never raises even if the DB write fails, each of the three send paths (alert/verification/test) logs with the right type and outcome, and the admin search filters by number.
[2.232.1] - 2026-09-11 - Make the SMS opt-in page discoverable, and instance-safe
The self-serve opt-in page from 2.232.0 was live but not linked from anywhere a visitor or a Twilio reviewer would naturally look, and had no way to hand someone a printable/scannable link. An operator confirmed they run more than one EAS Station deployment, so anything added here has to derive its URL from the actual request, never a hardcoded host.
Added
- Settings → Notifications: a QR code (
/admin/notifications/sms-optin-qr.png, gated behindsystem.configure) next to the existing "share this link" callout, for signage or printed material. Generated fresh per request fromurl_for(..., _external=True)-- never a fixed hostname -- so each deployment's QR code always points at itself. /sms-compliancenow links directly to/sms-opt-inin its Opt-In/Consent Disclosure section (instead of only describing the admin-added path), and renders the consent disclosure language from the sameCONSENT_TEXTconstant/sms-opt-initself uses, so the two pages can't drift out of sync.tests/test_sms_optin_qr.py(3 tests): permission gate, and that the URL encoded into the QR reflects the requesting host -- proving two different instances get two different, correct codes.
[2.232.0] - 2026-09-11 - Public double opt-in for SMS alerts
A carrier/Twilio A2P 10DLC campaign review needs a verifiable opt-in flow to point at. The previous design — an administrator adds a phone number and attests consent was obtained elsewhere (verbally, on paper) — gave a reviewer nothing to click through.
Added
- New public page
/sms-opt-in: a visitor enters their own phone number, agrees to explicit TCPA-style consent language, and confirms by entering a one-time code texted to that number (double opt-in). Only then is the number added to the live SMS recipient list. app_core/_models_sms_optin.py(SmsOptInRequest, migration20260911_add_sms_opt_in_requests): one row per opt-in attempt — a verbatim snapshot of the consent text shown, the submitter's IP, and a nullableverified_atthat's the actual evidence of confirmed consent.- Settings → Notifications → Consent Records: an admin-facing audit table of every verified sign-up, plus a link to share the opt-in page.
- Abuse protection: rate-limited per IP (reusing
/login'sLoginRateLimiter) and per phone number (a 60-second resend cooldown, so a bystander can't be used to spam a number they don't control), a 5-attempt cap on wrong confirmation codes, and hashed (never plaintext) codes at rest. docs/guides/SMS_OPT_IN.md, anddocs/policies/SMS_MESSAGING.mdupdated to describe both opt-in paths (self-serve and the legacy admin-added one, which still exists for cases the self-serve flow can't cover).tests/test_sms_optin.py(14 tests): consent/phone validation, already-subscribed short-circuit, IP and per-phone rate limiting, and the confirm step's wrong-code/expired-code/lockout paths.
[2.231.1] - 2026-09-11 - Rate-limit the MFA verification step, no-store the enrollment QR code
A follow-up to the pgweb pentest finding: while reviewing MFA (prompted by "anything else we can improve for 2FA"), found the login-time TOTP/backup-code check had no rate limiting at all, unlike the password step right before it.
Fixed
webapp/admin/auth.py:/mfa/verify(the code entry screen after a correct password) had no rate limiting --/login's own lockout only covers the password step. An attacker who already had valid credentials for an MFA-enabled account (phished, leaked, stuffed) could try unlimited TOTP/backup-code guesses against this endpoint. Now reuses the sameLoginRateLimiterclass/loginalready uses (5 attempts / 15-minute lockout), under a separatemfa:-prefixed bucket per IP so MFA guesses and password guesses don't share or exhaust each other's attempt budget.webapp/routes_security.py: the MFA enrollment QR code image (/security/mfa/enroll/qr) had no cache headers. The image encodes the TOTP secret in theotpauth://URI its pixels represent -- withoutCache-Control: no-store, an intermediate proxy or the browser's disk cache could persist a copy of the secret past the enrollment session.- New
tests/test_mfa_verify_rate_limit.py: pins down the lockout threshold, that a locked-out request is rejected without even checking the code, that a successful verification clears the bucket, and that the MFA bucket is separate from the password-login bucket.
[2.231.0] - 2026-09-11 - Authenticated access gate for the pgweb database browser
A pentest against a live deployment (following the security-audit pass in 2.230.0) found pgweb -- an optional, operator-installed third-party PostgreSQL browser with no authentication of its own -- listening on 0.0.0.0:8081, with a firewall rule allowing the entire LAN subnet in. Anyone on that LAN got full, unauthenticated read/write SQL access to the production database, including administrator accounts; the nav registry even linked directly to the raw port with a description acknowledging the risk rather than closing it.
Added
config/nginx-eas-station.conf: newlisten 8081server block that proxies to pgweb only after anauth_requestsubrequest confirms the caller has a logged-in session with thesystem.configurepermission -- the same gate this app's other highest-sensitivity admin actions (e.g. downloading the TLS private key) already use. A denied request is redirected to/logininstead of reaching pgweb.app.py:/api/internal/pgweb-auth-check, the endpoint thatauth_requestsubrequest calls. Deliberately hand-written rather than using@require_permission-- that decorator's denial path redirects for a non-JSON request instead of returning a bare status, and nginx'sauth_requestmodule treats anything other than 2xx/401/403 as an upstream error (producing a 500 for the real client), not a denial.bin/eas-station-pgweb-launch.sh,systemd/eas-station-pgweb.service: corrected, repository-tracked versions of this box's ad-hoc setup, binding pgweb to127.0.0.1only (an internal port nginx proxies to) instead of0.0.0.0.- Settings → Data & Storage → Database Browser (pgweb) (
webapp/admin/database_browser.py,/admin/database-browser/): shows whether pgweb is installed/running and links to the authenticated port. Replaces the nav registry's previous raw, hardcoded-IP link to the unauthenticated port directly. docs/guides/DATABASE_BROWSER.md: setup, removal, and the access-control design above.tests/test_database_browser.py: pins down the exact status codes nginx'sauth_requestcontract needs (401 unauthenticated, 403 authenticated-without-permission, 200 authenticated-with-permission -- never a redirect) and the status page's rendering for both installed states.
Why this wasn't caught by the earlier audit
The five-area security audit that produced 2.230.0 covered this application's own code; pgweb is a third-party binary an operator installs outside that code entirely; nothing in the codebase's automated checks (tests, CodeQL, dependency scanning) has visibility into a manually-run process's bind address or an ad-hoc firewall rule. Found only by actually enumerating the live host's listening ports during a hands-on pentest of the deployed system.
[2.230.0] - 2026-09-11 - Security audit fixes: TLS key exposure, WebSocket auth, stored XSS, broken access control
A full security audit (injection/command execution, authN/authZ, file handling/SSRF, secrets/config, dependencies/shell scripts) turned up the issues below. Ranked by severity; deferred items are noted with why.
Fixed — Critical
config/sudoers-eas-station,install.sh,update.sh,webapp/admin/certbot/paths.py: the certbot working directory (certbot_data/) waschmod -R 777'd, making the TLS private key world-readable and world-writable to any local user/process. Now755for the tree (still gives the web app read+traverse for its.exists()/directory-listing/openssl calls, none of which ever read a private key's bytes) plusprivkey*.pemlocked to600(root-only) specifically -- nothing in the codebase reads a key's content as theeas-stationuser, and nginx's master process reads it as root anyway. Ownership staysroot:rootunchanged, preserving the existing AppArmor renewal-compatibility fix.
Fixed — High
app.py: the Socket.IO server had no connect-time authentication and defaultedcors_allowed_originsto'*'when unset -- any website (or any non-browser HTTP client, since CORS is browser-enforced only) could open a WebSocket to this server and receive live system logs, GPIO/audio/broadcast status, and alert summaries with zero credentials. Added aconnecthandler requiring a valid session, and stopped forcing the'*'CORS fallback.templates/alert_detail.html,templates/audio_detail.html,templates/manual_eas_print.html: CAP alertdescription/instruction/message_textrendered through|safewith no HTML escaping -- a spoofed/compromised NOAA/IPAWS feed could plant a stored-XSS payload that executes in any viewer's browser. Now escaped (|e) before the newline-to-HTML formatting that legitimately needs|safe.webapp/admin/dashboard.py:/api/admin/sessions(GET) had no permission decorator while its sibling endpoints (terminate/bulk-terminate) both requiresystem.manage_users-- any authenticated user of any role could list every admin's username, IP, and session activity. Now requiressystem.manage_users, matching its siblings.webapp/admin/environment.py,templates/admin/environment.html:/admin/environment/download-ssl-keyrequired onlysystem.view_config(the same permission as read-only settings pages) instead ofsystem.configure-- a read-only "viewer" role could exfiltrate the TLS private key. Escalated tosystem.configure; the download button is now hidden from accounts without it.
Fixed — Medium
webapp/admin/maintenance/routes_operations.py: theref/checkoutrequest parameters reachedgit fetch/git checkoutunvalidated -- a value like--upload-pack=<cmd>is a known git-argument-injection primitive. Both now validated against a strict branch/tag character-class before use.app_core/radio/demod/types.py,services/demod/worker.py,app_core/audio/redis_sdr_adapter.py: the demod worker's cross-process status handoff usedpickle.dumps/pickle.loadsover Redis -- an arbitrary-code-execution primitive the moment anything untrusted can write to that key. Replaced with plain JSON (demodulator_status_to_json_dict/_from_json_dict), base64-encoding the twobytes-typed RBDS fields and string-keying the two int-keyed dict fields JSON can't represent natively. Verified with a round-trip test before and after.webapp/admin/boundaries.py:upload_shapefile'sshapefile_pathform field accepted any filesystem path with only an.exists()check, unlike every sibling upload path in the same file -- an arbitrary-file-read primitive (pyshp error text can reveal whether an out-of-tree file exists and something about its shape) for an authenticated admin. Now resolved and containment-checked against the configured shapefile directory, mirroringwebapp/routes_backups.py'sresolve_backup_path().install.sh,update.sh,scripts/diagnose_502_504.sh,scripts/fix_website_504.sh,scripts/diagnose_smart.sh,scripts/update_bad_actors.sh: several root-run scripts wrote to predictable/tmp/*filenames (venv-creation.log,dep_check.txt,pip-install.log,test_smart.py,argon1v5.sh,bad-actors-nginx-test.log) -- a classic symlink race (CWE-377) where a local user can pre-plant that exact path pointing at an arbitrary file before the script runs. All switched tomktemp;fix_website_504.sh's documentedcurl -o /tmp/fix_website.sh ... && sudo bashusage instructions updated to the same pattern (acurl-then-sudo bashon a fixed path is also a race on the downloaded content, not just the log).
Fixed — Low
app_core/_models_admin.py: the legacy SHA256-hash upgrade path compared hashes with plain==instead of a constant-time compare -- a timing side-channel, nowhmac.compare_digest.webapp/admin/dashboard.py,webapp/admin/boundaries.py,webapp/public/logs.py:/admin/rbac,/admin/sessions,/admin/gpio/statistics,/admin/list_shapefiles,/logs,/logs/export.csv,/logs/export.pdfrendered for any authenticated user, relying only on the app-wide login gate rather than a specific permission like their sibling APIs already use. Now gated onsystem.view_users,system.manage_users,gpio.view,system.configure, andlogs.view/logs.exportrespectively.
Deliberately deferred (documented, not fixed this pass)
- Sudoers wildcards (
sed -i * /etc/nginx/sites-available/eas-station,ln -s * /etc/letsencrypt/live/*,cp /tmp/*.xml /etc/icecast2/icecast.xml): broader than the app's current hardcoded call sites need, but narrowing them risks silently breaking SSL certificate installation or Icecast config in a way that can't be verified without a live certbot/nginx pipeline. Not currently exploitable per the audit (no user input reaches these arguments today). - CSP
'unsafe-inline'onscript-src/style-src(app.py): removing it would require migrating every inline<script>across the template tree to a nonce-based CSP -- a large, high-blast-radius refactor out of scope for this pass. The stored-XSS root cause it would have provided defense-in-depth against is already fixed above. - Icecast "test connection" SSRF-by-design: no host allow-list, but blocking private/internal targets would break the legitimate case of testing an Icecast server on the operator's own LAN. Already admin-gated (
system.configure). - Four systemd services running as root (
certbot.service,eas-station-postal.service,bad-actors-update.service,eas-station-failure-recovery@.service):certbot.servicegenuinely needs root (matches the ownership design above); the other three write to root-owned system paths (/etc/nginx/, the Docker socket) that would need their own sudoers/script rework to safely demote, without a way to verify the result outside a live deployment.
[2.229.2] - 2026-09-10 - Dithered "desktop" backdrop behind the DOS-installer banner and completion screens
Added
scripts/lib/ui.sh:ui_bannerandshow_celebrationnow center their box on a full-width, stippled two-tone "desktop" (a▒MEDIUM SHADE fill in the same grey-on-blue as the box border) instead of leaving plain terminal background on either side -- matching the floating-box-on-textured-backdrop look of the reference DOS installers (DOOM Setup, DOSBox config, Beneath a Steel Sky), rather than just a solid-blue box with black on both sides. Falls back to no margin/no dither on a terminal narrower than the box itself.- This only applies to the two screens this file draws by hand. The live
whiptail --gaugeprogress screen can't carry it: newt repaints its own root as a flat color fill on every redraw (confirmed by pre-filling the screen with the same dither pattern and watching whiptail's first paint wipe it), so dithering it would require replacing whiptail with a fully custom-drawn progress display.
[2.229.1] - 2026-09-10 - DOS-installer blue theme for every whiptail dialog, and a black-gutter box-art bug fix
Added
scripts/lib/ui.sh: everywhiptaildialog install.sh/update.sh show (--yesno,--msgbox,--gauge,--menu, ...) now renders in a solid Turbo-Vision-blueNEWT_COLORStheme, instead of newt's own default grey -- this is what actually ties the whole install experience together into one consistent DOS-installer look end to end, matching the hand-drawn banner/completion box screens rather than clashing with them. Respects a caller-suppliedNEWT_COLORSif one is already set.- The
--gaugeprogress bar's filled portion is now green instead of red, which read as an error/danger color rather than progress.
Fixed
_DOS_GREYused\033[0;37m, whose leading0resets ALL SGR attributes -- including the active blue background -- before setting the grey foreground. Inside the hand-drawn blue-background box screens (ui_banner,show_celebration), this silently knocked the background back to black for every box-rule character (╔═╗║╚╝) and every grey-colored label, leaving a black gutter around what should have been a solid blue box. Now\033[37m(foreground only).
[2.229.0] - 2026-09-10 - Progress bars now show elapsed/remaining time, and the alert-verification bar no longer freezes after 5 minutes
Added
- The alert-verification audio-decode progress overlay (
templates/eas/alert_verification.html) and the one-click System Upgrade progress bar (templates/admin/operations.html) both now show elapsed time and a linear-extrapolation "About Nm Ns remaining" estimate, computed the same way in both places: elapsed / (percent / 100) - elapsed. webapp/routes/alert_verification/progress.py:ProgressTrackernow tracks each operation's wall-clockstarted_at(recovered from the on-disk payload so a later phase's tracker doesn't reset the clock) and reportselapsed_seconds/eta_secondsalongsidepercent.webapp/admin/maintenance/routes_upgrade_progress.py:/admin/operations/upgrade/progressnow also reportselapsed_seconds/eta_seconds, derived from systemd'sActiveEnterTimestampMonotonicproperty paired with/proc/uptime-- immune to timezone/NTP wall-clock issues that a parsedActiveEnterTimestampstring would have.
Fixed
- The alert-verification progress overlay stopped polling for genuine completion after 5 minutes, freezing the bar at a fake 90% -- a real accuracy bug, since a run that legitimately took longer than 5 minutes could no longer show it had finished. Polling now keeps running indefinitely; only the status message degrades after 5 consecutive failed polls.
- Fixed a
threading.Lockdeadlock inProgressTracker.update()/complete()/error(): the new elapsed/ETA calculation could recoverstarted_atvia the same-named, also-lockingProgressTracker.get(), which deadlocks if called from inside the already-held lock. The timing calculation now always runs before the lock is acquired.
[2.228.23] - 2026-09-10 - Share cards: same information across every aspect ratio, and instruction can no longer get crowded out
The narrow (landscape, FB/X/LinkedIn) share card and the wide cards (square/portrait/story, Instagram/TikTok/Snap) used to show genuinely different information for the same alert -- the narrow layout only drew HEADLINE/DESCRIPTION as a fallback when no threat-specific content rendered, and never drew AFFECTED AREAS or COVERAGE at all. All layouts must carry the same information regardless of width.
Fixed
app_utils/image_export/render.py: HEADLINE, AFFECTED AREAS, DESCRIPTION and COVERAGE now render unconditionally in the narrow layout too, matching the wide layout's content set.- Rendering an actual sample card with this fix exposed a second, related bug in both layouts: on a content-dense product (storm threat data + a long NWS headline), HEADLINE + AFFECTED AREAS + DESCRIPTION could fill the entire info panel before ever reaching INSTRUCTION, silently dropping "move to an interior room" off the bottom while less safety-critical narrative text survived. INSTRUCTION now draws immediately after the threat-summary section in both layouts, so space pressure can only clip the narrative sections (which already degrade gracefully everywhere else in this file), never the one thing on the card that tells someone what to physically do.
New tests in tests/test_image_export_broadcast_panels.py and tests/test_image_export_themes.py cover both the content-parity fix and the instruction-ordering fix, for both layout families, at the unit (drawer call order) and end-to-end (rendered pixel) level -- verified each new test actually fails against the pre-fix code before confirming it passes against the fix. Full test suite (3126 tests) passes.
[2.228.22] - 2026-09-10 - Cache the share-card radar overlay fetch
An audit of app_utils/image_export/ found _fetch_radar_overlay() (maps.py) doing an uncached synchronous WMS network fetch on every call, unlike its sibling basemap-tile fetcher in tiles.py, which already has a two-level (in-memory LRU + disk) cache. This isn't just an on-demand cost: generate_alert_image() runs on an automated path -- app_core/notifications/alert_image.py's build_alert_image() calls it for every notification email the CAP poller and audio monitoring services send for weather alerts. During a severe-weather outbreak, several warnings issued minutes apart, often with overlapping bounding boxes, frequently land in the same 5-minute WMS time bucket -- each previously triggered its own independent network fetch of what's very possibly the identical radar tile.
Fixed
- Added an in-memory LRU cache (
_RADAR_CACHE, 32 entries) to_fetch_radar_overlay(), keyed on(tx_min, ty_min, tx_max, ty_max, z, canvas_w, canvas_h, scan_time). Deliberately no disk-backed second tier liketiles.py's -- unlike the fixed, immutable basemap tile grid, radar bboxes are per-alert-specific (far higher cardinality) and the key is already self-expiring (a new 5-minute time bucket naturally ages out prior entries), so a disk cache would only grow unbounded for one-off bboxes never fetched again. HTTP errors are never cached, so a transient WMS failure can't poison the cache for the next (retryable) request in the same time bucket.
New tests in tests/test_image_export_radar_overlay.py: a second call with identical params skips the HTTP fetch entirely; different bbox or different 5-minute time bucket are both cache misses; HTTP errors aren't cached. Added an autouse fixture clearing the now-module-level cache between tests -- without it, two pre-existing tests (..._returns_none_on_http_error, ..._returns_none_on_network_exception) would silently return a previous test's cached success instead of exercising their own mock; verified this by temporarily removing the fixture and confirming both failed exactly as expected before restoring it. Full tests/test_image_export_* suite (194 tests) passes.
[2.228.21] - 2026-09-10 - Fix a numpy/numba dependency conflict that took the whole site down mid-update
update.sh stops every eas-station service, updates the main venv, updates the SDR venv, then restarts everything. requirements.txt's numba pin was raised to >=0.67.0,<0.68.0 (needed for numpy>=2.5.2 -- every numba<0.65 release caps numpy at <2.4) but requirements-sdr.txt was never updated to match, still capped at <0.64.0. The main venv install succeeded; the SDR venv install then hit pip's ResolutionImpossible on numpy>=2.5.2 vs. numba<0.64.0's numpy<2.4 requirement, and update.sh exited without ever reaching its restart step -- the site was down until this was found and fixed by hand.
Fixed
requirements-sdr.txt'snumbapin now matchesrequirements.txt's (>=0.67.0,<0.68.0). Verified with a fresh dependency resolve: resolves cleanly tonumpy-2.5.3,numba-0.67.0,llvmlite-0.49.0.
New tests/test_requirements_sdr_numba_numpy_sync.py: asserts the two files' numba pins stay identical and that requirements-sdr.txt's numpy floor is never below requirements.txt's pin. Verified it actually catches the original bug (fails against the pre-fix <0.64.0 pin, passes against the fix).
[2.228.20] - 2026-09-10 - Database audit: missing timestamp indexes, redundant Redis fetch, three N+1 query patterns
A four-way parallel audit (DB query patterns, background schedulers, Redis/connection pooling, Flask route handlers) turned up one critical, well-corroborated finding and several smaller ones.
Fixed
system_log.timestamphad no index.app_core/_models_admin.py.SELECT * FROM system_log ORDER BY timestamp DESC LIMIT 20cost 500ms-1s -- a parallel sequential scan across all 440k rows (810MB) plus a sort, just to fetch 20 rows. Hit every 10 seconds forever bywebsocket_push.py's_emit_logs_update(inside the persistent slow-loop session), plus the/logspage. Verified live:EXPLAIN (ANALYZE, BUFFERS)went from 1023.8ms to 0.055ms after adding the index -- roughly 18,600x. New migration20260910_add_timestamp_indexes.- Same gap on
poll_history.timestamp(app_core/_models_polling.py), same migration -- 27-36ms per hit across several/logs-related routes and_emit_ipaws_status_update, smaller table but identical root cause. app_core/websocket_push.py:_emit_audio_sources_updateand_emit_audio_health_updateboth run on the same 30s interval starting from the same zero offset, so they land on the same tick and each independently re-fetched the identical Redis metrics hash. Added a 1-second-TTL cache (_read_audio_metrics_cached) shared between them; the 4Hz fast-loop emit is untouched (wants every tick's freshest read).webapp/admin/maintenance/routes_import.py: the manual NOAA alert import endpoint issued oneCAPAlert.query.filter_by(identifier=...).first()per feature in the response instead of one batched.filter(...in_(...))lookup for the whole payload -- fine for a single alert, scales badly for a large historical backfill. Batched the existence check; preserved the original per-iteration behavior for a duplicate identifier appearing twice in one payload (must become insert-then-update, not a duplicate-key crash) by updating the lookup dict as each new row is inserted. Newtests/test_import_alert_batching.pycovers both the normal insert/update split and that regression case specifically.webapp/radio_settings/routes_diagnostics_status.py: the SDR metrics-to-UI conversion looked up each receiver's DB id with its own query inside the per-receiver loop (polled every 15s while the Radio Diagnostics page is open). Batched into one.filter(identifier.in_(...))lookup before the loop.app_core/alert_purge.py's_delete_orphaned_messages(6-hourly auto-purge sweep) ran one query per candidate message id to check whether it was still referenced by areceived_eas_alertsrow. Batched into one query for the whole id list.
Full test suite (3113 tests) passes.
[2.228.19] - 2026-09-10 - Guard the remaining /dev/tty writes in scripts/lib/ui.sh's static-UI helpers
update.sh --non-interactive runs with no controlling tty when launched via systemd-run (the Admin -> Operations "System Upgrade" button's path, per bin/eas-station-run-update). Most of scripts/lib/ui.sh already guards its /dev/tty writes behind _UI_HAS_CONTROLLING_TTY (~15 call sites), but _dos_goto_row(), _tty(), _tty_raw(), ui_banner()'s plain-terminal branch, ui_progress_bar()'s plain-terminal branch, and ui_progress_end() were missed. A 2>/dev/null redirect on the printf only suppresses printf's own runtime stderr -- it does not catch bash's own failure to open /dev/tty in the first place (ENXIO, no controlling tty), which bash reports directly to the script's current stderr before printf ever runs.
Fixed
- Added the same
_UI_HAS_CONTROLLING_TTYguard already used everywhere else in the file to the six call sites above. Verified with a real before/after run undersetsid ... </dev/null(no controlling tty): the unfixed version throws 3/dev/tty: No such device or addresserrors from a two-line smoke test; the fixed version is silent.
[2.228.18] - 2026-09-10 - Apply the proven glibc malloc-arena fix to eas-station-audio.service
system_metric_samples showed a week-long sawtooth: system memory climbing
steadily after every eas-station-audio.service restart, then resetting on
the next one. Correlating with process RSS directly: eas_monitoring_service.py
grew from ~400 MB fresh to 3.44 GB RSS over 2.8 days of uptime, while swap sat
chronically 47-100% full the whole time.
Fixed
eas_monitoring_service.pynever hadservices/common/bootstrap.py'sinit_runtime()applied — the exact fix already proven oneas-station-displays.service, which cut that service's RSS from 9.68 GB to 320 MB. glibc defaults to one malloc arena per thread (up to 8x on a Pi 5), and this is the most heavily threaded eas-station process (websocket push fast+slow loops, gated-alert scheduler, per-source audio pipelines, ffmpeg feeder threads) -- 33 threads were already running within 90 seconds of a fresh restart. Addedinit_runtime("audio")as the first statement inmain(), before any thread spawns (arena caps only bind threads created afterward), and mirroredeas-station-displays.service's systemd env vars ontoeas-station-audio.service:MALLOC_ARENA_MAX=2,MALLOC_TRIM_THRESHOLD_=131072, andMEMDIAG_DUMP_DIR=/var/log/eas-station(wires up the SIGUSR1/SIGUSR2 memdiag hooks too, previously entirely absent on this service, for diagnosing any residual growth without guessing).
New tests/test_eas_monitoring_service_glibc_tuning.py: asserts init_runtime("audio") is imported and called, that it precedes the first thread spawn in main(), and that the systemd unit pins the same malloc tuning + MEMDIAG_DUMP_DIR as the already-fixed Phase 4 units. Confirmed the module still imports cleanly (existing tests in tests/test_broadcast_metadata_reconcile.py, tests/test_dead_air_monitoring.py, tests/test_audio_metrics_snapshot_writer.py already import eas_monitoring_service directly -- 56 tests pass).
[2.228.17] - 2026-09-10 - Fix a long-lived idle-in-transaction connection leak in the WebSocket push service, compress rotated app logs, and close a retention-sweep coverage gap
A routine database health check (pg_stat_activity) found several
connections stuck idle in transaction for hours, each frozen at a bare
SELECT with no following COMMIT/ROLLBACK -- exactly the kind of open
snapshot that pins Postgres's vacuum horizon and blocks dead-tuple cleanup
database-wide.
Fixed
app_core/websocket_push.py's slow push loop (_push_worker_slow) holds one shared SQLAlchemy session open for the life of the process (intentional, to avoid per-tick app-context overhead at high frequency -- see the_recover_db_session()docstring), but only rolled that session back inside each emit'sexceptblock. A purely successful pass -- the common case -- never committed, so everySELECT-only emit (e.g._emit_pending_alerts_update'sgated_alertsquery, thecount(CAPAlert.id)in the system-health snapshot) left its read transaction open indefinitely. Added onedb.session.commit()per 1 Hz loop iteration, after all emits run, to close out the transaction on the success path too. The 4 Hz fast loop is untouched -- it's confirmed DB-free by inspection, matching its own docstring's claim.
Changed
- The rotated
eas_station.logbackups (app.py'sRotatingFileHandler, 10 MB × 5 backups) are now gzip-compressed on rotation via a customrotator/namerpair, instead of sitting on disk as plain text. - The retention sweep (
app_core/retention.py) now also prunes the*.jsonmetadata sidecar thatsdr_hardware_service.pywrites next to every*.npyIQ capture -- previously only the.npyitself matched the prune pattern, so every capture left a small orphaned sidecar behind forever. Also added a new fixed-age (30 day, not exposed inretention_settings-- these are internal diagnostics, not a sized data category) sweep step foreas-memdiag-*.txtsnapshots andeas-station-*-startup-error-*.logcrash dumps inEAS_LOG_DIR, neither of which previously had any cleanup mechanism at all.
Updated tests/test_retention.py (test_sweep_prunes_iq_and_temp_files, test_one_failing_step_does_not_stop_the_rest) for the new diagnostic_files_removed summary field and the additional guarded prune_directory() calls. Full tests/test_retention.py suite (20 tests) passes.
[2.228.16] - 2026-09-09 - Audit and fix every pure-FIR lfilter slow-path instance in the codebase
Continuing the CPU hunt: profiling the demod service directly (ground-truth
per-thread /proc census, then re-filtering the existing py-spy profile
excluding known idle leaves -- same disciplined methodology as 2.228.14/15)
found numpy.convolve again, this time inside RBDSWorker._process_rbds().
Finding the same bug a third time (after drivers.py in 2.228.14) was the
signal to stop fixing these reactively, one profiler hit at a time, and
instead grep the entire codebase for every .lfilter( call and classify
each by whether its a coefficient is a true multi-tap IIR (fine, hits
scipy's fast path) or a pure-FIR 1.0/[1.0] (the bug).
Fixed
RBDSWorker._process_rbds()'s 54-60 kHz bandpass (app_core/radio/demod/rbds_worker.py, applied at the pre-decimation rate -- the highest-leverage stage in the RBDS pipeline) and its 2.4 kHz post-mix lowpass both calledscipy.signal.lfilter(..., [1.0], ...)-- the same pure-FIR case that unconditionally takes scipy's slownp.apply_along_axis(...) -> np.convolvefallback fixed indrivers.pyback in 2.228.14. The genuinely interesting part: the surrounding comments explain the developer had already diagnosed and fixed a real bug here -- switching from a plain per-chunknp.convolve(stateless, so every chunk boundary produced a transient that flooded the RBDS bit-sync with garbage) tolfilterwith a persistedzidelay line. That fix was correct, but the developer had no way to knowlfilter's fast C path only activates for a true IIR filter (len(a) > 1) -- for a pure-FIR filter it falls back internally to the exact samenp.convolveroutine being moved away from, just wrapped behind a state-carrying API. Both filters now use overlap-add viaoaconvolve(FFT-based, and its convolution "tail" carries state across chunks exactly likezidid -- same technique as 2.228.14's fix andFMDemodulator._mono_audio_lowpass). The lowpass filter also drops its real/imaglfiltersplit entirely --oaconvolvehandles complex input natively.FMDemodulator.demodulate()'s RBDS early-decimation anti-alias filter (app_core/radio/demod/fm.py, the filter that decimates the multiplex down to the RBDS worker's intermediate rate when a receiver's raw SDR rate exceeds ~500 kHz) had the identicallfilter(..., 1.0, ...)pure-FIR pattern. Not currently exercised by the livewbksreceiver (its 256 kHz effective rate needs no further RBDS-path decimation), but would hit full force the moment any higher-rate receiver -- including the disabled Airspy config already in this deployment's database, or an RTL-SDR bumped back toward 1 MHz+ -- is enabled. Fixed with the same overlap-add technique.scripts/rbds_diagnose.py(the offline RBDS troubleshooting tool operators run against a captured IQ file) had the same pattern in both its bandpass and post-mix lowpass. Not a live-service cost, but fixed for consistency and so the tool runs faster when someone actually needs it.- Audited and confirmed correct, left unchanged:
RBDSWorker._apply_interference_notch'sscipy.signal.iirnotch-derived filter (a genuine multi-tap IIR,len(a) == 3) andFMDemodulator._apply_deemphasis's one-pole de-emphasis filter (a = [1.0, alpha - 1.0],len(a) == 2) -- both true IIR filters that correctly hit scipy's fast path already.
New tests: test_rbds_bandpass_oaconvolve_matches_lfilter_ground_truth, test_rbds_lowpass_oaconvolve_matches_lfilter_ground_truth, test_rbds_aa_filter_oaconvolve_matches_lfilter_ground_truth (numerical equivalence against each filter's mathematical ground truth -- a single lfilter call) and test_rbds_bandpass_chunked_matches_single_continuous_call (proves the tail-carry state stitches irregular real-world chunk boundaries seamlessly), all in tests/test_rbds_demodulation.py. Existing filter-behavior tests (test_rbds_post_mix_lowpass_rejects_stereo_sideband_artifact, frequency-response and stopband-rejection checks, not just call shape) continue to pass unchanged.
Live-verified on /opt before opening the PR: wbks decoding cleanly after
the rbds_worker.py fix deployed -- 3439/3442 blocks OK (99.9%), full PS
name and RadioText, only 2 sync-loss events total (both from the retune
transient, not ongoing instability).
Live-verified on /opt before opening the PR, same discipline as 2.228.14/15.
[2.228.15] - 2026-09-09 - Batch the SDR publisher's per-chunk Redis calls into one round trip
Continuing the CPU hunt after 2.228.14: this time built a proper ground-truth
measurement first, rather than trusting py-spy record's wall-clock sampling
directly (a time.sleep() or blocked syscall shows up in a wall-clock
profile proportional to how long it took, not how much CPU it used --
exactly the pitfall that produced the wrong diagnosis in 2.228.13). Reading
utime+stime straight from /proc/<pid>/task/<tid>/stat over a fixed window
gives real, unambiguous per-thread CPU consumption.
That census confirmed sdr_hardware_service.py's two hot threads
(RTLSDRReceiver-wbks capture and SDR-Publisher) both do carry genuine
CPU load (not just I/O wait) -- combined ~42.4% of a core. Re-filtering the
existing py-spy profile to just the publisher thread's frames, and
excluding its time.sleep() idle leaf this time, showed Redis client
protocol overhead (command send + reply read + parse, repeated per call)
at ~42% of that thread's real busy time -- more than zlib.compress alone.
Fixed
publish_samples_and_metrics()(sdr_hardware_service.py) issued up to four separate synchronous Redis round trips per chunk --publish()(IQ samples, every chunk),setex()(spectrum, rate-limited to every 100ms), andhset()+expire()(ring-buffer stats, every chunk, unlike the other two which are throttled). Each round trip pays the full redis-py call chain (execute_command->_execute_command->call_with_retry->_send_command_parse_response->parse_response->read_response->read_from_socket) even though none of these calls' return values were ever used. Now queues whichever of these are due each iteration onto one non-transactionalredis_client.pipeline(transaction=False)and executes once. No behavior change -- same commands, same order, same effects, just one network round trip instead of up to four.
Live-verified before opening this PR, same discipline as 2.228.14: deployed
directly to /opt, confirmed wbks still decoding correctly (stereo pilot
locked, RBDS synced, real PS name/RadioText), confirmed the ring-buffer-stats
and spectrum Redis keys still populate (proving the pipelined commands still
execute), and re-ran the ground-truth per-thread /proc census: total CPU
on sdr_hardware_service.py dropped from 42.4% to 38.9% of a core. Smaller
than 2.228.14's win (this only removes redundant round-trip overhead, not a
slow-path compute bug), but real and measured the same rigorous way.
[2.228.14] - 2026-09-09 - Correction: the real 2.228.13 fix is oaconvolve, not a real/imag lfilter split
2.228.13's diagnosis was wrong, caught by re-profiling live rather than
trusting the theory. After deploying 2.228.13 to /opt and restarting
eas-station-sdr.service, a fresh py-spy record showed numpy.convolve
still dominant (29.7% of samples, barely down from 38.7%) -- top even
showed the process running hotter (68.7% vs. the original ~60%).
Fixed
- Reading scipy's actual
lfiltersource (_signaltools.py) showed the real condition: the fast C path (_sigtools._linear_filter) only activates whenlen(a) > 1-- a true IIR filter. This anti-alias filter is pure FIR (a=1.0), so anylfiltercall on it -- real or complex input, split or not -- unconditionally takes the slownp.apply_along_axis(...) -> np.convolvefallback. The real/imag split in 2.228.13 ran two calls through the identical slow path instead of one, which is why CPU didn't meaningfully improve.app_core/radio/drivers.py's_capture_loopnow replaceslfilterentirely with overlap-add viascipy.signal.oaconvolve(FFT-based, handles complex input natively, no split needed) -- the same techniqueFMDemodulator._mono_audio_lowpassalready uses successfully. Benchmarked directly:oaconvolveon 1M complex64 samples with a 257-tap filter took ~46 ms vs.lfilter's >150 ms for just the real half alone. Confirmed live this time: a follow-uppy-spy recordafter deploying showsnumpy.convolvegone entirely from the profile (the only remaining FFT-related cost is legitimateoaconvolvework at ~8.4% combined), andsdr_hardware_service's live CPU dropped from ~60% to ~36%. Also fixes a latent double-counting bug present in the original pre-2.228.13 code (predates both attempts): it carried filter state (zi) across calls but also re-fed leftover unfiltered samples through that same state on the next call, filtering the boundary samples twice; the overlap-add tail-carry has no such issue since every input sample is consumed and filtered exactly once. New tests intests/test_early_decimation.py:test_oaconvolve_hot_path_matches_single_complex_lfilter_call(numerical equivalence against the filter's mathematical ground truth) andtest_chunked_hot_path_matches_single_continuous_call(proves the tail-carry/phase state stitches irregular real-world USB-read chunk boundaries seamlessly).
Process note for next time: verified live on /opt (file copied directly,
service restarted, re-profiled) before opening this PR, rather than
merging on passing unit tests alone and finding out after -- which is
exactly what caught 2.228.13's wrong diagnosis in the first place.
[2.228.13] - 2026-09-09 - Fix scipy.signal.lfilter silently falling off its fast path on complex IQ
Correction (2.228.14): the fix below did not work. Live re-profiling after deployment showed numpy.convolve still dominant and CPU higher than before. The real/imag split assumption was wrong -- see 2.228.14 for the actual root cause and fix. Left here for the historical record of what shipped and why the diagnosis seemed right at the time.
While profiling sdr_hardware_service.py directly (ps/top showed it at
~60% CPU, higher than the demod worker itself) to look for more CPU wins
after the RTL-SDR sample-rate investigation, a 15-second py-spy record
found the single largest cost in the entire process: 38.7% of all
samples (496/1283) were inside numpy.convolve, called via
numpy.apply_along_axis from inside scipy.signal.lfilter.
Fixed
_capture_loop's early-decimation anti-alias filter (app_core/radio/drivers.py) calledscipy.signal.lfilter(self._early_decim_aa_filter, 1.0, to_decimate, zi=...)directly on the complex64 IQ stream.scipy.signal.lfilter's fast C implementation (sigtools' direct IIR/FIR routine) only handles real dtypes -- complex input silently falls back to a genericnumpy.apply_along_axis(...) -> numpy.convolvepath, an O(N·taps) direct-form convolution instead of the optimized routine, for every single USB read on every high-rate SDR receiver (Airspy, or any RTL-SDR run above 500 kHz).RBDSWorkerhad already discovered and worked around this exact scipy behavior (_apply_interference_notchand its 2.4 kHz post-mix lowpass both filter real and imaginary parts separately) but the fix was never applied to this call site. Now splitsto_decimateinto.real/.imag, filters each independently (two real-valuedlfiltercalls, each hitting the fast path) with separatezidelay-line state, and recombines asreal_out + 1j*imag_out-- exact, not an approximation, since the filter coefficients are real and the two components are independent linear systems. Newtest_real_imag_split_matches_single_complex_lfilter_callintests/test_early_decimation.pyproves numerical equivalence (rtol=1e-5) against the old single-complex-call path on a multi-tone test signal; the existingtest_alias_image_is_rejectedandtest_rbds_passband_is_flattests (which exercise actual filter behavior, not just call shape) continue to pass unchanged.
Not yet measured live: the actual CPU delta on sdr_hardware_service.py
after this deploys. Given the profile showed this call at 38.7% of total
process time, a substantial drop is expected -- will confirm with a live
py-spy record comparison after deployment rather than assume.
[2.228.12] - 2026-09-09 - Stop narrowing the analog IF filter when a WFM receiver's sample rate is set low for CPU
Follow-up investigation to 2.228.10/2.228.11's CPU work: since wbks (the
live, actively-receiving wbks RTL-SDR) already decimates from its
1.024 MHz configured rate down to 256 kHz in software before the FM demod
pipeline ever sees the signal (app_core/radio/decimation.py's
EARLY_DECIM_TARGET_RATE), the obvious next question was: why not just
configure the RTL-SDR to natively capture at ~250 kHz and skip the
early-decimation FIR entirely? RTL-SDR hardware supports this directly (its
low band is 225,001-300,000 Hz) and 250 kHz already clears every downstream
FM-stereo/RBDS threshold in fm.py.
Tested live on wbks: it made things measurably worse. Over matched clean
windows, the RBDS sync-loss rate roughly tripled (0.022/s at 1.024 MHz vs.
0.083/s at 250 kHz).
Fixed
- Root cause:
_SoapySDRReceiver's device-open path (app_core/radio/drivers.py) calleddevice.setBandwidth(SoapySDR.SOAPY_SDR_RX, channel, self.config.sample_rate)-- tying the tuner's analog IF filter bandwidth directly to the configured digital sample rate. At 1.024 MHz the analog filter stays wide open (far more than the ~120 kHz the FM multiplex needs), so all real filtering happens cleanly in software afterward. At 250 kHz that same line also narrows the analog RF filter down to ~250 kHz -- right in the neighborhood of the multiplex itself -- attenuating the pilot/L-R/RBDS subcarriers before they're even digitized, which no amount of downstream software decimation can recover. Added_SoapySDRReceiver.WFM_MULTIPLEX_MIN_BANDWIDTH_HZ = 300_000(Carson's rule: full-deviation broadcast FM's occupied bandwidth is ~264 kHz) and floor the analog bandwidth request at that value whenevermodulation_typeis FM/WFM and stereo or RBDS is enabled -- narrowband receivers (e.g. NOAA NFM, no stereo/RBDS) are left untouched, since widening their analog filter would only admit more adjacent-channel noise for no benefit. New tests intests/test_radio_drivers.py:test_wfm_stereo_floors_analog_bandwidth_below_multiplex_minimum,test_narrowband_sample_rate_above_floor_is_unaffected,test_non_wfm_low_sample_rate_bandwidth_not_floored.
Not yet re-tested: whether this fix actually closes the gap and makes
250 kHz native capture on RTL-SDR safe for real CPU savings. wbks is
currently reverted to 1.024 MHz (confirmed stable) pending a fresh live A/B
test of this fix before recommending the lower rate to anyone, in the UI
or otherwise.
[2.228.11] - 2026-09-09 - Total-audit pass: two more redundant-computation fixes in the FM stereo path
Follow-up to 2.228.10 after being asked not to stop at a single finding: a
full read-through of every file in app_core/radio/demod/ (fm.py, dsp.py,
rbds_worker.py, rbds_decoder.py, kernels.py, am.py) looking for the
same class of bug -- work computed once, then computed again for no reason.
Two more confirmed in fm.py; the RBDS worker's apparent real/imag lfilter
"duplication" turned out to be a different, already-documented pattern (a
single complex lfilter call and two real-valued calls do the same total
floating-point work either way -- not a finding) and its one diagnostic
pilot_rms computation is already throttled to 1-in-100 calls.
Fixed
FMDemodulator._decode_stereo()(app_core/radio/demod/fm.py) recomputedpilot_rms = np.sqrt(np.mean(pilot_filtered ** 2))even after the previous fix started passing inpilot_filtereditself --demodulate()already computes that identical mean+sqrt reduction over the same array to derivestereo_pilot_strength, before ever calling_decode_stereo. Now acceptspilot_rmsas an optional parameter, passed through fromdemodulate(); only recomputed when omitted. Newtest_precomputed_pilot_rms_is_numerically_equivalentintests/test_fm_stereo_decoder.pyproves bit-for-bit equivalence.demodulate()'s stereo call site builtstereo_sample_indices = np.arange(len(multiplex), dtype=np.float64)-- a full chunk-length float64 array, tens of MB/sec of allocation and fill at typical SDR rates -- on every stereo-locked chunk, purely to satisfy_decode_stereo'ssample_indicesparameter, which the method body has never read (its own docstring already said "unused; kept for backwards-compat", but nothing had removed the allocation at the call site).sample_indicesis now optional (defaultNone) and the call site no longer builds it. Newtest_decode_stereo_no_longer_requires_sample_indicesconfirms the method works without it.
Audited and found clean (no action needed): dsp.py's filter-design helpers and fast_decimate (each called once per purpose, no duplication); rbds_worker.py's two real/imag lfilter pairs (54-60 kHz bandpass and 2.4 kHz post-mix lowpass, both already running at the decimated rate); am.py (too small to have redundant work); kernels.py (JIT kernels, no wrapper-level duplication).
Not fixed here, same as noted in 2.228.10 and still deliberately out of scope for a "redundant computation" pass: _decode_stereo's remaining two oaconvolve calls and the polyphase resampler's einsum step still run at the SDR's full raw IQ rate rather than a decimated one. That is real further CPU to reclaim but is an algorithmic rate change, not a duplicate-work bug -- it needs its own dedicated, separately-validated pass.
[2.228.10] - 2026-09-09 - Stop recomputing the FM stereo pilot filter twice per chunk
Continuing the wbks CPU investigation (2.228.8/2.228.9): py-spy profiling
found _decode_stereo's three oaconvolve FFT convolutions were the
single largest category of the demod worker's on-CPU time once idle
waits were excluded from the accounting.
Fixed
FMDemodulator.demodulate()(app_core/radio/demod/fm.py) already computes the 19 kHz-bandpass-filtered multiplex once, to decidestereo_pilot_locked, before it ever calls_decode_stereo-- which then recomputed the identicaloaconvolve(multiplex, self._pilot_filter, mode="same")call internally, same input, same filter, same result, a full third of its own FFT-convolution cost for zero benefit._decode_stereonow accepts the already-computed value as an optional parameter and only recomputes it when a caller doesn't supply one (every direct-call test intests/test_fm_stereo_decoder.pystill exercises the original internal-compute path unchanged). Newtest_precomputed_pilot_filtered_is_numerically_equivalentproves the two paths produce bit-for-bit identical output. Full audio/demod/RBDS/FM/stereo/SDR test surface (467 tests) run clean.
Not fixed here: _decode_stereo's remaining two oaconvolve calls (LPR
lowpass, DSB lowpass for L-R) and the polyphase resampler's einsum gather
step still run at the SDR's full raw IQ rate. Real further reduction needs
decimating before this stage, which the RBDS worker's own early-decimation
path already validates is spectrally safe (RBDS needs up to 57 kHz, higher
than stereo's 38 kHz) -- but wiring the stereo/pilot path through the same
decimated signal is a larger, separate change deserving its own dedicated
pass, not bolted onto this one.
Investigated why eas-station-displays.service was consuming 18%+ CPU at
idle. Its log was spamming two distinct errors on every render cycle,
continuously.
Fixed
/api/gpio/status— thevfd_gpio_statusdefault screen's data source, per the route's own docstring ("...with summary data for OLED") — was gated behind@require_permission('gpio.view')with no local-network exemption, soscripts.screen_renderer.ScreenRenderer's unauthenticatedlocalhostrequests 401'd on every single render cycle; confirmed live in the service's own logs. Addedrequire_permission_or_local_network()(app_core/auth/roles.py, mirrors the existingrequire_permission_or_setup_modepattern) so an anonymous local-network caller is let through -- the same caseapp.py'sLOCAL_API_GET_PATHSalready exempts from login app-wide -- while a signed-in session withoutgpio.viewis still denied./api/gpio/statusregistered inLOCAL_API_GET_PATHSto match.scripts/screen_renderer.py'sevaluate_condition(): when the live value fails to parse as a number (exactly what happened above -- the 401 fed a non-numeric placeholder into a numeric condition), the code fell back to the original string for the live value but left the condition's configuredexpectedvalue as whatever raw type its JSON stored, typically a bare int ({"value": 0}) -- mixingstrandinton a>/</>=/<=comparison and raisingTypeError, caught by the outer handler and logged on every cycle. Both sides now fall back to strings together.
New tests in tests/test_require_permission_or_local_network.py and tests/test_screen_renderer.py.
Investigated a reported missed Required Monthly Test on the wbks SDR
receiver. eas-station-audio.service's "Icecast buffer running low for
mount /sdr-wbks.mp3" warning -- the exact symptom commit 26b4eb9f
(2026-08-26, "Split demod into its own process") was written to eliminate
-- turned out to still be firing roughly every 30 seconds, continuously,
unbroken from that commit's own era through today; a later Nice priority
bump (2.228.1, PR #2579) didn't touch it either. Confirmed live via
py-spy record on the actual running eas-station-demod.service process
under real contention (system load 4.5-5.6 on 4 cores) rather than
reasoning from code: of the worker thread's genuinely on-CPU time (a third
of all samples were idle waits in the subscriber/queue threads and
correctly excluded), publishing the per-chunk demod:status:<id> Redis
key -- pickle.dumps() + base64 + SETEX, once per ~32ms IQ chunk
(~31/s) -- was the single largest individual line, ahead of the actual FM
stereo/RBDS DSP math.
Fixed
RedisSDRSourceAdapter._get_remote_status()(app_core/audio/redis_sdr_adapter.py) already caches this key for 250ms because, per its own docstring, "_update_metrics()runs far more often than the status meaningfully changes" -- butDemodWorker._publish()(services/demod/worker.py) was still writing a fresh one on every single chunk, roughly 7-8x more often than any reader could ever consume. Added_STATUS_PUBLISH_INTERVAL_S = 0.2s throttle on the status write only; the audiopublish()call right next to it -- the actual signal data -- is untouched and still fires every chunk. New tests intests/test_demod_service.pycovering both the throttling and that it resumes after the window elapses.
Not fixed here, flagged for dedicated follow-up rather than a rushed
change to the live SAME-decode signal path: _decode_stereo's three
oaconvolve FFT-convolution calls (app_core/radio/demod/fm.py), each
against a 1025-tap filter (algorithmically the right choice at that
length, not a bug), were the single largest category of on-CPU time
once grouped -- and they run at the SDR's full raw IQ rate (e.g.
1.024 MHz for wbks) rather than a decimated rate, per an explicit
CRITICAL FIX comment documenting a prior filter/sample-rate mismatch
bug. Reordering that decimation could recover a large further reduction
but touches the correctness of the live EAS decode path directly; it
needs its own careful, validated pass, not something bolted onto this
session's fix.
Verified live on a real multi-source deployment: GET /api/audio/now-playing
(no ?source=) returned an empty (all-null) payload even though two other
configured sources were actively streaming real songs with title/artist/
artwork metadata.
Fixed
_default_public_source()(webapp/routes_now_playing.py) picked the first enabled, Icecast-published source ordered byprioritydescending -- but thatpriorityfield issource_manager.py's EAS/SAME failover preference (a hardware line kept reliable for alert monitoring can legitimately carry no song metadata at all), not a "worth showing the public" signal, and it's a different field from the one the actual SAME decoder (app_core/audio/eas_monitor_v3.py'sUnifiedEASMonitorService) uses -- that class ignorespriorityentirely and watches every enabled source independently. Renamed to_default_public_candidate(); it now walks sources in priority order but returns the first one that actually has title or artist metadata right now, falling back to bare priority order only when nothing has metadata yet (e.g. right after startup), so the common single-station case is unaffected. New tests intests/test_now_playing_api.py.
Changed
- Dependabot dependency bump (minor release, no CVE). Synced the three tech-stack badges (
README.mdx2,templates/partials/tech_stack_badges.html) and a stalerequirements.txtcomment that referenced the old pinned version -- Dependabot only ever touches the pin itself, not the badges or comments describing it.
[2.228.5] - 2026-09-09 - Restore Keep a Changelog category headers on every entry since Aug 27
The /version page's category badges ("Added (N)", "Fixed (N)", ...) come
from app_utils/changelog_parser.py, which only recognizes real
### Added/### Fixed/### Changed H3 subheadings -- v2.193.8
(2026-08-26) was the last entry to use them. Every entry from v2.193.9
(2026-08-27) onward switched to flat prose with inline bold labels
(- **Fixed**: ... as plain text, not a heading), so the parser found
zero categorized sections for all ~90 of them and no badge ever rendered.
Nobody course-corrected until now.
Fixed
- Restructured all 89 affected entries (v2.193.9 through v2.228.4) into proper
### Added/### Changed/### Fixed/### Removed/### Deprecated/### Securitysections. 67 already had top-level bullets with inline labels (**New**:,**Fixed**:,**Changed**:, ...) that map deterministically to a category, grouped mechanically without rewording; unlabeled continuation bullets inherit the category of the immediately preceding labeled bullet in the same entry, matching how they were actually written (elaboration on the same change). The other 22 entries were pure prose with no bullets at all and were hand-restructured into itemized form. Verified viaapp_utils.changelog_parser.parse_changelog()directly: 571 entries parse cleanly, and only the pre-2026-08-26 entries this pass deliberately left alone still show zero categorized items. - Going forward, new entries use
### Added/### Fixed/### Changedfrom the start instead of drifting back to flat prose.
Two commits landed on main with no VERSION bump and no CHANGELOG entry at
all, discovered by scanning history for two different commits that both
claim the same version number -- the signature of a bump that got
clobbered when a second PR merged before the first one's version had moved.
Both fixes have been live since their original commit date; this entry only
catches the documentation up, same precedent as 2.227.0's own
"(missed at merge time)" bump.
Changed
- Fixed (originally 2026-09-03, commit
287251ec): the live weather-alert video export route (/api/alerts/<id>/export-image.mp4) failed every time with "child watchers are only available on the default loop".ffmpeg'ssubprocess.run()was running inside_run_off_worker'sgevent.get_hub().threadpool.spawn(), but gevent's cooperative subprocess handling needs a child watcher registered on the default event loop, which only exists on the process's original hub -- not the separate per-thread hub a threadpool worker gets. Splitvideo_export.pyintorender_alert_video_frames()(CPU/network-bound Pillow work, safe on the threadpool) andencode_frames_to_mp4()(the ffmpeg subprocess call, which must run back on the request's own greenlet so gevent's cooperativesubprocess.run()actually works);routes_alert_export_video.pynow calls them separately instead of one function wrapped entirely in_run_off_worker. - Fixed (originally 2026-08-26, commit
7aaa9b1e, #2471): the global broadcast overlay could get stuck open. Its local countdown reaching 0:00 never closed it -- the modal always waited for abroadcast_state_updatepush/poll to reportactive:false, and a backgrounded mobile tab can stall the WebSocket and miss that update entirely, leaving a live-looking abort button showing even after the broadcast had genuinely finished server-side. The overlay now closes when/api/broadcast/abortreturns a 409 ("No broadcast is currently active"), and reconciles with the server onvisibilitychangeso a stale overlay self-corrects on tab focus.
[2.228.3] - 2026-09-09 - Bump lxml to 6.1.3
Changed
- Dependabot dependency bump (patch release, no CVE). Synced the three tech-stack badges (
README.mdx2,templates/partials/tech_stack_badges.html) thattests/test_tech_stack_badges.pychecks againstrequirements.txt-- Dependabot only ever touches the pin, not the badges, so every dependency bump needs this same manual sync or the badge-drift test fails CI.
[2.228.2] - 2026-09-07 - Deprioritize the security-perimeter-ingest timer
Fixed
- Fixed:
security-perimeter-ingest.service(new in 2.227.0, run every 2 minutes bysecurity-perimeter-ingest.timer) boots the full Flask app viacreate_app()-- all ~260 routes, every subsystem -- just to tail the nginx log and insert a handful of rows. Measured at ~6s of near-single-core CPU per run on the bare-metal box, forever, every 2 minutes. That's the samecreate_app()-for-a-CLI-script patternscripts/create_example_screens.pyandscripts/fix_admin_roles.pyuse, which is harmless for an occasional by-hand admin task but becomes a recurring burst when applied to an automated timer -- one that competes with the CPU-contention-sensitive real-time SDR/demod/SAME-decode path (see 2.228.1'sNice=-3fix below). - Added
Nice=10andIOSchedulingClass=idletosecurity-perimeter-ingest.serviceso its periodic bursts always yield to the real-time services instead of contending with them. The proper fix -- a lightweight DB-only bootstrap instead of the full route-registering app factory -- is bigger scope; tracked for follow-up.
[2.228.1] - 2026-09-07 - Reduce dropped SDR audio chunks under CPU contention
Fixed
- Fixed: the
wbksSDR receiver's SAME/EAS header decoder had produced zero alerts for two weeks (last success 2026-08-24) despite the receiver itself streaming samples normally and RDS still decoding -- while the two network-stream sources (ERN-LUC,WNCI) kept decoding alerts throughout, unaffected. Root cause:services.demod's own exit-stats log showed real dropped audio chunks (dropped=513) on a box running at a sustained load average of 3.5-4.0 on 4 cores; a chunk dropped during the ~1s SAME tone burst fails that header even though average throughput looks healthy.ERN-LUC/WNCIdon't share this failure mode since they receive already-decoded PCM over the network instead of running the CPU-heavy SDR front end (filter/decimate 1.024 Msps IQ down to audio). app_core/radio/demod/rbds_worker.py: four RDS trace log lines were left atINFOinstead ofDEBUG-- one of them explicitly commented "for diagnostics only" -- producing ~845 log lines/minute (98% of the demod service's total log volume) for zero operational value. Downgraded toDEBUG. This alone did not measurably reduce CPU or the drop rate; kept as a legitimate cleanup, not the fix.eas-station-demod.serviceandeas-station-audio.servicenow run atNice=-3(systemd unit change), giving the real-time IQ-to-audio and SAME-decode path scheduling priority over less time-critical services when the box is under contention -- matching the existingNice=-5oneas-station-sdr.service, but one step lower since that service alone is servicing USB reads directly. Post-change,sdr-wbks's recurring Icecast buffer-underrun warnings (previously roughly one every 30s, continuously) dropped to zero in the most recent observation window.
[2.228.0] - 2026-09-09 - Public now-playing API (album art over Icecast, without Icecast)
Added
- New:
GET /api/audio/now-playing-- a public, unauthenticated JSON endpoint (webapp/routes_now_playing.py) returning{source, stream_name, icecast_url, title, artist, album, artwork_url, length}for the station's public Icecast stream(s). Icecast/Shoutcast's in-stream metadata (ICYStreamTitle) is text-only -- there's no field for an image, so album art can never travel inside the audio stream to an external player (VLC, a phone app, a car radio, an embedded widget on another site). This is the standard workaround every real internet radio station uses: a small public "now playing" endpoint the player/widget polls alongside the raw audio. Optional?source=<name>selects a stream in a multi-source deployment; omitted, it uses the first enabled source with a public Icecast mount. - Deliberately a redacted view -- only display-safe fields. None of the machine-describing data the internal (session/local-network-gated)
/api/audio/sourcescarries -- mount/server/port, bitrate, device params, priority -- is exposed, matching the existing public/local/private API tiers documented inapp.py'sPUBLIC_API_GET_PATHS/LOCAL_API_GET_PATHS. - Refactored the ICY metadata field-extraction logic (title/artist/album/artwork_url/length parsing, XML/JSON attribute stripping, URL-decoding) out of
IcecastStreamer._extract_metadata_fieldsinto a standaloneapp_core/audio/now_playing_metadata.pyso both the audio-service process (which still pushesStreamTitleupdates to Icecast itself) and the webapp process (this new endpoint) share one implementation instead of two independently-drifting copies.IcecastStreamer._extract_metadata_fieldsis now a thin backward-compatible wrapper. - Surfaced in the UI:
/audio_monitoringnow shows each public source's "Now Playing API" URL alongside its existing Icecast stream URL, so an operator can find and hand out the endpoint without reading code. - New tests in
tests/test_now_playing_api.py(default/named source selection, 404s for private/unknown/disabled sources, DB fallback when Redis has no fresh snapshot, the public-path registration guard) plus a regression check that the refactor didn't changeIcecastStreamer's own extraction behavior (tests/test_icecast_metadata_url_decoding.py, unchanged, still passing).
[2.227.1] - 2026-09-09 - Fix narrow share-card info column (clipped EXPIRES time, missing content)
Fixed
- Fixed: the landscape share-card's narrow info column (
app_utils/image_export/render.py, info panel <INFO_NARROW_MAX_W) only drew severe-thunderstorm-specific panels -- damage-tier callout, tornado tag, wind/hail stat boxes, storm motion. For any non-severe-weather CAP event (911/telephone outage notices, civil emergency messages, advisories with no convective threat data) every one of those was a no-op, so the card showed nothing but a bare EXPIRES time with a large empty column below it. The column now falls back to the same generic HEADLINE/DESCRIPTION text the wide-column layout always shows whenever none of the weather-specific panels rendered anything. - Fixed:
_draw_expires_block(app_utils/image_export/panels_broadcast.py) drew the absolute EXPIRES timestamp in a fixed 30px font with no width check against the column -- a stamp like "Sep 9 · 8:48 AM EDT" (342px) didn't fit the 284px-wide narrow column, and since that column sits only 8px from the canvas's right edge, the overflow ran past the image boundary and was hard-clipped (visible as "...8:48 AM E"). Now shrinks the value font to fit before drawing, matching the shrink-to-fit pattern already used for the header's event-name title.
[2.227.0] - 2026-09-04 - Perimeter defense: flood control, bad-actor blocklist, http:BL, Edge Defense analytics
Added
- New: nginx-level rate limiting (
/api/20r/s,/login5r/min, bothlimit_req) and a reject-before-the-app rule for WordPress/.env/.git/PHP-shell scanner paths -- this app is pure Python/Flask, so none of those paths are ever legitimate, and they previously fell through to a full 29KB/loginpage render on every scan hit. - New: an updatable known-bad-actor IP blocklist sourced from Spamhaus DROP/EDROP (
scripts/update_bad_actors.sh, refreshed daily viabad-actors-update.timer), merged with a hand-curated local list (config/bad-actors-local.conf), enforced by nginx before any proxy logic runs. Admin UI controls added on Application Settings ("Bad Actor Blocklist" panel,webapp/admin/bad_actors.py): enable/disable toggle, allowlist a false positive, trigger an immediate refresh -- previously only editable by hand over SSH. - New: opt-in Project Honeypot http:BL reputation check on login attempts (
app_core/auth/httpbl.py), auto-banning IPs flagged as harvesters/comment-spammers through the existingIPFilterblocklist (newIPFilterSource.HTTPBL). Configured via Application Settings (enabled flag + access key, both DB-backed rather than only.env); the key is write-only in the UI/API and never round-tripped in plaintext once saved. - New: Security Center "Edge Defense" tab -- visibility into everything the protections above block before it ever reaches the app (none of it showed up in the Traffic tab, which only sees requests Flask actually handled). 24h counts by reason, top blocked IPs/paths, recent events, current blocklist size/state. Fed by a 2-minute systemd timer (
security-perimeter-ingest.timer) tailing the nginx access log (rotation-safe checkpoint by inode+offset) into a newsecurity_perimeter_eventstable; required adding theeas-stationservice user to theadmgroup (it couldn't read the nginx log at all before). - Repeatable across deployments:
install.shandupdate.shboth seed the new nginx control files, enable the two new timers, and (update.sh) re-apply the nginx config diff/SSL-preservation and grant the new group membership on an existing installation, not just a fresh one.
Fixed
- Fixed:
dashboard_status.js,health.js, andsystem_health.htmlkept retrying/api/eas-monitor/status,/api/system_status, and/api/system_healthforever on 401, even though those endpoints are intentionally restricted to local-network/authenticated callers (app.py'sLOCAL_API_GET_PATHS) -- every anonymous visitor's tab polled them indefinitely with no backoff. Each now stops retrying its gated endpoint after the first 401.
[2.226.0] - 2026-09-04 - API Dashboard
Added
- New: Reports -> Analytics -> API Dashboard (
/api-dashboard) shows live request volume, latency (p50/p95/p99) and error rates for every/api/*route, broken out per route -- the usage companion to the existing staticAPI Referencepage, which documents routes but not how they're actually used. The Traffic Analytics dashboard only ever showed a single rolled-up "API hits" count; this is where that traffic gets broken out. - Needed no new request-timing instrumentation: every request already flows through
app.py's existingbefore_request/after_requesthooks intoWebRequestLog(async, buffered -- never a synchronous DB write on the request path). The one gap was that only the raw path was recorded, which would fragment a parameterized route like/api/alerts/<id>into one bucket per ID ever requested;WebRequestLoggained a nullableendpointcolumn (Flask's dotted view-function name) captured alongside it, in the same namespacecompute_api_reference()already keys routes by, so usage data joins directly against each route's docstring/auth metadata. - New
app_core/analytics/api_stats.py(per-route counts, error rates, latency percentiles -- computed in Python rather than a database-sidepercentile_cont, since the same code needs to run on PostgreSQL in production and SQLite in tests) andwebapp/routes_api_dashboard.py. Latency percentiles use nearest-rank over sorted per-route response times. - New tests in
tests/test_api_stats.py.
[2.225.3] - 2026-09-04 - Push CARTO Dark Matter map detail further
Changed
- Changed: the previous fix (2.225.1) made CARTO roads/labels survive the resize pipeline, but only just -- follow-up review against a live render wanted more headroom.
TONE_PRESET_DARK_NATIVE's brightness lift raised from 2.1 to 3.0 and contrast from 1.25 to 1.4, confirmed against the same live alert render: place labels (city names, township names) and road structure are now clearly legible throughout the map inset, not just in isolated spots, while the map still reads as a dark-mode basemap rather than washing toward OSM's brightness.
[2.225.2] - 2026-09-04 - Fix Stream audio sources giving up permanently on HTTP 404
Fixed
- Fixed: a "Stream"-type audio source (
app_core/audio/sources.py'sStreamSourceAdapter) treated an HTTP 404 from its URL identically to 401/403 -- a permanent, unrecoverable error that stops the restart loop for good until someone manually restarts the source. That's wrong for the common case of a Stream source relaying another Icecast source client (e.g. SDRTrunk pushing to its own mount on this server's Icecast): whichever side isn't running yet when the other one starts gets a 404 and, previously, gave up forever even after both sides came up. 404 now keeps retrying on the normal backoff, same as any other transient failure; only 401/403 (genuinely bad credentials) still stop the loop. - New tests in
tests/test_stream_auth.py:test_stderr_pump_marks_404_as_error_but_not_fatalandtest_restart_retries_on_404_instead_of_stopping.
[2.225.1] - 2026-09-04 - Fix washed-out CARTO Dark Matter map detail
Fixed
- Fixed: the CARTO Dark Matter basemap (Settings -> Map Tiles) rendered with no visible roads, place labels, or landcover -- just solid black under the radar/county overlays. Root cause: CARTO's own linework sits only ~50-65 (out of 255) above its near-black background, and that low-contrast signal didn't survive the card's Lanczos tile-resize plus radar-overlay compositing, unlike OSM's much higher-contrast tiles.
TONE_PRESET_DARK_NATIVE(app_utils/image_export/map_style.py) previously left brightness/contrast at identity on the theory that a dark-native source needs no darkening; it now applies a brightness lift (1.0 -> 2.1) and a mild contrast lift (1.0 -> 1.25) so the linework survives downstream resizing, confirmed against a real fetched CARTO tile and a full share-card render of a live alert. - New regression test
test_tone_preset_dark_native_road_survives_the_map_inset_downscaleintests/test_image_export_map_style.py, built from the real ~50-65 contrast measured off a live CARTO tile; the existing preset test was renamed and re-asserted to expect a brightness lift instead of "stays close to source," since identity color ops turned out to be the actual bug.
[2.225.0] - 2026-09-04 - Broadcast-style landscape share card
Changed
- Changed: the landscape (1200×630) alert share card is now a map-dominant broadcast-style graphic, modeled on RyanHallYall/WeatherWise-style warning cards -- the radar map now fills ~75% of the canvas (up from ~50%), with a narrow callout column carrying a bold "DESTRUCTIVE DAMAGE EXPECTED" / "CONSIDERABLE DAMAGE THREAT" box (for the two elevated NWS Impact-Based-Warning tiers), a TORNADO POSSIBLE pill, a hero-sized EXPIRES time, stacked WIND GUST / HAIL SIZE stat tiles, a one-line storm-motion readout, and the safety-instruction block (now titled "WHAT TO DO"). Square/portrait/story cards are unchanged for now.
- This is a restyle, not new data: hail size, wind gust, tornado detection, and storm motion were already parsed (
webapp/admin/api/display_data.py) and already rendered as gauge-style threat cards -- the new narrow column presents the same data as bold callouts/stat-boxes instead, since the wider gauge-card layout doesn't fit the narrower column.render.pyswitches between the two treatments based on the info panel's actual width (layout.INFO_NARROW_MAX_W), so a future wide-info layout keeps working unmodified. - New
app_utils/image_export/panels_broadcast.py(the narrow-column drawers) and a new_draw_stat_boxprimitive indrawing.py;app_utils/image_export/layout.py's landscape preset resized accordingly. - New tests in
tests/test_image_export_broadcast_panels.py.
[2.224.0] - 2026-09-04 - Optional CARTO Dark Matter basemap for the share card
Added
- New: Settings -> Map Tiles (
/admin/map-tiles) lets an operator switch the alert share-card map inset from plain OpenStreetMap raster tiles (the zero-config default) to CARTO's Dark Matter style. OSM tiles are darkened/desaturated in post (tone_basemap()) since they're authored light and label-dense; CARTO's Dark Matter tiles are dark and minimal from the start, so the result reads noticeably cleaner. Requires a free CARTO API key (carto.com/basemaps/apikey, 5M tile requests/month, no approval queue) -- with no key configured, or if a tile fetch ever fails, rendering falls back to OpenStreetMap automatically, so an unconfigured or bad key can never break the map. - New
MapTileSettingsmodel (carto_api_keyencrypted at rest like every other stored credential in this project) andapp_core/map_tile_settings.pyaccessor, following the same dual-path (Flask context / rawdb_session) pattern this session's earlier fix (app_core/crypto.py'sSECRET_KEYenvironment-variable fallback) made safe for the standalone CAP poller to use too. app_utils/image_export/tiles.py: tile cache keys (both the in-memory LRU and the on-disk cache) now carry the provider, so switching providers can never serve a tile that was cached under the other one for the same coordinate.map_style.pygained aTONE_PRESET_DARK_NATIVEpreset (near-identity color ops) for pre-darkened sources, alongside the existing OSM-tuned defaults. The card's tile attribution is now computed once and reused for both the drawn text and the county-label keep-out box, instead of a hand-maintained pixel-width literal.- New tests:
tests/test_map_tile_settings.py(including a regression test reproducing the exact "no Flask app context" bug shape from the earlierapp_core/crypto.pyfix), plus new coverage intests/test_image_export_themes.pyandtests/test_image_export_map_style.py.
[2.222.0] - 2026-09-03 - Replace animated GIF alert export with MP4 video
Changed
- Changed: the animated share-card export for weather alerts (
/api/alerts/<id>/export-image.mp4, reachable from the alert detail page's Export Social Image menu) is now an MP4 (H.264/yuv420p) instead of a GIF. Facebook and most other social platforms transcode an uploaded GIF into a silent looping MP4 on ingest anyway, so encoding straight to MP4 skips that lossy round-trip and sidesteps GIF's 256-colour palette entirely, which was producing visible banding/dithering on real radar reflectivity and multi-megabyte files for a ~10-frame loop. - Same behavior otherwise: plays the radar from ~15 minutes before the alert was issued, then reveals the warning polygon only on the frame matching the real issuance time.
app_utils/image_export/gif_export.pyreplaced byvideo_export.py-- reuses the same per-framegenerate_alert_image()composition, then pipes the rendered PNG frames to ffmpeg (already a system dependency of this project) instead of Pillow's GIF encoder.webapp/admin/api/routes_alert_export_gif.pyreplaced byroutes_alert_export_video.py.- New tests in
tests/test_image_export_video.pyexercise the real ffmpeg binary (already a CI dependency for the audio-source tests) rather than mocking the encode step, including a regression test for scaled card sizes rounding to an odd pixel width/height, whichyuv420pcannot encode without an explicit even-dimension filter.
[2.221.1] - 2026-09-03 - Fix encrypted settings unreadable outside a Flask app context
Fixed
- Bug fix: every
EncryptedString-backed credential (TTS's Azure OpenAI key, Icecast source/admin passwords, SMTP password, Twilio auth token, SNMP community string, Tailscale auth key, Tickstem API key, admin MFA secrets) was silently unreadable from any process without an active Flask app context -- including the standalone CAP poller, which reads settings through its ownsessionmaker()session with no Flask app ever pushed. Decrypting the column raisedRuntimeError: Working outside of application contextdeep inside SQLAlchemy's row hydration (current_app.secret_key), which the poller's own error handling swallowed -- so a fully configured, enabled TTS provider was treated as unconfigured, and every forwarded alert went out tone-only with no spoken narration. The same silent failure applied to any other credential read the same way outside a request. - Root-caused from a real production incident: alert #1057 (a Severe Thunderstorm Watch) was auto-forwarded with no voice narration despite Azure OpenAI TTS being enabled and fully configured in Settings -> TTS.
app_core/crypto.py:_root_secret()/_fernet()now fall back to theSECRET_KEYenvironment variable when there's no Flask app context, rather than exclusively depending oncurrent_app.secret_key. This derives the identical key those processes would get if a Flask app were pushed -- systemd'sEnvironmentFile=/opt/eas-station/.envalready putsSECRET_KEYin every service's environment, poller included.- New regression tests in
tests/test_secret_encryption.pyreproduce the actual bug shape (a realEncryptedStringcolumn read via a rawsessionmaker()session with zero Flask app context, not a mock) -- the existingtest_airchain_fringe_cases.pycoverage for this code path used aMagicMock()session that never touched real column decryption, which is why it didn't catch this.
[2.221.0] - 2026-09-03 - Animated GIF export for weather alerts
Changed
- New: animated GIF share card for weather (
category='Met') alerts, alongside the existing static PNG/WebP export --/api/alerts/<id>/export-image.gif(ratioquery param, same four aspect ratios as the PNG export). Reachable from the alert detail page's Export Social Image menu. - The animation plays the radar in the ~15 minutes before the alert was issued, then reveals the warning polygon for the first time on the frame matching the alert's actual
senttimestamp -- never earlier. A GIF can never imply a warning was active before it really was. app_utils/image_export/radar_loop.py: addedRADAR_LOOP_LEADIN_MINUTES(15) and ashow_polygon/issuedflag threaded throughbuild_radar_loop()andmaps.py's_render_map(). The existing interactive Radar Loop viewer on the alert detail page picks up the same lead-in + polygon-reveal behavior automatically, since it's backed by the same function.radar_loop_hires.py's Level II loop is unaffected -- the lead-in window is opt-in per caller (_needed_timestamps(..., leadin_minutes=...)), not baked into the shared timestamp helper.- New
app_utils/image_export/gif_export.py: reusesgenerate_alert_image()'s full card composition once per radar-loop frame (only the map inset and polygon visibility change between frames), quantized against one shared colour palette so the static header/panels/footer don't flicker between frames. - New tests:
tests/test_image_export_gif.py, plus updates totests/test_image_export_radar_loop.pyfor the lead-in window.
[2.220.0] - 2026-09-03 - Search Settings by field name and stored value
Changed
- New: search box on the Settings hub (
/settings) that matches a setting's field label ("Stream Bitrate") and its currently-stored value ("128") -- not just the label of the settings page it lives on, which is all the existing Ctrl+K command palette could do. Deliberately scoped to the Settings page's own content, not a global header search bar. app_core/settings_search.py: builds the index by querying each mapped settings model's single row and humanizing its columns into labels (stream_bitrate-> "Stream Bitrate", with acronym fixups for SMTP/GPIO/API/TTS/etc.). Covers the ~14 settings pages backed by a simple single-row model (Icecast, TTS, Notifications, Hardware, Location & Alert Filtering, Poller, Heartbeat, Tickstem, Alert Gating, Tailscale, Certbot, Application Settings, EAS Encoder Settings) -- pages that are actions or record lists rather than field/value forms (Backups, RBAC, User Accounts, Environment Variables, the pgweb link) are intentionally left out.- Security, verified by test and live: every field is checked against
app_core.crypto's encrypted-column list and anisinstance(EncryptedString)check before inclusion, plus a manual blocklist for the one plaintext-but-secret-shaped column found (heartbeat_settings.ping_url, a bearer-token URL). A value from an encrypted column (Icecast/SMTP/SNMP/Tailscale/Tickstem credentials) can never appear in a search result, searchable or not, regardless of query. - HardwareSettings (one ~60-column model shared by three pages) is routed by column prefix --
gpio_*to GPIO & Relays,zigbee_*to Zigbee, everything else to Hardware Settings -- rather than lumping every field under one page. - Reuses the exact permission filtering
webapp.navigation._flatten_settings_items()already does for the command palette, so a viewer without access to a given settings page never sees that page's fields in search either -- no separate permission logic to keep in sync. - New tests in
tests/test_settings_search.py.
[2.219.1] - 2026-09-03 - Every page now shows the navigation breadcrumb
Changed
- Root cause found and fixed:
static/js/core/nav-enhance.js's breadcrumb/command-palette indexer only ever scanned the rendered navbar DOM. The Settings section (webapp/navigation/registry_settings.py, ~35 pages -- Icecast, NTP Server, GPIO, TTS, Backups, etc.) renders in the navbar as a single link (its items only ever appear as cards on the/settingshub page), so every one of those pages has been missing a breadcrumb since the feature existed, no matter how it was organized in the registry. Fixed at the root:webapp/navigation/__init__.py'sinject_navigation()now also exposes a flat, already permission-filterednav_settings_itemslist;templates/components/navbar.htmlembeds it as JSON;nav-enhance.jsmerges it into its index. One fix, all ~35 pages, no per-page registry duplication. - Audited every page-rendering route in the app for breadcrumb coverage and fixed the remaining real gaps:
- New registry entries for pages that had never been registered anywhere: Alert-Boundary Intersections, Zone Catalog (Monitor -> Alerts), GPIO Statistics/Interlocks/Pin Map (Monitor -> Station Hardware), SMS Compliance (Help -> About This System).
- Hand-rolled a breadcrumb (matching the existing pattern in
templates/alert_detail.html) on four dynamic per-record pages that can't take a static registry entry: Audio Detail, Received Alert Detail, Manual EAS Activation print view, and the OLED Screen editor (new + edit). Also added one to the global-search results page. /adminand/search(no query) were confirmed as correctly breadcrumb-less: both just redirect (to/settingsand/respectively) rather than rendering a page of their own.
[2.219.0] - 2026-09-03 - Consolidated Firewall settings page; fix Icecast auto-streaming after credential encryption
Fixed
- Fixed Icecast auto-streaming silently disabled since credential encryption shipped (2.218's predecessor, #2552):
eas_monitoring_service.pybuilds its own minimal Flask app for the standalone audio/EAS process, and never setSECRET_KEYon it.app_core.cryptoderives the encryption key for encrypted-at-rest settings (e.g.IcecastSettings.source_password) fromcurrent_app.secret_key, so reading Icecast settings from that process raisedSECRET_KEY is not configuredon every restart, was caught, and fell back to silently disabled -- with no loud, visible error and no periodic retry. All four Icecast streams had been down for hours before this was caught by a listener reporting they couldn't connect. Extracted the secret-key resolution app.py already had (env var, falling back to a shared persisted key file so every process agrees on the same key) intoapp_utils/secret_key.pyand hadeas_monitoring_service.pyuse it too. - Fixed a related, pre-existing gap:
tests/test_radio_audio_monitoring.py'sDummyAdaptertest double didn't implementis_quarantined()or set_start_time, so the realIngestController's background health-monitor thread (which the test registers the double into) threw and logged anAttributeErroron every monitor cycle for that test's duration -- harmless (caught and isolated by design) but noisy. - New: Firewall settings page (
webapp/admin/firewall.py,/admin/firewall, Reports -> Security -> Firewall) -- one place for every host-firewall (UFW) rule the app manages, replacing a real gap where opening Icecast's port required a manualsudo ufw allow 8000/tcpnobody had documented as a required step, and an inline firewall-rule widget that had grown on the LAN NTP server's own settings page:- Host Firewall Baseline -- the existing Security Center UFW check (22/80/443, default-deny), with its one-click fix, now also surfaced here.
- LAN NTP Server (UDP/123) -- the subnet-management UI moved off
/admin/ntp-server(which now just shows status and links here); the underlying/admin/ntp-server/configureendpoint and its behavior are unchanged. - Icecast Streaming (TCP, configured port) -- new: list the subnet(s) allowed to reach the Icecast port, or
0.0.0.0/0for a public stream, with detected-local-subnet suggestions (never applied automatically -- a cloud host's own interface subnet is usually the provider's range, not the operator's LAN). - Extracted
app_core/network_info.py(detect_local_subnets(),validate_cidr()) out ofwebapp/admin/ntp_server.pyso both features share one implementation instead of two copies drifting apart. config/sudoers-eas-stationgained the Icecast-portufw allow/ufw delete allowentries (taggedeas-station-icecast, mirroring the existing NTP entries) -- runupdate.sh(or manually redeploy/etc/sudoers.d/eas-stationfrom this file) on any existing installation, or the Icecast card's Apply button fails with a permission error.- New tests in
tests/test_firewall_admin.py.
- Documented which ports need router port-forwarding for WAN access vs. which must stay closed, in a new "Router Port Forwarding" section of
docs/troubleshooting/FIREWALL_REQUIREMENTS.md-- previously undocumented beyond a one-line troubleshooting note. - Updated
docs/compliance/FCC_26-38_EAS_CYBERSECURITY.md's network-segmentation section (47 CFR SS 11.35(d)(3)) to describe the new consolidated Firewall page as the platform's implementation of that control, and added it totemplates/help.html/templates/about.html.
[2.218.1] - 2026-09-02 - Respect Tickstem's per-plan heartbeat quota when bulk-creating
Changed
- Confirmed live: Tickstem's free tier caps heartbeats at 5 total, well under the 12 critical services this box has. Bulk-creating all 12 at once burned through the quota, failed on the remaining ones with HTTP 402, and would have repeated the same failed attempts on every subsequent click since a failed create doesn't get remembered as "already tried."
TickstemAPIErrornow carriesstatus_code, socreate_all_service_heartbeats()can stop the moment a 402 comes back instead of continuing to retry a request Tickstem has already said it won't honor for any of the remaining services.- The bulk-create route accepts an optional
service_nameslist, scoping the attempt to a specific subset instead of always going for every unmonitored critical service. /admin/tickstemnow shows a checkbox per unmonitored service (with a "select all" convenience) instead of one blind "create everything" button, so a plan near its quota can choose which services matter most.- New tests in
tests/test_tickstem_service_heartbeats.pycovering theservice_namessubset and the stop-on-402 behavior.
[2.218.0] - 2026-09-02 - Per-service Tickstem heartbeats for critical EAS Station subsystems
Changed
- New
tickstem_service_heartbeatstable andTickstemServiceHeartbeatmodel (app_core/_models_tickstem.py): one row per critical service fromapp_core.config.get_eas_services()(the 11 EAS subsystems plus the poller), each holding its own Tickstem heartbeat. - Why per-service instead of one combined heartbeat: Tickstem's ping carries no payload, so a missed ping on one aggregate heartbeat can only ever mean "something's wrong" in the resulting alert. A heartbeat per service, each named on Tickstem's side (e.g. "EAS Station -- eas-station-poller.service"), means a missed ping names the exact subsystem that failed.
app_core/tickstem_client.pygainedcreate_heartbeat(),set_heartbeat_status(), anddelete_heartbeat(), mirroring the existing Monitors API functions but against Tickstem's Heartbeats API -- fully outbound, no public URL needed (unlike the existing Monitors integration, which requires one).app_core/heartbeat_worker.py's background loop now also drives these: each row is pinged only when it's both due (its owninterval_secs) and its matching systemd service is currently active, read from the same cached snapshotget_system_health()and the System Health page share. The loop moved from "sleep for the configured interval" to a fixed 60s tick with each signal checking its own last-ping timestamp, since multiple independently-scheduled heartbeats can no longer share one sleep duration.- New admin UI on
/admin/tickstem: a "Monitor N Remaining Services" button bulk-creates heartbeats (via the already-saved Tickstem API key) for everyget_eas_services()entry that doesn't have one yet, plus a per-service table with individual pause/resume/delete. - New tests in
tests/test_tickstem_service_heartbeats.py: the due/active gating logic (the core of why this feature works), and the three newtickstem_clientfunctions.
[2.217.1] - 2026-09-02 - NetBIOS hostname fallback, and fix chronyc's own resolution clashing with it
Fixed
- Added a NetBIOS (NBT-NS Node Status, UDP/137) fallback in
_lookup_client_hostname()for when reverse DNS comes up empty -- the common case for a Windows PC on a LAN whose resolver has no PTR records for it, since Windows doesn't register itself in DNS by default. Hand-rolled the query/response (RFC 1002 wildcard-name encoding, minimal Node Status response parser) rather than adding a dependency for a two-message UDP protocol. - Fixed a regression the hostname feature itself exposed once reverse DNS started working on a given deployment:
chronyc clients' own first column does its own reverse-DNS resolution and truncates long names to fit a fixed-width text column, so once PTR records resolve,_client_summary()'s parser -- which expects that column to always be a numeric IP -- started reading truncated hostnames instead. Fixed by adding-n(raw IPs only) to thechronyc clientscall, and updated the scoped sudoers entry inconfig/sudoers-eas-stationto match the new exact command (/usr/bin/chronyc -n clients). - Not a chrony bug or anything wrong with this feature in isolation -- it only ever showed up because this same session's earlier fix made PTR resolution actually work end-to-end for the first time, which is exactly the condition needed to expose it.
[2.217.0] - 2026-09-02 - Show hostnames in the LAN NTP Server's Recent Clients list
Changed
webapp/admin/ntp_server.py's_client_summary()now attempts a reverse-DNS (PTR) lookup for each client IP via_reverse_dns(), capped at 1 second so a client with no PTR record -- normal for most phones, laptops, and IoT devices on a home LAN -- can't stall the whole list.templates/admin/ntp_server.htmladds a "Hostname" column (both the server-rendered initial table and the JS-driven Refresh path), showing—when no record resolves.- Wrapped the clients table in
.table-responsiveand added.text-break-anywhereto the IP/hostname cells while touching this template, per the mobile-friendly requirement indocs/development/AGENTS.md-- the new column made overflow at narrow viewports more likely. - Whether this actually shows anything depends entirely on the deployment's DNS setup: a resolver that doesn't serve PTR records for RFC1918 addresses (e.g. a public DoH/DoT forwarder, which most
resolv.confs on this kind of deployment end up pointing at) will show—for every client regardless of how well the feature works, since there's no PTR data to find. A home router that also acts as local DNS for its DHCP leases is the common case where this actually resolves something.
[2.216.3] - 2026-09-02 - Fix the LAN NTP Server's Recent Clients list always showing empty
Fixed
- A second, independent sandboxing bug in the same feature:
chronyc clients(used to populate the "Recent Clients" list) connects to chronyd over a UNIX socket at/run/chrony/chronyd.sock, whose containing directory isdrwx------, owned by_chrony.eas-station-web.service'sCapabilityBoundingSet=caps what thesudo-escalated root process inside its sandbox can do, and it was missingCAP_DAC_OVERRIDE-- so that "root" can't traverse a directory it doesn't own. chronyc silently fell back to the legacy cmdmon protocol and got501 Not authorised. - This failed on every single page load, not just before a client's first sync:
_client_summary()inwebapp/admin/ntp_server.pytreated anychronyc clientsfailure identically to a genuinely empty list ({"available": False, "clients": []}), and logged nothing, so the page always read "No non-local clients have queried this host yet" regardless of real client activity. - Fix: added
CAP_DAC_OVERRIDEtoCapabilityBoundingSet=insystemd/eas-station-web.service. Confirmed via the same sandbox-reproduction method as 2.216.2:chronyc clientsreturns501 Not authorisedinside a transient unit mirroring the service's exact sandbox, and returns the real client list (three hosts, in this case) once the capability is added. - Also added a
logger.warning()in_client_summary()'s failure path so a future occurrence of this class of bug shows up injournalctl -u eas-station-webinstead of silently rendering as "no clients yet" -- the UI copy itself is unchanged (a fresh server legitimately has zero clients, and that's not an error), only the previously-silent failure case now leaves a trace.
[2.216.2] - 2026-09-02 - Fix the real cause of the LAN NTP Server's "Read-only file system" write failure
Fixed
- Corrects the diagnosis in 2.216.1: the
sudo teewrite of/etc/chrony/conf.d/eas-station-ntp-server.confwas never transient.eas-station-web.serviceruns withProtectSystem=strict, which bind-mounts the whole filesystem read-only inside that service's own mount namespace except for the paths listed in itsReadWritePaths=./etc/chronywas never added to that list when the LAN NTP Server feature shipped in 2.216.0, so every write from inside the running service hitsRead-only file system-- 100% of the time, not intermittently. The 2.216.1 write-up tested the path from an ordinary root shell, which sits outside the service's mount namespace and so is not sandboxed the same way; that made the write look "directly writable... afterward" when in fact the service itself could never write it. Confirmed by reproducing live:sudo journalctl -u eas-station-webshowed the identicaltee: ...: Read-only file systemfailure again at 16:30:01, a second and unrelated Apply click roughly 20 minutes after 2.216.1's commit claimed it had "self-resolved... no repeat since." - Fix: added
/etc/chronytoReadWritePaths=insystemd/eas-station-web.service, alongside the existing/etc/nginx//etc/letsencrypt//etc/icecast2entries this same service already needs write access to for other admin features. - The retry-once logic added in 2.216.1 (
_write_chrony_conf()) is left in place -- harmless now that the underlying write actually succeeds, and cheap insurance against a genuinely transient failure in the future -- but it is no longer the fix for this bug. - Manually recovered the live host's config after reproducing the bug: the conf.d fragment had been briefly overwritten with placeholder content while confirming the failure was reproducible outside the sandbox, then restored to
allow 192.168.8.0/24/local stratum 10and chrony restarted before this fix landed.
[2.216.1] - 2026-09-02 - Retry the LAN NTP Server's config write once on a transient failure
Changed
- Confirmed live:
webapp/admin/ntp_server.py'ssudo teewrite of the chrony conf.d fragment hit a transientRead-only file systemerror for about 15 minutes right after this feature's first deploy, then self-resolved on its own with no code change and no repeat since. The exact trigger was never confirmed — it wasn't real filesystem corruption (no matching kernel/dmesg errors, and the target path is directly writable when tested from inside the service's own mount namespace afterward) — so this isn't a root-cause fix, but a short retry costs nothing on the common (successful) case and may ride out a similarly brief blip without the admin needing to notice the error and click Apply again themselves. - Extracted the write into
_write_chrony_conf(), which retries once after a 1-second pause on either a non-zero exit or a raised exception before giving up and surfacing the existing error message. - New tests in
tests/test_ntp_server.py: unit-level coverage of_write_chrony_conf()(succeeds without retrying, recovers after one failure, gives up after exhausting the retry, recovers from a raised exception the same as a bad return code) plus one integration-level test confirming the/configureroute as a whole succeeds when the underlying write recovers on its retry.
[2.216.0] - 2026-09-02 - Add a LAN NTP Server admin page
Added
- chrony is installed and running on every deployment (it's the box's own time sync, and on GPS-HAT hardware the stratum-1 source), but by default it only ever acts as a client -- nothing in
chrony.confgrants any subnet permission to query it, so a request from a LAN device is silently ignored. Which subnets should be trusted is inherently a per-deployment decision (a home LAN, an office VLAN, a Tailscale range, or nothing at all) with no correct default, so this needed to be admin-configured rather than somethinginstall.shcould set up once. - New Settings -> Network -> NTP Server page (
webapp/admin/ntp_server.py,templates/admin/ntp_server.html): list the subnet(s) allowed to query this host, enable/disable, and see recent clients with how long ago each last synced (parsed fromchronyc clients). Deliberately stateless likewebapp.admin.mail_server-- the chrony conf.d fragment on disk is the single source of truth, read back fresh on every status check rather than mirrored into a DB row that could drift from what's actually applied. - The firewall side follows the same idempotent, tag-scoped reconciliation
webapp.admin.security_checkup's UFW fix established: every rule this feature creates carries a fixedeas-station-ntp-serverUFW comment, and only rules carrying that exact comment are ever added or removed through it -- an operator's own rules for other ports/services (Icecast's 8000, pgweb's 8081, etc.) are never inspected or touched. Disabling clears both the chrony config and every tagged firewall rule; nothing lingers. - New sudoers entries (
config/sudoers-eas-station) scoped to exactly what this needs: writing the conf.d fragment, restarting chrony,chronyc clients, and the twoufw ... comment eas-station-ntp-serverentries.update.shalready re-syncs sudoers on every run, so this reaches every existing deployment on next update with no manual step. - New
tests/test_ntp_server.py(34 tests): CIDR validation/normalization, conf-file and UFW-rule parsing, the client-list parser (including the never-synced and localhost-exclusion cases), and the full configure route (enable/disable, dedup, validation errors, and that a disable only ever removes this feature's own tagged rules).
[2.215.3] - 2026-09-02 - Fix two more System Health dashboard accuracy bugs
Fixed
- SMART health falsely reported "passed" when smartctl never actually got any data.
_collect_smart_health's exit-code fallback (used whenever smartctl's JSON has nosmart_statusblock) only checked bits 3-7 of smartctl's exit code for "disk problem" bits, never bits 0-2 ("command line did not parse" / "device open failed" / "SMART command failed" -- i.e. no real data was ever retrieved at all). Found on a Vultr KVM instance: its virtio-blk-backed/dev/vdahas no ATA/NVMe protocol to the underlying disk at all (true of virtio-blk generally, not specific to this app or provider), so every smartctl device-type probe returns exit code 2 with a mostly-empty but validly-parsing JSON report -- which sailed straight through "bits 3-7 clear -> passed" and got shown as a healthy drive despite smartctl never having successfully talked to anything. Now bits 0-2 short-circuit tooverall_status: "unknown"with a realerrormessage (smartctl's own JSONmessages, or a generic exit-code explanation) -- the dashboard's existing (already-correct) "Unknown" badge + error-alert rendering picks this up with no template changes needed. Addedtests/test_smart_health.py(8 tests: the execution-failure paths, plus regression coverage that the bits-3-7 "passed"/"failed" inference and the NVMecritical_warningpath are unaffected). - A failed systemd unit for a service retired from the codebase was completely invisible to the System Services panel. Found
eas-station-eas.service(folded into-audio/-demodduring the hardware subsystem split) sitting insystemctl --failedas a stalenot-found/failedunit, killed by a stop timeout two weeks earlier --update.shnever disables/removes units for services that get renamed or removed, so a box running since before such a change is left with a permanent stale failure record.app_utils/system/services.py's_collect_systemd_servicesonly ever checked a fixed allowlist (EAS_SERVICES/POLLER_SERVICESinapp_core/config/services.py), so a unit that fell off that list was never checked at all, no matter how broken. Added_collect_orphaned_failed_services: asks systemd directly (systemctl list-units --state=failed <prefix>-*) for any failed unit matching the service prefix regardless of whether the allowlist still knows its name, excluding template-instantiated units (e.g.eas-station-failure-recovery@<subsystem>.service) which are legitimate dynamic infrastructure, not retired services. Surfaces as a normal "EAS Station" category entry in the services list plus an actionable issue (includes thesystemctl reset-failedcommand to clear it). Cleared the stale record on the deployment it was found on. Addedtests/test_orphaned_services.py(6 tests, including a_collect_systemd_servicesintegration test verifying the orphan flows through tosummary/issues).
[2.215.2] - 2026-09-02 - Fix the one-click upgrade's live progress feed showing nothing useful
Fixed
- Admin -> Operations' "System Upgrade" progress panel (
get_upgrade_progressinwebapp/admin/maintenance/routes_operations.py) read exclusively fromjournalctl -u eas-station-update.service, butupdate.shredirects its own stdout/stderr to/var/log/eas-update.logright after its root check (exec 1>>"$LOG_FILE" 2>&1) -- that redirect replaces the fd 1 the systemd unit handed the script, so none of update.sh's actual output (everyecho_step/echo_info/... line, including the=== UPDATE RESULT ===marker the endpoint looks for) ever reached the journal. All the endpoint could see was sudo/PAM session noise from the commands update.sh runs, plus the unit's own bare start/stop lines -- confirmed against a real captured run, where the journal held nothing usable while the log file had the full step-by-step output including the final result marker. - Added
_tail_update_log()and made it the primary source forget_upgrade_progress; the journal is now only consulted for its unit-lifecycle lines (Failed with result/Deactivated successfully), kept as the fallback for a crash so early update.sh never got to write anything to its own log, per the existing_classify_upgrade_log_linelogic. Only the most recent journal lifecycle line is used, so a stale entry from a previous run sitting in the same 500-line window can't override this run's own log-file content. - Also fixed the
/opt/eas-station/scripts/lib/ui.sh: line 866: /dev/tty: No such device or addressnoise visible in that same captured run's log: three ofscripts/lib/ui.sh's TTY-write helpers (whiptail()'s wrapper,ui_gauge_stop(), and thecleanup_on_exit()trap) touched/dev/ttyunconditionally instead of checking the existing_UI_HAS_CONTROLLING_TTYflag every other TTY write in the file already checks -- irrelevant interactively, but update.sh's one-click path runs viasystemd-runwith no controlling terminal at all, where2>/dev/nullon the same line does not suppress the error (confirmed empirically: bash reports a failed redirection to the current stderr before any later redirection on the same command line takes effect, regardless of what that later redirection points to).cleanup_on_exit()runs on every single script exit via itsEXITtrap, so this fired on every non-interactive run, successful or not, adding an ugly stray line to what the UI now actually surfaces. - Updated
tests/test_upgrade_progress.py's endpoint tests to mock the new_tail_update_log()primary source instead of treatingget_systemd_logsas authoritative, added direct tests for_tail_update_log()(missing file, ordering,max_linestruncation), and added a regression test for the stale-journal-line-vs-fresh-log-file ordering fix.
[2.215.1] - 2026-09-02 - Fix Checkup tab misreporting an active UFW firewall as inactive
Fixed
- The Checkup tab added in 2.215.0 (
webapp/admin/security_checkup.py) reported "UFW is installed but not active" on a host wheresudo ufw status verboserun interactively worked fine and UFW genuinely was active, default-deny-incoming, with the baseline ports allowed. Root cause:eas-station-web.service'sCapabilityBoundingSetgrantssudoenough to reach uid 0, but doesn't includeCAP_NET_ADMIN, whichiptables-nftneeds just to read the ruleset via netlink -- so under the actual service (not an interactive shell, which isn't capability-bounded the same way) the command failed withCould not fetch rule set generation id: Permission denied (you must be root), and_run()'s nonzero-exit fallback silently parsed that failure as "inactive." - Added
CAP_NET_ADMINtosystemd/eas-station-web.service'sCapabilityBoundingSet. Reproduced the failure and confirmed the fix withsystemd-runtransient units matching the service's exact capability set, rather than by trial-and-error on a live box -- this bug would otherwise reappear on every deployment using this unit file, not just the one it was found on.
[2.215.0] - 2026-09-02 - Security Center "Checkup" tab: detect and fix a missing host firewall
Fixed
- Found (on a real deployment, by hand) a host running with no firewall at all:
install.shonly configures UFW automatically on a fresh install (v2.19.7+), andupdate.shnever re-runs that one-time provisioning — a deployment first installed before that version, or one where UFW was later removed, stays silently exposed through every subsequent application update. Also found thatFail2banSettings.enabledcan betruein the database while theeas-stationjail was never actually loaded — the enforcement toggle looked "on" while nothing was actually being mirrored to the host firewall. - Added a new Checkup tab to Security Center (
webapp/admin/security_checkup.py,/admin/security-checkup/status,/admin/security-checkup/fix-ufw): detects whether UFW is installed, active, default-deny-incoming, and has the baseline 22/80/443 rules, and reuseswebapp.admin.fail2ban's already-accurate live jail state (it distinguishes the stored "enabled" flag from the realactuator_jail_loadedcheck) rather than duplicating that logic. A "Fix now" button reproducesinstall.sh's own baseline UFW setup as an idempotent, web-triggered action — no SSH required — without touching any rule an operator has added beyond that baseline (Icecast, pgweb, etc.). - New sudoers entries (
config/sudoers-eas-station) scoped to exactly the six commands the fix needs, following the same least-privilege pattern as every other privileged action in this file. - New
tests/test_security_checkup.pycovers the UFW status parser against captured real output: inactive, properly baselined, active-but-missing-a-port, and the specific dangerous misconfiguration (active with a default-allow-incoming policy) this check exists to catch.
[2.214.0] - 2026-09-02 - Encrypt stored credentials at rest and pepper password hashes
Changed
- Every stored credential in the database was plaintext: Icecast source/admin passwords, the Azure OpenAI TTS key, SMTP password, Twilio auth token, the SNMP community string, the Tailscale pre-auth key, the Tickstem API key, and per-user TOTP (MFA) secrets. Found while investigating a related browser-exposure bug (2.213.2/2.213.3) and confirming passwords are salted (they are, via werkzeug's scrypt) -- these reversible secrets weren't, because hashing doesn't apply to a credential the app has to hand back to a third-party API.
- Added
app_core/crypto.py: anEncryptedStringSQLAlchemy column type (Fernet, keyed via HKDF-SHA256 derived from the app'sSECRET_KEY-- no new required env var) that encrypts on write and decrypts on read transparently, so every existing read/write call site kept working unchanged. Legacy plaintext rows are tolerated (decrypted as-is) and get encrypted automatically on next save; aSECRET_KEYrotation fails closed (empty string, logged) instead of crashing. - Applied it to all nine columns above. Added migration
20260902_encrypt_stored_secretswidening them fromVARCHARtoTEXT, since Fernet ciphertext runs longer than the plaintext it replaces. - Also found
TailscaleSettings.auth_keyhad the same browser-exposure bug already fixed for the TTS key: pre-filled in plaintext in both the page template and the/api/tailscale/settingsJSON response. Fixed the same way (masked into_dict(), field left blank on page load, blank submission preserves the existing key) with one addition -- an explicit "clear the saved key" checkbox, since blank already had a meaning here (switch to browser-based login) distinct from "no change." - Added a server-side password pepper (a second HKDF-derived secret, independent of anything in the database) to
AdminUser.set_password/check_passwordand MFA backup-code hashing, so a stolen database dump alone isn't enough to brute-force credentials offline even with correct per-hash salts. Existing pre-pepper hashes/backup codes still verify via a fallback check and upgrade transparently in place on next successful use -- no forced password resets. - New tests:
tests/test_secret_encryption.py(encrypt/decrypt round-trip, legacy-plaintext tolerance, key-rotation failure mode, at-rest verification via a raw SQL read, peppered-hash verification, pre-pepper hash/backup-code upgrade paths).
[2.213.4] - 2026-09-02 - Add the standard page header to the documentation viewer
Added
doc_viewer.html(the single-document reader behind/docs/<path>and the policy pages) rendered straight into a breadcrumb + card with no page header, unlike its sibling pagesdocs_index.html,docs/search.html, anddocs/rbac_visual.html, which all use the standardcomponents/page_header.htmlcomponent. Added the same header, using the page's resolved title.
[2.213.3] - 2026-09-02 - Surface real playback errors on the TTS admin test player
Changed
- The Test TTS and Pronunciation Preview
<audio>players had noerrorevent handling at all: a genuine browser-side playback failure (bad decode, unsupported source, network error) rendered as the native control's bare "Error" label with zero diagnostic text, while the generated audio itself could be perfectly valid — confirmed by regenerating the same request server-side and validating the WAV withffprobe/ffmpeg(cleanpcm_s16le, 16kHz mono, decodes with no errors). - Added a shared
wireAudioErrorReporting()listener on bothttsAudioElementandpreviewAudioElementthat reads the element'sMediaErrorcode and shows a concrete, human-readable message in a visible alert instead of leaving the user with an unexplained "Error" label. - Also reset the new error panel at the start of each test run, alongside the existing audio-player visibility reset, so a stale error from a previous attempt can't linger.
[2.213.2] - 2026-09-01 - Fix incomplete hostname checks in the CAP poller's endpoint classification
Fixed
- CodeQL flagged
poller/cap_poller.py:2044(elif 'weather.gov' in endpoint.lower()) as "Incomplete URL substring sanitization" on PR #2549: a plain substring check matches a malicious or misconfigured endpoint likehttps://evil.example/weather.govorhttps://weather.gov.evil.com, not just the real NOAA API. - The same anti-pattern existed at five other call sites classifying
self.cap_endpointsentries as NOAA/IPAWS/CUSTOM for logging and source-tagging (poll_and_process,get_poller_status, the startup endpoint log, the poll-summary log, and the zone-code-rebuild filter). - Added
_endpoint_host_matches(url, domain), which parses the URL withurllib.parse.urlparseand compares the actual hostname (exact match or subdomain) instead of doing a substring search, and switched all six call sites to use it.
[2.213.1] - 2026-09-01 - Isolate per-item failures in the CAP poller's processing loops
Changed
- The 2026-08-31 outage (fixed in 2.211.2) was caused by one unhandled data-shape variance in one alert's
<references>field crashing the entire poll cycle, not just that alert — becausepoll_and_process()'s main per-alert loop had only onetryaround the whole cycle, not one per alert. That specific field bug was fixed, but the structural gap that let it take down every other alert in the batch was not. - An investigation this session (prompted by a broader stability review) found four loops in
poller/cap_poller.pywith the same shape — one bad item's exception propagating out of the loop and aborting everything else in that batch/cycle — while confirming every other external-data ingestion point in the codebase (audio capture, GPS parsing, GPIO events, boundary uploads) already isolates per-item failures correctly. - Wrapped each loop's per-item body in its own
try/exceptthat logs the offending item's identifier with a full traceback and moves on to the next item:poll_and_process()'s main per-alert loop (the one that caused the outage),fetch_cap_alerts()'s per-alert dedup/normalize loop,_parse_ipaws_xml_feed()'s per-<alert>XML conversion loop, and_process_cap_references_cancellation()'s per-reference loop (a single Cancel message can reference several prior alerts; one bad reference no longer blocks the others from being cancelled). - Also added
db_session.rollback()to the per-alert catch in the main loop:_process_cap_references_cancellation()mutates ORM objects and commits conditionally with no rollback path of its own, so a failure partway through one alert could otherwise leave dirty, uncommitted session state that cascades into the next alert's processing. - New regression tests (
tests/test_cap_poller_per_item_isolation.py) feed one malformed item alongside valid ones into each of the three functionally-testable loops and assert the valid items are still processed; the fourth (poll_and_process()itself, which needs a full Flask/DB context to exercise functionally) is guarded structurally, asserting the try/except-with-rollback wrapping is present around the exact loop that crashed in production.
[2.213.0] - 2026-09-01 - Reorganize the setup wizard into collapsible sections
Changed
- The setup wizard at
/setuprendered all ~35 configuration fields as one flat, unbroken form with no section headers, no progress indication, and no visual grouping — despite the backend already modeling the config as 7 logical sections (WIZARD_SECTIONSinapp_utils/setup_wizard.py). A first-time user got a wall of inputs before being told what any of it meant. templates/setup_wizard.htmlnow renders those sections as a Bootstrap accordion with real headers, descriptions, and per-section field counts. Location Settings and EAS Broadcast — the two every install needs — are open by default; Core Settings, Audio Ingest, Icecast Streaming, Text-to-Speech, and Hardware Integration start collapsed. A section auto-expands regardless of category if one of its fields fails validation, so a resubmitted error can never end up hidden behind a collapsed header.- Added a "Before you start" intro panel explaining what EAS Station is and what information to have ready, so the field wall isn't the first thing a new user sees.
- Fixed a real bug found while implementing this: only
SECRET_KEYwas blanked in the form when it already held a real, install.sh-configured value — the fourPOSTGRES_*credential fields (explicitly commented "NOT shown in wizard, managed by install.sh" in the code) were still rendered, unrecognized, prompting a user to re-enter database credentials they never chose.webapp/routes_setup.py's new_is_managed_field_present()generalizes the SECRET_KEY-only check to every system-managed field. - That surfaced a deeper, separate bug: those four
POSTGRES_*fields are dead weight.app.pyreadsDATABASE_URLdirectly and raises at startup if it's missing — there is no discrete-variable fallback in the running app (app_core/config/database.py::build_database_url()still supports one, butapp.pynever calls it), andinstall.sh/.env.exampleboth only ever writeDATABASE_URL. So on every current install, the fourPOSTGRES_*fields never held a value at all, and the wizard could never recognize them as configured no matter what. Replaced them with a singleDATABASE_URLfield (_validate_database_url(), matching the field the already-working Admin → Environment page manages), which now correctly shows "Already configured" likeSECRET_KEYdoes. - The same stale-variable assumption was live in a second place: the Diagnostics page's
check_environment_config()checkedPOSTGRES_PASSWORDas a "critical" variable, which was therefore always reported "not set" on every current install — a permanent false-positive warning. Fixed to checkDATABASE_URL's embedded password instead. - Verified live via CDP screenshot against the actual running instance, both before and after this additional fix: intro panel and section badges render correctly, Location/EAS Broadcast open with real pre-filled data, all four optional sections collapsed, Core Settings expands on click and shows the new "Already configured" placeholder for both
SECRET_KEYandDATABASE_URL. Checked at a 360px mobile viewport with no new horizontal overflow introduced. - Documented the new section layout in
docs/guides/SETUP_INSTRUCTIONS.md.
[2.212.1] - 2026-09-01 - Add friendly error pages for gateway-down and oversized-upload failures
Added
- Previously, if the Flask app (gunicorn, behind nginx on port 5000) was down or restarting — mid-deploy, crashed, or overloaded — nginx served its bare stock "502 Bad Gateway" page, which gives a visitor no information and no path forward. Same problem for an upload over the size limit: nginx's bare stock "413 Request Entity Too Large" page.
- Added
static/errors/gateway-down.html, a self-contained page (no external assets besides the wordmark, which nginx also serves directly) explaining the web dashboard is temporarily unavailable, auto-rechecking every 15 seconds, and — importantly — reassuring the visitor that alert monitoring, the CAP poller, audio decoding, and GPIO/transmitter control are independent background services unaffected by the web app being down. Wired intoconfig/nginx-eas-station.confviaerror_page 502 503 504pointing at aninternal-onlylocationblock, so nginx serves it directly without proxying to the (unreachable) backend. - Added
static/errors/upload-too-large.htmlfor oversized uploads, listing the size limit and practical next steps (lower bitrate, split shapefile components, or ask an admin to raise the limit). - Discovered nginx's own
error_page 413doesn't render either custom page over HTTP/2 — a documented nginx limitation where the oversized-body check happens at the protocol/framing layer, bypassingerror_page/locationentirely, confirmed still present in nginx 1.26.3. Since HTTP/2 is nginx's default over HTTPS and what most browsers negotiate, this would have meant most real users never saw the custom page at all. Fixed at the right layer instead:app.pynow setsMAX_CONTENT_LENGTH = 100 MBand a@app.errorhandler(413)that renderserror.html(JSON for/api/*) — a normal application response is unaffected by the HTTP/2 quirk regardless of protocol, since it isn't an early protocol-level rejection. nginx's ownclient_max_body_sizeis raised to 110 MB as a hard backstop above the app's real limit, so the staticupload-too-large.htmlpage (which does still have the HTTP/2 limitation) is now only ever reached by a payload that exceeds even that backstop, over HTTP/1.1. - Verified live: stopped
eas-station-web.serviceand confirmed the gateway-down page renders at HTTP 502 (internal-only guard also confirmed via 404 on direct access); sent an oversized multipart upload and confirmed nginx's own 413 page over HTTP/2 vs. the custom page over forced HTTP/1.1, which is what led to the app-level fix above.
[2.212.0] - 2026-09-01 - Move the relay lead-in/lead-out from embedded audio silence to program-level GPIO timing
Changed
- 2.211.3 (below) added the relay lead-in as 1 second of silence embedded directly in the generated WAV. That was the wrong layer: resend/replay (
/messages/<id>/resend) plays back the stored audio bytes from the original broadcast rather than regenerating them, so a message generated before this fix deployed could never retroactively show the lead-in — confirmed live by resending a pre-fix alert and finding no lead-in, since resend by design never re-runs audio generation. More broadly, baking transmitter-stabilization silence into the audio content meant every consumer of that audio (Icecast stream listeners, FCC-compliance exports, archived recordings) got artificial dead air mixed into the actual alert, permanently and unadjustably. - The relay lead-in/lead-out is now purely a program-level GPIO timing concern:
BROADCAST_LEAD_IN_SECONDS/BROADCAST_LEAD_OUT_SECONDS(both 1.0s,app_utils/eas.py) are applied astime.sleep()calls by every caller that drives the airchain — immediately afterset_broadcast_active()and before real playout begins (lead-in), and immediately beforeclear_broadcast_active()after playout ends (lead-out) — in all four broadcast paths:EASBroadcaster.handle_alert()(automatic CAP-poller alerts and OTA-relay forwarding), the manual send route (webapp/eas/workflow.py), the RWT scheduler (app_core/rwt_scheduler.py, both the automated weekly test and the operator-triggered "Send Test RWT"), and the resend script (scripts/resend_eas_broadcast.py). Because resend replays whatever audio is stored, this also means resend now gets correct relay lead-in/lead-out timing for any stored message going forward, regardless of when that message's audio was generated. duration_secondspassed toset_broadcast_active()at each call site now includes both paddings so the Redis marker's TTL and the browser countdown overlay reflect the true on-air window;header_seconds/eom_seconds(the countdown's phase boundaries) are padded by the lead-in only, since they anchor to when real audio actually starts.- Reverted the embedded-silence approach entirely: removed the lead-in silence added to
EASAudioGenerator.build_files()'s'same'segment, removed the pre-existing unconditional trailing-silence tail in bothbuild_files()andbuild_manual_components()(the default case when no post-alert chime is configured), and removedbuild_manual_components()'s now-deadsilence_before_headerparameter along with its lead-in silence branch. No GPIO-subprocess code changes were needed —services/gpio/alert_indicators.pyalready keys/releases the relay purely off thebroadcast_activemarker's edges, so the callers controlling when that marker flips is exactly the right layer for this.
[2.211.3] - 2026-09-01 - Add missing lead-in silence before the SAME header on automatic broadcasts
Added
EASAudioGenerator.build_files()— the path used for every automatic and forwarded alert (CAP poller auto-forward, OTA relay) — started the composite audio right on the first SAME header FSK bit when no pre-alert chime was configured, the default. The GPIO subprocess keys the relay off thebroadcast_activemarker, which tracks actual audio playback, so the transmitter got no lead time to come up and stabilize before the header burst — unlike the tail end, which already had a full second of trailing silence after the EOM (holding the relay a second past end-of-message), and unlikebuild_manual_components()(the manual-send/RWT path), which already had this same lead-in silence.- Fixed by adding the same unconditional 1-second lead-in silence
build_manual_components()already uses, folded into the'same'audio segment soheader_seconds(read by the caller that drives the countdown overlay) still measures true elapsed time from the start of the composite audio. - No GPIO code changes were needed — the relay already keys and releases off actual audio playback duration via the
broadcast_activemarker's edges, so extending the audio symmetrically on both ends was sufficient to extend the relay hold symmetrically too.
[2.211.2] - 2026-09-01 - CRITICAL: fix a poller crash that stopped all alert ingestion for ~9 hours
Fixed
- The CAP poller crashed on every polling cycle starting 2026-08-31 ~20:54 EDT, silently dropping every alert fetched from NOAA/IPAWS for roughly 9 hours until diagnosed and fixed live.
poller/cap_poller.py's(properties.get('references') or '').strip()assumed CAP's<references>field is always a string (sender,identifier,senttriples, space-separated, per CAP 1.2 §3.3.2.3), but api.weather.gov's JSON API represents the same field as a list of{identifier, sender, sent, @id}objects instead. The first "Update" message carrying that shape (a Heat Advisory) crashedAttributeError: 'list' object has no attribute 'strip'— and since this check runs unconditionally before the Cancel/Update type check, it took down the entire poll cycle, not just that one alert, for every cycle afterward. - Fixed at the one shared point all three affected call sites already go through:
parse_cap_reference_identifiers()now accepts either shape (the legacy CAP-string format from IPAWS, or api.weather.gov's list-of-objects), extracting identifiers correctly from both instead of assuming a string. - Also added a full traceback (
exc_info=True) to the poller's top-level exception log — the barestr(e)this incident originally logged took real production reproduction plus a temporary diagnostic change to pin down; a traceback would have shown the exact line immediately. - Verified live against the real alert that was crashing every cycle: after the fix, the poller correctly processed and saved it (
status: SUCCESS), restoring ingestion. Added regression coverage intests/test_cap_references_cancellation.pyfor both shapes, including the exact real-world payload that crashed production, and confirmed all existing tests (CAP-string format, Update-supersession) still pass unchanged.
[2.211.1] - 2026-08-31 - Fix an 8x-slower-than-needed query on the /stats dashboard
Fixed
- Continuation of the same load-time investigation as 2.210.3/2.210.4: profiling every section of
/stats's data pipeline individually foundcollect_polling_trend()alone taking 7.7 of the page's ~8.5 total seconds — every other section combined ran in well under a second. - Root cause was ORM overhead, not a missing index or a slow query plan:
poll_historyrows carry adetailsJSON blob and anerror_messageText column, and the function was fetching all columns for every row in the last 30 days (SELECT *, then.all()) via two separate, largely-overlapping queries (7-day and 30-day windows) when onlytimestamp/status/error_message/execution_time_msare ever read.EXPLAIN ANALYZEshowed the raw filtered scan itself takes under 100ms on this table's ~41K rows — the cost was fetching and fully hydrating tens of thousands of wide ORM objects nothing needed. webapp/public/stats_sections/polling.py'scollect_polling_trend()now does one query with.with_entities(...)selecting only the four needed columns, and derives the 7-day subset from the 30-day result set in Python instead of querying twice.- Verified live:
collect_polling_trend()dropped from 7.7s to 0.91s (8.4x), and the full/statspage's data-build time dropped to 1.76s. Output values (rates, counts, p95) verified unchanged. - Verified correctness with the real test suite (
tests/test_public_stats_sections.py) run against a genuinely isolated scratch PostgreSQL database (created and dropped for this run only, matching CI's setup) rather than the live database, since that test file's fixtures wipe several tables between tests — alltest_polling*tests pass; confirmed the only 3 failures elsewhere in the file (unrelated timezone-formatting assertions) are pre-existing and reproduce identically against the unmodified code.
[2.211.0] - 2026-08-31 - Add retention for system_log, shrink the audio metrics retention window
Added
- Found while auditing database size during the load-time investigation (2.210.3/2.210.4):
system_loghad no retention policy at all — confirmed by checking bothapp_core/retention.py's field list andalert_purge.py(which only ever writes audit entries to it, never prunes it). It had grown to 1M+ rows / 850+ MB with nothing capping it. Addedsystem_log_max_age_days(default 90 days, matching the existingaudio_alert_max_age_daysprecedent for operational logs) and wiredSystemLogintoRetentionScheduler's sweep. audio_metrics_max_age_dayslowered from 30 to 3 (on both the model default and the already-persisted settings row, via migration). Nothing in the codebase reads rawaudio_source_metricssamples older than a short troubleshooting window — the "latest value" and recent-trend endpoints only ever need current data, andapp_core/analytics/aggregator.pyalready rolls raw samples into the separate, much smaller, permanentMetricSnapshottable for long-term history. 30 days of raw per-sample data (at ~288K rows/day) was pure bloat with nothing reading it once it aged past a few hours.- New "System Log" field added to Settings → Application → Data Retention alongside the existing fields, following the same pattern (day-count input, help text explaining what it covers and doesn't).
- Verified live: migration applied cleanly, settings API round-trips the new field correctly (GET/PUT/GET), admin UI renders and saves it correctly.
[2.210.4] - 2026-08-31 - Fix a 20+ second query behind GET /api/audio/sources
Fixed
- Same load-time investigation as 2.210.3:
/api/audio/sourceshad a 64-second worst case in the site's own request timing data, and the server log showed realpsycopg2.errors.QueryCanceled: canceling statement due to statement timeoutfailures. Root cause:audio_source_metricsis an append-only time-series table (~3.3 rows/sec across all sources, 1.55M rows / 2.8GB at the time of this fix) and the "get the latest reading per source" helper backing this endpoint fetched every matching row, sorted them all in Postgres, and kept only the first one seen per source in Python -- confirmed viaEXPLAIN ANALYZEat 21+ seconds with a 400+MB disk-spilled sort. - Tried Postgres's native
DISTINCT ONfirst (paired with a new composite index) since it's the idiomatic "top-1 per group" operator -- measured no better (17-18s) even with the index in place, because Postgres has no loose/skip-scan index strategy:DISTINCT ONwith anINlist still has to walk every matching row before deduplicating. Replaced it with N separateORDER BY timestamp DESC LIMIT 1queries instead, one per source (the source list is small -- one row per configured hardware input) -- each one lands directly on an index and stops at the first match. Measured at ~0.15ms per source. - The new composite index (
source_name, timestamp DESC, added via20260831_audio_metrics_latest_index, builtCONCURRENTLYso it didn't lock out the audio service's continuous writes while building on 1.5M+ existing rows) turned out to matter for a real edge case caught during testing: one configured source had gone quiet days before the others, so a plain per-source lookup using only the existing timestamp index had to scan backward through everything every other source wrote since then before finding it. With the composite index, that same lookup is 0.14ms regardless of how stale a given source's data is. webapp/admin/audio_ingest/listing.py's_latest_metrics_by_source()now uses this per-source approach; all 14 existing tests intests/test_audio_source_listing.pypass unchanged, confirming no behavioral regression.- Verified live against the deployed app: the endpoint's real, uncached response time dropped from 20-64+ seconds to under a second.
[2.210.3] - 2026-08-31 - Fix site-wide request stalls caused by CPU-heavy work blocking a gevent worker
Fixed
- Root-caused via the site's own recorded request-timing data (
WebRequestLog, not guesswork):/api/broadcast/state— a trivial Redis-backed status check polled from every page's status widget, called 13,880 times in the sample window — had a 230ms average but a 163-second worst case. The query behind it is fast (cap_alertshas under 1,000 rows;EXPLAIN ANALYZEshowed 6.8ms). The real cause: gevent workers only yield control during I/O, and/alerts/<id>/export-image.png(the social-share image renderer) does 20-30 seconds of pure CPU work — Pillow composition, tile mosaicking — synchronously inside the request handler. With only 2 gunicorn workers, one in-flight image export could stall every other concurrent request routed to that worker, including completely unrelated ones like the broadcast-state poll. --workers 2→4insystemd/eas-station-web.service(idle CPU headroom confirmed: 4 cores, only 2 in use).MemoryMaxraised1500M→4000Mto match — the existing 2 workers were already observed using ~956M+474M combined RSS, so doubling worker count without raising the cgroup limit would have hit the exact OOM-kill failure mode thatMemoryMaxwas originally raised to avoid.- Root-cause fix:
webapp/admin/api/routes_alert_export.py's new_run_off_worker()runs the image renderer on a real OS thread via gevent's own threadpool instead of the request greenlet, so the worker's event loop stays free to serve other requests while it renders.generate_alert_image()already supported running outside a Flask context via an explicitdb_session(used by the CAP poller's notification-email images) — reused that instead of inventing a new pattern, with a dedicatedsessionmaker-backed session per render (the request's owndb.sessionisn't safe to share across threads). - Verified with a live concurrency test against the deployed app: fired the ~24s image export and three
/api/broadcast/statepolls at once — all three fast requests completed in under a second each while the image render was still in flight, and the returned PNG was pixel-identical to the pre-fix synchronous output. - Added
tests/test_alert_export_off_worker.py— regression coverage for the exact bug caught during manual testing of this fix: the first version looked updb.engine(which needscurrent_app) inside the threaded closure instead of on the calling greenlet, so it failed every real request withRuntimeError: Working outside of application context.
[2.210.2] - 2026-08-31 - Remove ambient debug logging from the browser console
Removed
- A repo-wide audit found 142
console.log/console.debug/console.infocalls with no gating flag, firing unconditionally on normal page loads and user actions — page-init narration ("Interactive map page loaded", "Alert detail page scripts loading..."), per-request cache hit/miss chatter, and bare function-entry markers ("toggleRawData called") left over from active debugging. Removed 116 of them (16 instatic/js/*.js, 100 across 10 templates) that were pure narration with no diagnostic value. - One instance was more than noise:
templates/admin/environment.htmllogged the signed-in admin's username and role to the console on every load of the environment-variables settings page — a minor but real thing to not be doing on a page that manages secrets. static/js/core/cache.js's cache hit/miss logging had a "dev only" gate (hostname === 'localhost') that was already broken for exactly this kind of self-hosted appliance, since the app is commonly accessed viahttp://localhost:5000in normal use, not just development — removed rather than tightened, since the logging itself wasn't valuable.- Kept every
console.log/debug/infocall that's a genuine diagnostic: anything inside acatchblock logging a real caught error (still present in every file touched), and the two intentional opt-in developer tools this app ships (window.mapDebug.*on the dashboard map, andinspectBoundaries()'s report generator) — those only produce console output when a developer explicitly invokes them, not on ordinary page loads. - Verified with a live browser console sweep (network cache disabled) across all 12 touched pages against the deployed app — every page loads completely silent, zero console output, zero new errors.
[2.210.1] - 2026-08-31 - Fix heading hierarchy accessibility warnings site-wide
Fixed
static/js/accessibility-utils.js'ssetupHeadingHierarchy()flags any heading whose level skips more than one step deeper than the previous heading in DOM order (e.g. h2 straight to h5) — a real, longstanding gap in nearly every page, since card/widget headers throughout the app were written as bareh5/h6regardless of the page's actual section depth. Fixed every skip across the template tree (82 templates, 1 shared JS component) rather than leaving it as a known issue.- Two-pattern fix, chosen per heading: (1) renumber the tag to the correct sequential level relative to its context, adding a matching
.hNclass (h1, .h1/h2, .h2/etc. are already paired instatic/css/base.css) so the visual size is unchanged even though the semantic level moved; (2) for headings that were really just small styled labels with no real document-outline meaning (a caption over a JSON blob, a stat tile's number label), demoted them the same way but the net effect is identical markup weight, just at a level that doesn't skip. templates/base.html's global footer (Quick Access/Resources/Legal & Info/System Status, previouslyh6) and its Display Units modal title were the single highest-leverage fix — both render on every page, so fixing them once cleared the same warning everywhere without touching per-page templates.- Verified with a static heading-sequence scanner (expanding
{% include %}s to check true rendered order, not just each file in isolation) and a live browser console sweep across 90 real routes against the deployed app — zeroHeading hierarchy jumpwarnings remain anywhere, and no new console errors were introduced.
[2.210.0] - 2026-08-31 - Add boundary layer toggles to the alert detail map
Added
- The alert detail page's Alert Coverage Map fetched all 8 boundary types (counties, fire, ems, electric, townships, villages, telephone, school) on every load but only ever showed counties — the rest were invisible dead weight with no way to see them. Added a row of toggle switches (mirroring the dashboard's existing "Map Layers" panel in
templates/index.html) below the map so any of the 8 can be shown on demand. - Counties stay on by default — no change to the page's existing default appearance, just a way to opt into the others without editing the map itself.
- Layer color swatches reuse
getBoundaryColor(), already defined on this page and shared with the boundary popups.
[2.209.0] - 2026-08-31 - Load incorporated city/village boundaries for the "affected municipalities" display
Changed
- New
scripts/load_municipality_boundaries.pyloads US Census incorporated-place (city/village) boundaries from the TIGER/Line cartographic "Places" file into the existing genericboundariestable (typevillages, already a recognized, colored, grouped type inapp_core/boundaries.py'sBOUNDARY_TYPE_CONFIG). Once loaded, the alert detail page's existing boundary-intersection display — which already lists every boundary type an alert's polygon intersects — starts showing named cities and villages for free, with no new UI work. - Deliberately scoped to the station's own coverage counties via
RWTScheduleConfig.same_codes(notAlertFilterSettings.fips_codes, which carries non-geographic wildcard entries) so the boundaries table isn't bloated with the ~32,000-record national dataset. Unincorporated Census Designated Places (CDPs) are filtered out — this is meant to show real municipalities, not census-only place designations. - The Places file has no per-record county field (a place isn't nested inside exactly one county the way a township is), so county scoping uses a real PostGIS
ST_Intersectstest against the already-loadedus_county_boundariesgeometry rather than a FIPS-string compare. - Verified end-to-end against the live database: loaded 99 real cities/villages across the station's 8-county coverage area, recalculated intersections for a real historical alert, and confirmed named cities (e.g. "Lima city", "Defiance city") and villages now appear in its stored intersections.
- Fixed a SAME-code/plain-FIPS format mismatch caught during testing: SAME codes are 6-digit PSSCCC (portion digit + state + county), while the Census shapefile's own STATEFP+COUNTYFP is 5-digit — comparing them directly matched nothing. Added
tests/test_load_municipality_boundaries.pyas a regression test for the normalization.
[2.208.0] - 2026-08-31 - Add a High-Resolution (Level II) radar loop with a velocity overlay
Added
- New High-Resolution Radar Loop card on the alert detail page, below the existing (Level III) Radar Loop card — an explicitly separate, distinctly-labeled feature, not a silent upgrade to it.
maps.py's_render_map()used to always prefer a sharper Level II render when a site was in range, but that was reverted because the exported/looped image could then disagree pixel-for-pixel with the live "Radar (at time of alert)" toggle (different resolution, different color ramp). This reintroduces Level II behind its own opt-inradar_source='level2'parameter, used by exactly one caller, so the toggle, share-card, and standard loop are all unaffected. - Adds a Reflectivity / Velocity selector — velocity is a Level II–only product (no Level III equivalent), useful for spotting rotation.
radar_level2.py'srender_frame()/_plot_ppi()now take afieldparameter; velocity uses cmweather'sNWSVelcolormap over a ±32 m/s range (the practical base-velocity Nyquist limit) with its ownVELOCITY_LEGEND. No de-aliasing is applied — a known limitation of the raw base product. - Level II only reaches ~230km from a WSR-88D site, so
radar_loop_hires.pychecks coverage once per alert up front and reports a genuine coverage gap distinctly from "not a weather alert", rather than silently caching a radar-less frame that would look identical to a legitimate no-echo scan. render_frame()now returns(image, scan_time)— the matched volume's actual timestamp, not just the requested one — so the on-image "Radar H:MM" caption is accurate for Level II the same way it already was for Level III.- Verified end-to-end against live NOAA data (not just mocks): both fields render correctly through
radar_level2.render_frame()directly and through the full_render_map()composite (basemap + polygon + radar + legend). - Added
tests/test_image_export_radar_loop_hires.py(11 tests: eligibility, the coverage-gap short-circuit, caching, field-scoped cache isolation, render-failure handling). - Corrected
templates/help.html's Radar Loop documentation, which had drifted to describe the old (reverted) "loop prefers Level II automatically" behavior as current.
[2.207.4] - 2026-08-31 - Bump zigpy to 2.1, fixing a permit_joining rename it silently would have broken
Changed
zigpynow floats>=2.1.0(was>=0.60.0) at the maintainer's request, after closing Dependabot's version of this bump (#2524) pending verification — zigpy manages real Zigbee hardware pairing, which can't be exercised in CI.- Found one real, confirmed break by inspecting the installed
zigpy==2.1.0+zigpy-znp==1.1.0API directly (the version pair pip's resolver actually picks for these pins):ControllerApplication.permit_joining(duration)was renamed to.permit(time_s, node=None)— the old name doesn't exist at all on 2.1.0.services/zigbee/controller.py'spermit_join()/close_join()(the pairing-mode open/close methods) called the old name;close_join()'s call is wrapped in a broadexcept Exception, so this would have failed the same silent way the pysnmp break below did. - Verified the rest of this codebase's zigpy-facing API surface is unaffected:
ControllerApplication.__init__,.add_listener,.startup,.shutdown, thedevice_joined/device_initializedlistener callbacks, and theDevice.ieee/.nwk/.model/.manufacturerattributes this code reads are all unchanged between 0.60.0 and 2.1.0. - Added
tests/test_zigbee_controller_permit.py— the first test coverage this file has ever had. It stubs the zigpy application object (no real coordinator hardware is available in CI) and assertspermit_join()/close_join()call.permit(), not the nonexistent.permit_joining(). - Real device pairing over a live coordinator has not been hardware-tested — this fix corrects a confirmed API break, but only physical testing can confirm end-to-end pairing behavior.
[2.207.3] - 2026-08-31 - Fix silently-broken SNMP compliance traps under pysnmp 7
Fixed
- A dependency bump already on
main(pysnmp>=7.1.29, from a Dependabot PR merged earlier the same day) silently broke SNMP compliance trap sending. pysnmp 7 restructuredhlapiinto arch-specific, asyncio-native submodules and dropped the old flatpysnmp.hlapimodule (CommunityData,SnmpEngine,sendNotificationas a sync-flavored generator) this code imported from. BothHealthAlertWorker._send_snmp_traps()(app_core/system_health.py) and the admin "Test SNMP" button (webapp/admin/notifications.py) catch that import failure broadly and just log/return a warning — so this broke with no crash and, since there was no prior test coverage for SNMP trap sending at all, no test failure either. Traps would have silently stopped sending entirely. - Both call sites now import from
pysnmp.hlapi.v3arch.asyncio, call the now-asyncsend_notification()viaasyncio.run(), useadd_varbinds(the renamed, non-deprecated method), and wrap the trap payload in an explicitOctetString(pysnmp 7 no longer auto-coerces a raw Pythonstrvarbind value). Each call'sSnmpEngineis now explicitly closed viaclose_dispatcher()in afinallyblock — without it, every trap sent (including from the recurring background health-check interval) leaked a UDP dispatcher socket for the life of the process. - Verified end-to-end, not just via import checks: sent a real trap over a real UDP socket to a local listener and confirmed the payload arrives, using an isolated venv with
pysnmp==7.1.29actually installed. - Added
tests/test_snmp_trap_pysnmp7.py— the first test coverage this code path has ever had.
[2.207.2] - 2026-08-31 - Bump numba to 0.67, lifting the previous llvmlite-size cap
Changed
numbanow floats>=0.67.0,<0.68.0(was>=0.61.0,<0.64.0). The old cap existed specifically to avoid numba 0.64+'s heavier llvmlite dependency; verified that cost is real (llvmlite 0.49.0's aarch64 wheel is ~58MB) but accepted it deliberately as a one-time download rather than staying capped indefinitely.- Verified end-to-end on the real target platform (aarch64) before merging: installed numba 0.67.0 into an isolated copy of the deployment venv (pulls llvmlite 0.49.0, keeps numpy at the already-pinned 2.3.5 — numba 0.67's own requirement,
numpy<2.6, is looser than before), confirmedapp_core/radio/demod/kernels.pyJIT-compiles, and ran the full demod/RBDS test suite (93 tests) against it. - Updated the Numba badges (README top badge, attribution table, footer partial) to the new range.
[2.207.1] - 2026-08-31 - Fix a startup crash from Flask-Caching 2.5.0's dropped CACHE_TYPE aliases
Fixed
- Bumped
Flask-Cachingto 2.5.0 (a Dependabot PR for this had failed CI:flask_caching.backends.redisno longer exists in that release).Cache._set_cache()builds an import path fromCACHE_TYPEand imports it; 2.5.0 dropped the lowercase short aliases (redis,simple,filesystem,null) thatflask_caching.backendsused to expose, keeping only the actual class names (RedisCache,SimpleCache,FileSystemCache,NullCache). Passing the old alias straight through raised anImportErrorfrominit_cache(), which runs unconditionally during app creation — this would have crashed the whole web service on boot, not just broken caching. app_core/cache.pynow translates the lowercase alias to the class name right at the Flask-Caching boundary. Everything else — theCACHE_TYPEenv var, the Settings → Environment dropdown, already-deployed.envfiles — keeps using the lowercase form; only the value actually handed to Flask-Caching changed.- Added
tests/test_app_cache_type_resolution.pycovering all four aliases plus the "already a resolvable class name" passthrough case.
[2.207.0] - 2026-08-31 - Require Python 3.13 / Debian 13 (Trixie); drop 3.11/3.12 support
Changed
- Breaking for Debian 12 / Python 3.11 or 3.12 installs. The project now requires Python 3.13 and targets Debian 13 (Trixie) / Raspberry Pi OS (Trixie-based) only. Maintaining both floors was an ongoing tax: scipy was capped below 1.18 solely because that series drops 3.11, numba's compatible-numpy range and the
audioop-ltsmarker both existed to branch on the interpreter version, and CI ran the whole suite twice per PR to catch drift between them. requirements.txt: unpinned scipy's 3.11-driven cap (now1.18.1) and removedaudioop-lts'spython_version >= "3.13"marker (unconditional now that 3.13 is the floor)..github/workflows/tests.yml: CI matrix is now Python 3.13 only (was['3.11', '3.13']); thelintjob also moved off 3.11..github/workflows/release.yml,.github/workflows/docs-pages.yml: bumped their own Python setup steps to 3.13 for consistency.pyproject.toml: rufftarget-versionis nowpy313; removed the now-deadaudioopdeprecation-warning filter (that warning only ever fired on <3.13, which no longer runs in CI).install.sh,sdr_hardware_service.py,scripts/fix_soapysdr_venv.sh: removed the "downgrade to Python 3.12" SoapySDR troubleshooting suggestion and the Python 3.10-3.12 site-packages fallback paths, since downgrading is no longer a supported workaround.- Updated
README.md's System Requirements table,docs/reference/ABOUT.md,docs/guides/HARDWARE_QUICKSTART.md,templates/help.html, andtests/README.mdto state the new floor.
[2.206.0] - 2026-08-31 - Pin the one-click upgrade to a specific release, and fix the upgrade docs
Fixed
- The "Version to install" field on Admin → Operations' System Upgrade card is now a dropdown populated from the repository's actual release tags (via a new
GET /admin/operations/upgrade/tagsendpoint), instead of a blank text box requiring an operator to already know the exact tag spelling. "Track main (latest)" stays the default and behaves exactly as before; a "Custom branch, tag, or commit…" option keeps the free-text field available for anything not in the list. - Rewrote
docs/guides/one_button_upgrade.md, which described a Docker-image-based upgrade pipeline (kr8mer/eas-station:latest, a nonexistent.github/workflows/build.yml) this project has never used — EAS Station deploys bare-metal viainstall.sh/update.sh. It now accurately documents the realupdate.sh-via-systemd-unit mechanism.
[2.205.0] - 2026-08-31 - Bracket the weekly test with optional spoken announcements and a guaranteed lead-in silence
Changed
- The automated Required Weekly Test can now play optional station courtesy announcements before the SAME header ("This station is conducting a test of the Emergency Alert System...") and after the EOM ("This concludes this test..."), synthesized via the configured TTS provider and enabled/edited from the Weekly Test Automation page (
/rwt-schedule). They play outside the encoded SAME/EOM burst, so they never affect RWT format compliance under 47 CFR §11.61(a)(1)(ii). - The composite RWT audio now always opens with at least a second of true silence before the SAME header begins (previously the header started at t=0 whenever no pre-alert chime was configured), mirroring the second of silence that already follows the EOM before the air-chain returns to normal programming.
EASAudioGenerator.build_manual_components()gainedsilence_before_header,lead_announcement_samples, andtrail_announcement_samplesparameters;RWTScheduleConfiggainedpre_announcement_enabled/pre_announcement_text/post_announcement_enabled/post_announcement_text(migration20260828_rwt_test_announcements).
[2.204.0] - 2026-08-31 - Show whether an upgrade is actually available before running one
The one-click "System Upgrade" button gave no way to tell whether there was anything to upgrade to -- clicking Start Upgrade was the only way to find out, and it always restarts every service even when the box is already current.
Added
- New
GET /admin/operations/upgrade/checkroute:git fetchs the target branch (defaulting to whatever branch is currently checked out, usuallymain) and compares localHEADagainstorigin/<branch>, reporting the current and remoteVERSIONfile contents and how many commits behind.git fetchonly updates this checkout's own remote-tracking refs -- the same thinggit statusimplicitly keeps current -- so it never touches the working tree, which is what makes it safe to run automatically on page load rather than waiting for a click. - The Operations page now shows "Up to date (2.204.0)" or "Update available: 2.204.0 → 2.205.0 (7 commits behind main)" above the Start Upgrade button, with a "Check again" link that re-reads whatever's in the Git Branch/Tag field. Purely informational -- doesn't gate the button, since a specific tag or commit checkout isn't always comparable this way.
[2.203.7] - 2026-08-31 - Stop anonymous visitors' browsers from hitting a guaranteed-401 endpoint
Checked the browser console on every public page (the dashboard, /health,
/style-guide, /about, /help) via a headless-Chromium CDP session.
Every one of them showed two 401s for /admin/pending-alerts/api/list on
every load, plus a repeat every 5s from its WebSocket-fallback poller.
Fixed
navbar_scripts.html(included on every page via the navbar) fires this fetch to light the stack-light widget's blue "pending alerts" state. The endpoint requires login (@require_auth) theneas.view(@require_permission) on top of that; the existing.then(r.ok ? ... : null)handling already covers an authenticated-but-under-permissioned viewer gracefully (403 -> null, blue state just never lights), but nobody anticipated a completely anonymous visitor -- this dashboard has no login wall -- who gets a guaranteed 401 instead, every page load, every 5s poll. Gated both the initial fetch and the WebSocket fallback subscription behindcurrent_user.is_authenticated(already available in the navbar's Jinja context). Anonymous visitors now skip the call entirely.
[2.203.6] - 2026-08-30 - Fix the actual highest-specificity rule controlling dashboard toggle switches
Fixed
- The app-wide
.form-switchfix (2.203.5) didn't actually take effect on the dashboard: live-checked via the browser's owndocument.styleSheets(matching every CSS rule against the element in cascade order, not just reading computed style), the real winner was.layer-options .form-check.form-switch .form-check-input-- a page-local rule intemplates/index.htmlwith higher specificity (four classes) than the app-wide fix (two classes), still settingbackground-color: var(--light-color). That selector covers every switch in the Map Layers panel: Active/Historical Alerts, the severity filters, the event-type filters, and every boundary-layer toggle. Switched it to the same fixed#495057used everywhere else tonight. Grepped every other template for a similarly-scoped override and found none.
[2.203.5] - 2026-08-30 - Sweep the rest of the app for the var(--text-muted)-as-fill bug
Grepped every background: var(--text-muted) / background-color: var(--text-muted) in the codebase (not just the dashboard) for the same
failure mode fixed five times already tonight, and computed exact WCAG
contrast for every hit that pairs it with text. Six more instances, all
fixed the same way (a fixed colour instead of the variable):
Changed
security_settings.html's.role-badge.viewer(bg--text-muted, text--bg-primary) failed in 17 of the 20 themes -- Blue measured 1.00:1, literally the same colour on both sides.alert_detail.html's.coverage-badge.bg-secondary(text--surface-color) and.scope-coverage-na(text--bg-color) failed in 5 and 9 themes respectively.displays_preview.html's.display-status.disabledwas marginal (3.78:1) in 8 themes.static/css/styles.css's.form-switch .form-check-inputunchecked track, paired with the switch knob's fixed white SVG circle, failed app-wide in 7 themes (1.33:1 in Lightning) -- every unchecked toggle switch on every page, not just one component.alert_detail.htmlalso carried its own copy of the radar-legend chips fixed on the dashboard in 2.203.1 (rgb(...)badges missing an explicitcolor) -- same fix applied.
Also found, but left alone: static/css/styles.css already carries a
large, deliberate comment block (~line 1131) documenting this exact
var(--light-color)-without-paired-text pattern in
.workflow-card > .card-header, .same-chip, .alert-summary-panel,
.feature-group, .layout-preset-btn:hover and .rss-item-row, with an
existing per-theme text-colour override. Unlike #date-filters (also on
that list, and the actual bug fixed in 2.203.2 -- its label had a more
specific rule fighting the override back to --text-muted), these five
weren't verified live and weren't touched.
[2.203.4] - 2026-08-30 - Drop the fill on historical alerts -- semi-transparent fills stack
Severity colouring (2.203.3) fixed historical alerts all looking the same, but introduced a different problem visible immediately on a real 30-day window: a date range easily loads 100+ overlapping polygons (the same county gets re-alerted many times across a month), and semi-transparent fills stack. Even a faint 0.12 fillOpacity per shape compounded across dozens of overlapping ones into a solid, texture-free colour wash that hid the map underneath -- a worse failure than the flat-colour problem it was fixing.
Fixed
displayHistoricalAlerts()now draws outline-only (fill: false,opacity: 0.75). Outline-only doesn't have the compounding failure mode a fill does; the dashed, severity-coloured stroke alone still distinguishes both alert type (colour) and historical-vs-active (dashed + reference pane, which sits under the hazard pane).
[2.203.3] - 2026-08-30 - Colour historical alerts by severity instead of one flat line
The previous pass (2.203.2) fixed historical alerts being invisible against the basemap, but left every one of them the same flat neutral line -- fixing invisible-to-visible didn't fix indistinguishable-from- each-other: a flood watch and a tornado warning looked identical, which throws away the one piece of information a reader actually wants when scanning a season of alerts.
Changed
displayHistoricalAlerts()now resolves each alert's own colour viaEASMap.severityColor()-- the same resolverhazardLayer()uses for active alerts -- instead of one fixed--map-reference-linevalue. Dashed stroke, lower opacity (0.65/0.12 vs. an active alert's full strength) and the reference pane (which sits under the hazard pane, so an active alert on the same spot always wins) still mark it as historical rather than happening now; only the hue was the problem.
[2.203.2] - 2026-08-30 - Fix the date-filter panel and historical-alert map lines being unreadable
Found from a user screenshot of the Lightning theme: the date-filter sub-panel's FROM/TO labels were nearly invisible, and every historical alert on the map rendered as a barely-visible pale line indistinguishable from the basemap.
Two more instances of the same root cause already fixed twice today
elsewhere on this page (var(--text-muted) used somewhere it wasn't
designed for):
Fixed
#date-filterspairedbackground: var(--light-color)with the label'scolor: var(--text-muted). That combination failed WCAG AA in all 20 themes, not just Lightning (1.24:1) -- Cosmo measured 2.45:1, Aurora 1.39:1, Charcoal 1.65:1, Slate 1.93:1, and the rest landed "marginal" in the 3.7-4.5 range. Switched to--surface-color/--text-secondary, the same pairing already verified safe everywhere else on this card.displayHistoricalAlerts()drew every historical alert polygon ingetColorVar('--text-muted')at 0.6 opacity over the toned basemap -- near-white in several themes, so the shape all but disappeared into the map. Switched to--map-reference-line, the variable this same map already uses for county boundary lines, purpose-built and tuned for exactly this job (legible against the basemap in both light and dark map-tone modes, unlike--text-mutedwhich has 20 unrelated values never designed to sit on top of map tiles).
[2.203.1] - 2026-08-30 - Fix unreadable radar-legend chips on the dashboard
The dashboard's Reflectivity (dBZ) legend (shown when the map's Radar
toggle is on) has six colour chips; three of them (20/40/60+) never got an
explicit color, so they fell back to .badge's default white text. That
failed WCAG AA on two of them regardless of theme, since the colours are
hardcoded RGB literals, not theme variables: the orange "40" chip measured
2.07:1, the green "20" chip 3.42:1 (AA needs 4.5:1).
Fixed
- No single ink works for all six -- the range runs from light green through saturated red and magenta. Set each chip's
colorindividually to whichever of black/white actually clears 4.5:1 against its own background (computed, not eyeballed):#123for 5/20/30/40, white for the red "50" chip, black for the magenta "60+" chip.
[2.203.0] - 2026-08-30 - Filter dashboard alerts by severity and event type
The dashboard map could only toggle Active vs. Historical alerts -- no way to narrow by severity or event type, even though the historical set alone spans 15+ event names (Flood Warning, Severe Thunderstorm Warning, routine Required Weekly Test, ...) and all 5 CAP severity levels.
Added
- A Severity checklist (fixed 5-item CAP set) and an Event Type checklist (populated from whatever's actually in the loaded active + historical data, with All/None shortcuts) to the Alert Types panel. Both filters apply to whichever alert layers are currently shown -- switching between Active and Historical, or reloading either one, never loses the selection, since
displayAlerts()/displayHistoricalAlerts()filter from the sameexcludedSeverities/excludedEventTypesstate and redraw from the already-cached data rather than re-fetching.
[2.202.2] - 2026-08-30 - Fix unreadable map-popup badges in several themes (worst in Lightning)
.historical-badge (the small "Historical" tag on a historical-alert map
popup) used background: linear-gradient(45deg, var(--text-muted), #495057)
with white text. --text-muted is tuned for text on a surface, not for
carrying white text as a fill, and it's a near-white colour in several
themes -- worst in Lightning (#d8e0f0), where the badge measured 1.33:1
contrast against its own white text (WCAG AA needs 4.5:1): effectively
invisible. Cosmo, Dark, Coffee, Aurora, Charcoal and Slate had the same
failure to varying degrees.
Fixed
- Switched to a fixed dark gradient (
#495057→#343a40, 8.2:1 / 11.5:1 with white) that doesn't depend on the theme. Fixed the same root cause in two related spots that shared it more marginally (dark text onvar(--text-muted), 3.78:1 in eight themes against the 4.5:1 target):.severity-unknownand the "EXPIRED" alert-popup badge, both now#adb5bdbackground (8.4:1 with#1a1a1atext) instead of the theme variable.
[2.202.1] - 2026-08-30 - Fix the dashboard's Historical Alerts toggle
Fixed
- Checking the "Historical Alerts" layer checkbox only revealed the date pickers -- it never actually loaded or displayed anything.
loadHistoricalAlerts()was wired to the "Apply Filter" button alone, so a user checking the box (the same gesture that immediately shows/hides Active Alerts) saw nothing happen until they noticed they also had to click Apply Filter below it. Checking the checkbox now callsloadHistoricalAlerts()immediately instead of waiting for a second click. - Separately, active and historical alerts shared one Leaflet layer group (
alertLayer).displayHistoricalAlerts()never cleared it before adding new polygons, so re-applying the date filter with a different range piled new alerts on top of old ones instead of replacing them; toggling "Active Alerts" off/on also wiped out historical polygons as a side effect ofalertLayer.clearLayers(), since both lived in the same group. Historical alerts now get their ownhistoricalLayer, cleared at the top of everydisplayHistoricalAlerts()call.
[2.202.0] - 2026-08-30 - Radar timestamp, card lift, bolded hazard numbers, and pill glow on the share card
Fifth polish pass on the social-share card, all four picked by the user from a shortlist:
Changed
- Radar "as of" timestamp (
maps.py) —_fetch_radar_overlay()now returns(image, scan_time);_draw_radar_legend()shows "Radar 2:08 PM" under the dBZ legend so the card admits the reflectivity can be a few minutes older than the alert's own timestamps (the WMS-T service snaps to its own 5-minute cadence). - Card lift (
drawing.py's new_apply_card_lift()) — a thin, low-alpha inner shadow along the card's own rounded edge. A real drop shadow needs canvas space outside the card, which would change the finished PNG's pixel dimensions -- both the export UI's advertised sizes and the test suite assume those are exact -- so this stays inside the existing bounds instead, deliberately subtle since header/footer copy sits close to every edge. - Bolded hazard numbers (
panels_text.py's new_draw_emphasized_line()) — HAZARD/IMPACTS body text now bolds numeric magnitudes with units ("60 mph",1.00") inline via a newsmall_boldfont, so the actionable numbers are scannable without reading the full sentence. - Pill glow (
drawing.py's new_draw_pill_glow()) — the tier/severity badges (WARNING / SEVERE) get a soft blurred glow behind them, the same glow-behind-a-crisp-shape technique already used for the map's hazard polygon, so the single most important words on the card pop first.
[2.201.0] - 2026-08-30 - Section icons, a tornado confidence gauge, a storm ping, and a north arrow on the share card
Fourth polish pass on the social-share card:
Changed
- Section-header icons (
icons.py's new_SECTION_ICON_FN, wired intodrawing.py's_section_header()) — a small glyph (flag / pin / lines / triangle / ring / needle / bolt) next to HEADLINE, AFFECTED AREAS, DESCRIPTION, STORM THREATS, COVERAGE, STORM MOTION and ACTION, keyed off the exact title string so every call site picked it up with no signature changes. - Tornado detection as a stepped gauge (
panels.py) — tornado detection (possible/radar/observed) has no continuous magnitude like wind/hail, so it now gets the same gauge shape as a discrete 3-segment version (_draw_stepped_gauge) instead of the flat funnel icon; a bare "none" reading still falls back to the icon. - Storm-position ping (
storm_overlay.py) — two fading outward rings behind the "now" marker, reading as a live radar ping / location pulse even in a still image, instead of a plain disc. - North arrow (
maps.py's new_draw_north_arrow()) — small two-tone compass needle in the map's one previously-empty corner (upper-left), added to the county-label keep-out list like the other three chrome elements so nothing lands on top of it.
[2.200.1] - 2026-08-30 - Fix crowded threat-level/category labels on the share card
Fixed
- The threat-level line ("Radar") and the category label ("WIND") under each Storm Threats gauge sat only ~11px apart against ~13-15px-tall text, so they nearly touched.
card_hbumped 108 → 118 and the category label's y is now derived from where the level line actually measures to (_th()+ a real gap) instead of a second guessed constant -- closes the same gap for the icon-fallback path too (wind/hail with an unparsable gust/size).
[2.200.0] - 2026-08-30 - Add film grain, an expiration countdown badge, and threat gauges to the share card
Three additive polish passes on the social-share card, picked from user feedback after the polygon-joint and radar-opacity fixes:
Added
- Header film grain (
weather_fx.py) — a subtle monochrome noise layer composited over the gradient before particles/text, so the header reads as a textured surface instead of a flat CSS-button gradient. Low alpha (14/255) by design; the goal is texture, not visible speckle. - Expiration countdown badge (
text.py's new_format_countdown(), wired into the footer inrender.py) — "Expires in 42m" next to the Issued/Expires timestamps, colour-coded red/orange/slate by urgency (≤30 min / ≤2 h / normal), skipped entirely when it would crowd the brand credit on a narrow canvas. - Storm-threat gauge meters (
panels.py) — the Wind/Hail cards now plot their magnitude as a semicircular gauge arc (0-100 mph / 0-3") instead of a flat icon, with the value sitting inside the arc. Tornado detection has no continuous magnitude (radar/observed/possible), so it keeps the plain icon.
[2.199.2] - 2026-08-30 - Fix jagged stroke joints on the share-card alert polygon and county outlines
Fixed
- Every bend in the affected-area polygon (and the county reference outlines under it) showed a visible notch cut into the wide white+accent stroke -- PIL's
ImageDraw.line()defaults to a hard miter join with no rounding, so a winding shape (a road corridor, an irregular county line) looked jagged at each vertex, worst on the crisp casing/core outline drawn on top. Addedjoint='curve'to the county-outline, glow, and casing/coreline()calls inapp_utils/image_export/maps.pyso every vertex rounds smoothly instead.
[2.199.1] - 2026-08-30 - Raise radar overlay opacity so reflectivity actually reads at a glance
Changed
_RADAR_OPACITY(app_utils/image_export/maps.py) and the matchingradarLayer()default (static/js/core/map_theme.js) were both 0.45 -- legible over the basemap, but light/moderate reflectivity read as a faint haze on the social-share card and the in-app radar pane alike. Raised to 0.6, verified pixel-by-pixel on a live storm cell alongside 0.45 and a rejected 0.75 (which reproduced the washed-out-basemap problem a prior pass already hit at 0.65 and pulled back from). The two files share one constant by design, so both surfaces -- and the animated radar loop export, which reuses the same_render_map()call -- move together.
[2.199.0] - 2026-08-30 - Extend systemd watchdog coverage to every hardware subsystem and the web app
Only eas-station-audio, -demod, and -poller had Type=notify +
WatchdogSec= -- Restart=always recovers a crashed process on every unit,
but nothing caught a hung-but-still-alive one (a wedged I2C bus, a stuck
GPIO ioctl, a deadlocked serial read) on -displays, -endec-feeds,
-gpio, -gps, -network, -sdr, -zigbee, or -web.
Added
- Each of the six
services/*/__main__.pysplit-hardware entry points plussdr_hardware_service.pynow callsd_notify("READY=1")once startup finishes and kick aWatchdog()(app_utils/system/sd_notify.py, the same helpereas_monitoring_service.py/cap_poller.pyalready used) from their existing ~1 Hz main loop; the matching unit files gainedType=notify/NotifyAccess=main/WatchdogSec=60. - The web app runs under a multi-process gunicorn arbiter rather than a single Python loop, so it needed its own mechanism: a new
gunicorn.conf.py(loaded via--configineas-station-web.service) starts a background thread in the arbiter'swhen_readyhook that kicks the watchdog every 5s for as long as the arbiter's event loop is alive. This only covers an arbiter deadlock, not a single hung gevent worker -- that path is already gunicorn's own--timeout 300(kills and respawns the worker).
[2.198.0] - 2026-08-28 - Make the one-click upgrade button run the real update.sh, with live progress
The one-click "System Upgrade" button (fixed in 2.196.3 to at least run
something bare-metal-appropriate) still only replicated a thin slice of
what update.sh actually does -- no pre-upgrade backup step, no service
stop/start ordering, no system package updates, no systemd unit refresh, no
nginx check, none of the migration-failure recovery path, and no
step-by-step feedback beyond a final stdout/stderr dump once everything
finished. Reimplementing all of that a second time in Python would have
meant two upgrade scripts that inevitably drift apart, so the button now
runs update.sh itself -- the exact script sudo bash update.sh runs at a
terminal -- non-interactively.
update.sh gained --non-interactive, --skip-backup, and --checkout <ref> flags: every whiptail confirmation (the welcome dialog, "create a
backup?", the migration-failure pause, the final success/error dialog) is
skipped in favor of a safe default (a backup is taken by default, since
there is no operator here to notice a silently-skipped one) instead of
hanging on a keypress that will never come, while every
echo_step/echo_info/echo_success/echo_warning/echo_error line
still goes to the log exactly as it does interactively.
Getting there surfaced two real, previously-latent bugs in
scripts/lib/ui.sh, neither ever exercised before because every prior
invocation of install.sh/update.sh had a real controlling terminal
attached:
Changed
[ -w /dev/tty ], used throughout as the "is a real terminal available" guard, only checks the special device node's own permission bits (always broad,crw-rw-rw-) -- not whether this session actually has a controlling terminal to open it against. A fully detached session (like the one below) passes that check and then dies the moment something really writes to/dev/tty, with "No such device or address". Replaced every such guard with a real probe (attempt an actual open, once, cached in_UI_HAS_CONTROLLING_TTY)._ui_ensure_gaugereturns 1 (an expected, ordinary result meaning "no whiptail gauge available, fall back to plain output") from a bare, unguarded call site inecho_step. Underupdate.sh'sset -e, that ordinary "couldn't attach" result took the whole script down on its very first progress step -- every previous run had a terminal, so the gauge always attached and this had never fired.
The button itself now launches update.sh via a new
bin/eas-station-run-update wrapper, as its own transient systemd unit
(systemd-run --unit=eas-station-update --collect --no-block) rather than
as a direct child of eas-station-web.service. That statement is load-
bearing, not cosmetic: update.sh's own "Restarting Services" step
restarts eas-station-web.service, and a direct child sits in that
service's cgroup -- it would be killed by its own restart before ever
reaching its final summary, the exact failure mode the previous one-click
upgrade fix (2.196.3) still had. A sibling unit keeps running straight
through that restart.
New GET /admin/operations/upgrade/progress (get_upgrade_progress in
webapp/admin/maintenance/routes_operations.py) reads that unit's state
back out for the Admin -> Operations page to poll, and is deliberately
not backed by the existing in-memory _OPERATION_STATE dict, which
resets the moment this very worker restarts partway through the upgrade it
would be reporting on. It is also deliberately journal-first rather than
systemctl show-first: manual testing against a real --collect unit
showed it gets garbage-collected within a couple of seconds of exiting,
success or failure alike, so systemctl show reliably answers "is it
running right now" but not "how did it end" -- by the time anything polls,
the unit routinely already looks exactly like one that never ran. The
journal does not get cleaned up, so result detection reads update.sh's
own === UPDATE RESULT: ... === marker (added to update.sh, printed
unconditionally to the log right before the existing whiptail summary/error
dialogs), falling back to systemd's own "Failed with result" / "Main
process exited" / "Deactivated successfully" lines for the same unit if
the script crashed before ever reaching its own summary.
The Operations page now shows a step counter ("Step 7 of 12: ..."), a
progress bar, and a live color-coded log feed reading that endpoint every 3
seconds -- through the expected mid-upgrade disconnect and reconnect, not
just a final dump after the fact. tools/inplace_upgrade.py, the
Python reimplementation this replaces, is removed.
[2.197.0] - 2026-08-28 - Auto-configure a USB sound card as an audio source and output
Every Raspberry Pi board's onboard audio (the vc4hdmi* ALSA cards) is
output-only -- there is no line-in or mic on the board, so it can never be
an ingest source. A plugged-in USB DAC (e.g. an HS100B) previously required
a manual trip through Admin -> Audio Ingest to become a source, and Admin ->
EAS Settings to become the local alert-playback output, even though it was
the only sensible device available.
Added
app.pynow runs_auto_configure_usb_audio_device()once per process start: when exactly one non-onboard ALSA card is present, it creates an enabledalsa-typeAudioSourceConfigDBrow (unless one already exists) and pointsEASSettings.audio_playerat the same device (unless it has already been customized away from itsaplaydefault). Zero or more than one external card is left alone as ambiguous. Documented in help.html.
[2.196.3] - 2026-08-28 - Fix the one-click upgrade button, remove unsupported Docker code paths, and fix Admin Operations navigation
Fixed
- The one-click "System Upgrade" button (Admin -> Operations) ran
tools/inplace_upgrade.py, which only knew how to upgrade a Docker Compose deployment (docker compose pull/up/exec/restart) -- but EAS Station ships exclusively as a bare-metal systemd install (install.sh), and nodocker-compose.ymlexists in the repository. Every click of the button failed outright with "Neither 'docker compose' nor 'docker-compose' is available in PATH." The script now performs the actual bare-metal upgrade:git pull --ff-only,pip install --upgradeagainst this venv'srequirements.txt,alembic upgrade head, thensudo systemctl restart eas-station.target(the same sudoers-granted command the Settings -> Environment "Restart All" button already uses). The now-meaningless "Compose File" field was removed from the Operations page and its route. tools/restore_backup.pyhad a live bug: any bare-metal deployment pointing at a non-localhostPostgreSQL host (a perfectly normal remote-database setup) was misrouted into runningdocker compose exec alerts-db psql ...against a container that was never going to exist, instead of connecting directly.- The Admin Operations page (
/admin/operations-- one-click backup, database optimization, alert-boundary recalculation, and the System Upgrade button fixed above) was filed in the navigation under Reports -> Analytics and labeled "Operations Report," which reads as a passive report rather than the maintenance/action page it actually is -- effectively making it undiscoverable. Moved it to Settings -> Data & Storage, next to Backups, and relabeled it "Admin Operations." Its route was also missing thesystem.configurepermission check every sibling/admin/*route has (the page rendered for any logged-in user, though the backup/upgrade POST endpoints were already permission-gated) -- added. The page had no help.html documentation at all; added an entry.
Removed
- While auditing for other Docker assumptions:
tools/create_backup.pycarried a Docker/Podman volume backup path that was never actually invoked (backup_summary["volumes"]was always empty) -- removed.tools/restore_backup.py's "restore Docker volumes" step looked forvolume-app-config.tar.gz/volume-certbot-conf.tar.gzarchives thatcreate_backup.pynever produced -- also removed, andtools/validate_restore.py's post-restore guidance (which told bare-metal operators to rundocker compose logs/docker compose restart) now prints the systemd/psql equivalents.tests/test_backup_restore.py::test_standby_config_existsno longer requires adocker-compose.standby.ymlthat was never shipped -- the standby doc it guards (examples/STANDBY_DEPLOYMENT.md) is already fully rsync/systemd based. Stale Docker mentions were also cleaned out ofinstall.sh(dead rsync excludes for files that don't exist) and the installation/architecture docs;scripts/setup_postal.shis untouched since the optional Postal mail server integration genuinely ships its own Docker-based install upstream. - Removing those dead
--no-volumes/--skip-volumesflags fromcreate_backup.py/restore_backup.pybroke three more call sites that still passed them:webapp/routes_backups.py(the full Backups page's create/restore actions),app_core/backup_scheduler.py(the in-process auto-backup scheduler, which defaultedinclude_volumestoFalse-- meaning every scheduled backup would have started failing), and the standalonetools/backup_scheduler.pycron/systemd-timer script. All three, plus the now-dead "Docker volumes" checkboxes intemplates/admin/backups.html, are cleaned up to match.
[2.196.2] - 2026-08-28 - Fix radar mismatch between the alert page and exported cards; widen the Py-ART attribution card
The exported/shared alert card and the alert page's "Radar Loop" both
rendered radar via _render_map(), which preferred a real per-site
NEXRAD Level II decode (radar_level2.py, Py-ART) over the Level III
WMS mosaic whenever a site was in range. The alert page's live "Radar
(at time of alert)" toggle, however, is a static/js/core/map_theme.js
Leaflet WMS tile layer that only ever shows the Level III mosaic. That
meant the same alert could show two visually different radar products
side by side on the same page (different resolution and color ramp --
IEM's own mosaic colors vs. cmweather's 15-band NWSRef), which read
as a bug even though both were technically valid radar for the same
storm.
Fixed
_render_map()(app_utils/image_export/maps.py) now always uses the Level III WMS mosaic (_fetch_radar_overlay) -- the same request the live toggle makes -- so the live map, the Radar Loop, and every exported share card agree pixel-for-pixel.radar_level2.py's Level II decode/plot path (render_frame) is no longer called but is left in place (itsREFLECTIVITY_LEGENDstill backs the on-image legend) for a future pass that gives the live map a matching high-resolution option instead of silently diverging from it. Updated stale comments/docstrings inradar_loop.pythat described the old Level-II-first behavior, and the Attribution page's Py-ART card to note the library is retained but not currently in the live overlay path.
Changed
- Widened the Attribution page's Py-ART/boto3/Cartopy/cmweather/Matplotlib card (
.stack-item-wideinstatic/css/styles.css) to span two grid columns -- it credits five libraries with the longest description and license list on the page, and was visibly cramped at the same width as single-library cards.
[2.196.1] - 2026-08-27 - Fix Level II projection mismatch and coarse color banding
2.196.0's Level II renderer (pyart.map.grid_from_radars) turned out to
smooth real gate detail into blurry blobs -- confirmed by requesting the
same bbox at increasing pixel counts and finding no new detail appeared,
the same test that had earlier confirmed the Level III mosaic's
blockiness was its native resolution, not under-sampling. Replaced with
RadarMapDisplay.plot_ppi_map, which draws each gate as its true
azimuth/range quadrilateral via matplotlib pcolormesh -- no
interpolation.
That swap surfaced two more real bugs, found by drawing the actual stored alert polygon directly onto a live radar image and checking pixel data rather than eyeballing screenshots:
Fixed
- Projection mismatch:
plot_ppi_mapwas rendering in plain lat/lon (ccrs.PlateCarree), while the basemap tiles and hazard polygon it composites onto are Web Mercator -- different north-south scale at non-equatorial latitudes, misaligning the overlay. Switched toccrs.epsg(3857)to match. - Coarse color banding: the hand-rolled 6-color ramp was flattening
real storm structure into solid blocks ("paint by numbers"). Replaced
with
cmweather'sNWSRefcolormap -- the ~15-band standard NWS reflectivity scale most radar apps use -- confirmed side-by-side against IEM's own archived radar image for the same storm at the same timestamp: same core position, same hook-like notch, same secondary cell.
Also added a light alpha-premultiplied Gaussian blur
(_soften_beam_edges) at typical alert-polygon zoom, where a real
beam-to-beam gap (confirmed genuine, not a rasterization artifact, via
the same 4x-pixel-density test) can span 15-20 pixels and read as a hard
picket-fence pattern -- softens the seams without blurring away real
gradient structure the way the discarded grid_from_radars approach did.
New explicit dependencies (previously only transitive through
arm_pyart, now imported directly): cartopy, cmweather,
matplotlib. Attribution updated in about.html, attribution.html,
and dependency_attribution.md.
[2.196.0] - 2026-08-27 - NEXRAD Level II radar for the weather-alert overlay
The radar overlay shipped in 2.195.0 used only NEXRAD Level III (IEM's
WMS-T national mosaic, ~1km resolution) -- visibly blocky at close zoom,
confirmed by requesting the same bbox at 256px through 2048px and finding
the "chunk" size identical throughout: that's the mosaic's native
resolution, not an under-sampling bug. app_utils/image_export/radar_level2.py
(new) now tries raw Level II first -- the actual per-site volume scan
(~250m gate resolution near the radar, the fine detail public radar apps
show), falling back to the Level III mosaic wherever Level II isn't
available (alert far outside every site's ~230km nominal range, no volume
within tolerance of the requested time, a download/decode error). Both
composite through the same _render_map pipeline and share one
REFLECTIVITY_LEGEND color ramp, so a viewer can't tell which source
produced a given frame from color alone.
Changed
- Site selection: nearest WSR-88D site by haversine distance, from the live NWS radar-stations API (cached 6h), capped at nominal base-reflectivity range -- returns nothing for a genuine coverage gap rather than guessing.
- Data source: NOAA's public Level II archive on AWS Open Data
(
unidata-nexrad-level2, no credentials needed), decoded with Py-ART. Verified live for both a fresh alert and one 8+ months old -- the archive goes back that far for the sites tested. - Cost, accepted deliberately:
arm_pyartpulls in a heavy transitive chain (cartopy, xarray, dask, pandas, matplotlib, netCDF4, h5py, shapely on top of the numpy/scipy/pyproj already required) -- a real departure from this project's normal minimal-dependency posture, with no lighter-weight NEXRAD Archive II decoder available in the Python ecosystem. A Level II frame costs ~15-30s (S3 download + decode + grid) versus one lightweight WMS GetMap call, soRADAR_LOOP_MAX_RENDER_PER_CALLdropped from 6 to 2 to keep a single request comfortably under Gunicorn's 300s worker timeout. - Attribution added throughout (
about.html,attribution.html,dependency_attribution.md,help.html): the AWS Open Data archive, the NWS radar-stations API, and Py-ART's own requested citation (Helmus & Collis, JORS 2016, doi:10.5334/jors.119).
[2.195.2] - 2026-08-27 - Radar overlay screenshot for the README tour
Added
- AGENTS.md's Documentation Requirements also call for a screenshot showing how to access a new feature, which 2.195.1 didn't add. Captured a real Alert Coverage Map with the radar toggle on (Tornado Warning over northwest Ohio, verified live against the deployed instance) and added it to the README's Screenshot Tour as
docs/screenshots/radar-overlay.jpg.
[2.195.1] - 2026-08-27 - Document the radar overlay feature
Added
- 2.195.0 shipped the radar reflectivity toggle and Radar Loop card without touching
templates/help.htmlortemplates/about.html, missing the "Documentation Updates Required" step indocs/development/AGENTS.md. Added a description of both to the Help page's "Monitoring Live Alerts" section, and credited Iowa Environmental Mesonet (the data source) inabout.html's acknowledgments,attribution.html's Data Sources table, anddocs/reference/dependency_attribution.md.
[2.195.0] - 2026-08-27 - Radar reflectivity overlay for weather alerts
Weather (category='Met') alert pages and the dashboard map now have a
"Radar (at time of alert)" toggle, pulling NEXRAD Level III base
reflectivity (product N0Q) from Iowa Environmental Mesonet's public WMS-T
mosaic — historical for past alerts, live for active ones. The alert detail
page also gets a "Radar Loop" card: a lazily-generated, disk-cached
sequence of frames spanning the alert's duration (5-minute cadence, capped
at 36 frames / ~3 hours), so a completed alert can be replayed rather than
only showing one frozen moment. The share-image renderer composites the
same overlay onto weather alert graphics.
Both the client-side toggle (static/js/core/map_theme.js) and the
server-side share/loop renderer (app_utils/image_export/maps.py) shared
one bug: _RADAR_OPACITY at 0.65 blended mathematically correctly (traced
pixel-by-pixel to confirm) but still read as a solid weather-radar image
over a wide, intense cell, with the toned basemap and road detail
essentially invisible underneath. Lowered to 0.45 in both places so the
basemap stays legible.
Changed
app_utils/image_export/radar_loop.py(new) — lazy, disk-cached frame generation;webapp/admin/api/routes_radar_loop.py(new) —GET /api/alerts/<id>/radar-loop.static/js/core/map_theme.js— neweasRadarpane andEASMap.radarLayer().app_utils/image_export/maps.py— radar overlay + legend on the share-card renderer.templates/alert_detail.html,templates/index.html— toggle, legend, and (alert detail only) the Radar Loop player.
[2.194.1] - 2026-08-27 - Five independent "is this alert active" checks never excluded Cancelled
Following up on 2.194.0's CAP <references> fix: an alert correctly marked
status='Cancelled' was still displayed as active on the dashboard's navbar
stack light and the physical tower light. The 2.194.0 fix was necessary but
not sufficient — it made the data correct, but at least five call sites
across the codebase independently reimplemented "is this alert active"
with their own ad-hoc expires > now check that predated
app_core.alerts.get_active_alerts_query() (the canonical, already
well-tested definition — see tests/test_alert_active_expired_partition.py)
and never adopted its status.notin_(("Expired", "Cancelled")) /
superseded_by_id.is_(None) exclusions:
Changed
services/gpio/__main__.py::_make_active_alert_counter— drives the physical USB tower light.webapp/routes_monitoring.py::api_broadcast_state(/api/broadcast/state) — the page-load fallback for the navbar stack light.app_core/websocket_push.py::_emit_broadcast_state_update— the continuously-running WebSocket push that is the primary channel driving the stack light in an already-connected browser, and the dominant reason the light stayed lit even after the REST route was checked.app_core/websocket_push.py::_emit_alerts_update— feeds the active-alerts page and dashboard widgets.scripts/screen_manager.py::_has_active_alerts— the OLED/LED display's alert screen.
All five now delegate to get_active_alerts_query() instead of
reimplementing it. Also fixed the public /alerts page and its PDF export
(webapp/public/alerts_page/query.py, pdf_export.py), which excluded
status == "Expired" but never "Cancelled" from the default view.
Manually corrected the one alert this actually happened to in production
(confirmed cancelled via an independent PBS WARN report) since the CAP
<references> Cancel message it depended on had already scrolled out of
FEMA's IPAWS feed before today's fix existed — code changes alone can't
retroactively catch a message that's no longer there to catch. Added
tests/test_public_alerts_page_hides_cancelled.py.
[2.194.0] - 2026-08-27 - CAP <references>-based Cancel/Update linking, and a post-EAS silence gap
Investigated a report that a "Local Area Emergency" alert (OHDOT) shown as
cancelled on PBS WARN was still displaying active on the dashboard. Root
cause: per CAP 1.2 sec 3.3.2.3, a Cancel or Update message gets its own
unique identifier and points back at the alert(s) it affects via
<references> rather than reusing the original's — our poller never
extracted or acted on <references> at all. Worse, a Cancel commonly
carries no <info> block whatsoever (there's nothing left to describe), so
it parsed as event="Unknown" with empty area codes and was silently
dropped by the geographic-relevance filter before ever being looked at as a
cancellation. Confirmed live: OHDOT's Cancel message reached our poller
within minutes of being issued and was logged as "not specific enough" —
the original alert then stayed "active" indefinitely with nothing to ever
mark it otherwise.
Fixed
- Added
poller.cap_poller.parse_cap_reference_identifiers()plus two new code paths:_process_cap_references_cancellation()intercepts a Cancel carrying<references>before the relevance filter (there's nothing else worth saving from it) and marks the referenced alert(s)Cancelled._mark_cap_references_superseded()handles the CAP Update case — an Update does carry real content and still gets saved as its own alert normally, but previously nothing ever linked it back to the alert it updates unless that alert carried NWS VTEC identity (which a state DOT's IPAWS feed never does); now the referenced original is markedsuperseded_by_id, the same mechanism the VTEC chain already uses, so a stale original and its Update don't both show up as separate active alerts. Checked the CAPmsgTypeenum for other exposure:Ack/Errorare network-handshake types public feeds don't emit in practice, so they weren't specifically handled. Addedtests/test_cap_references_cancellation.pyandtests/test_cap_update_supersede.py. inject_eas_audio()released the air-chain gate the instant the last EAS sample was queued, so listeners heard the EOM tone cut directly into music/talk with zero break.POST_EAS_SILENCE_SECONDS(1.0s) is now queued as trailing silence before the gate clears, matching how a real station hands the air chain back to regular programming. Addedtests/test_eas_stream_injector_trailing_silence.py.- One more layer on the 2.193.10/2.193.11 ad-metadata work: even with those fixes, "resolve and play" on an ad in Song History almost always still failed — every VAST cache URL checked more than ~20 minutes after being logged already 404s. iHeartRadio's ad server (Triton) discards these per-impression cache entries within minutes; an operator browsing history later and clicking "resolve" is nearly always too late, and no amount of client-side fixing can resolve a link the ad network has already deleted. Moved the VAST-fetch/parse logic out of
webapp/audio_archive/metadata.pyintoapp_core/audio/vast_resolve.py(a Flask-free leaf module) so_handle_icy_metadata()(app_core/audio/sources.py) can resolve an ad tag immediately, on its own dedicated metadata thread, the moment the StreamTitle arrives — while the tag is still fresh — and store the underlying creative's durable CDN URL instead of the ephemeral VAST wrapper. That CDN file is a stable, reused asset, not a per-impression token, so "resolve and play" keeps working long after the original tag would have expired.webapp/audio_archive/metadata.pynow just re-exportsresolve_stream_urlfor the existing manual "resolve" API route. Added regression cases totests/test_stream_metadata_parsing.py.
[2.193.11] - 2026-08-27 - Fix "Ad URL" title text not actually being clickable
Fixed
- Following up on 2.193.10's VAST namespace fix: after that fix landed, resolving an ad still appeared to do nothing when clicked, and the
eas-station-webaccess log showed zero requests ever reaching/api/audio/archives/resolve-stream-url. Root cause was a second, independent bug: the Audio Archives Song History page renders "Ad URL" as the row's title (styled like a link, with an ad icon, right where a song title normally goes -- the obvious thing to click), but it was a plain<span>with no click handler. The actual working "resolve and play" button was a separate, tiny icon-only button off in the row's far-right action column, easy to miss and not visually connected to the "Ad URL" text at all. The title cell's "Ad URL" is now itself the clickable trigger.
[2.193.10] - 2026-08-27 - Fix VAST ad resolution never finding a playable MediaFile
Fixed
- Following up on 2.193.9's fix for iHeart ad metadata display: the Audio Archives Song History page's "resolve and play" button on an ad entry always reported "No playable audio found," even for VAST ad tags that contained a perfectly good
audio/mpegMediaFile. Root cause:resolve_stream_url()(webapp/audio_archive/metadata.py) searched forroot.iter("MediaFile"), but real-world VAST responses (VAST 3.0+, which is standard — confirmed against live iHeartRadio/Triton ad-server responses) declare a default XML namespace on the<VAST>root element, so ElementTree parses every descendant's tag as{http://www.iab.com/VAST}MediaFile— the bare-string search silently matched nothing, regardless of whether the ad actually had playable audio. Fixed with a namespace-agnostic element search. Also now extractsAdTitle,AdSystem, andDurationfrom the VAST payload when present, and the player bar shows them instead of the generic "Ad URL" placeholder when available. Addedtests/test_audio_archive_vast_resolve.py.
[2.193.9] - 2026-08-27 - Fix OLED init file-descriptor leak and iHeart ad-break metadata display
Investigated a report that eas-station-displays was consuming 8.9 GB RSS
after ~4 days of uptime (every other service stayed under 1 GB over the same
window).
Fixed
- Root cause:
ArgonOLEDController.__init__(app_core/oled.py) opens the I2C bus viasmbus2.SMBus(a raw file descriptor with no__del__) before thessd1306handshake; on a host with no OLED physically attached, that handshake always fails, and the just-opened handle was never closed on the exception path.initialise_oled_display()retries this every 5 seconds indefinitely, so over ~4 days it leaked roughly 30,000/dev/i2c-1file descriptors (confirmed via/proc/<pid>/fd), which is what actually drove the RSS growth despite the existing glibc malloc-arena tuning. Fixed by closing the I2C handle before re-raising. Addedtests/test_oled_init_fd_leak.py. - iHeartRadio ad breaks send
StreamTitle=adContext="<base64 VAST url>", which didn't match any of the knowntext=/title=/song=/artist=attribute patterns and wasn't recognized as a decodable base64 blob either (it's wrapped in an attribute, not a bare blob), so the raw, undecoded string was stored and displayed verbatim in the Audio Archives Song History page._handle_icy_metadata(app_core/audio/sources.py) now scans quoted attributes for a base64 value that decodes to an http(s) URL and resolves it into the existingstream_urlfield, which the Song History UI already renders as a clickable "Ad URL" badge. Added a regression test totests/test_stream_metadata_parsing.py.
[2.193.8] - 2026-08-26 - Documentation renders and links cleanly, and CI now enforces it
Auditing the published docs site (kr8mer.github.io/eas-station) for a reported footer whitespace issue turned up two real problems and a sizeable batch of broken links, none of which were previously caught by anything:
- GitHub Pages was misconfigured: repo settings still pointed at the legacy branch-build Pages source, which failed on every single push, running alongside (and redundant with) the custom mkdocs Actions workflow that actually publishes the site. Switched Pages' source to "GitHub Actions" via the API so the failing legacy builder stops firing.
- A stray, unpaired closing
```fence indocs/development/AGENTS.md(line 973, no matching open) silently turned the ~1,100 lines after it into a mis-rendered code block on the live site — found by the new link checker below, since it corrupted markdown-fence-stripping enough that unrelated Python regex source lines started looking like broken links. - 61 broken links (
mkdocs build --strictWARNING-level) across 13 files: relative links fromdocs/*.mdpages into source files (app_utils/eas.py,README.md,requirements.txt, etc.) that resolve fine in a raw repo checkout but 404 on the published site, since GitHub Pages only serves thedocs/tree. Converted tohttps://github.com/KR8MER/eas-station/blob/main/...URLs (matching the pattern already used elsewhere in these same docs), ortree/main/for directory references. - 13 broken in-page/cross-page anchors (INFO-level, not
strict-mode-blocking but still dead links in practice) — mostly stale
TOC entries that didn't track a heading rename, and GitHub/mkdocs's
actual em-dash/
&/emoji slugification (single hyphen, not double) being different from what was originally typed. Verified the fixed anchors resolve correctly by checking generated headingids in the built HTML and click-testing several in a live browser. - The "repeated footer" screenshot that prompted this investigation turned out to be a capture glitch from a flaky remote-browser connection, not a real bug — confirmed via direct DOM query (exactly one footer element, correctly positioned) rather than trusting the screenshot.
Added
tests/test_docs_link_integrity.py: 4 tests enforcing no broken local links indocs/*.mdand perfectmkdocs.ymlnav coverage in both directions (every doc file reachable from nav; no nav entry points at a missing file). Fenced code blocks are stripped before link-scanning so example markdown syntax isn't mistaken for a real reference..github/workflows/docs-pages.ymlnow runsmkdocs build --strictinstead of a plain build, so a future broken link or bad anchor fails the deploy loudly instead of silently shipping.
Verified
- Live in Chromium: light and dark theme contrast/readability across prose, syntax-highlighted code blocks, and callouts — no issues found.
mkdocs build --strictexits 0 (was 61 warnings + 13 info-level anchor mismatches).
[2.193.7] - 2026-08-26 - Remove the dormant direct-hardware SDRSourceAdapter
Pre-v3 punch-list nice-to-have (#6): app_core/audio/sources.py's
SDRSourceAdapter (~430 lines) was the pre-separated-architecture way to
read SDR audio directly via a local RadioManager, inline demodulation
included -- the same "demod running inline in the audio-service process"
smell that 2.193.0's services/demod split fixed for the adapter that's
actually used. Confirmed genuinely unreachable: every SDR-backed
AudioSourceConfigDB row is created exclusively by
webapp/admin/audio_ingest/radio_sources.py, which always sets
managed_by='radio'; eas_monitoring_service.py's loader short-circuits
on that flag and constructs a RedisSDRSourceAdapter directly, never
reaching the factory function that would have instantiated this class.
The web UI's manual "Add Audio Source" form already excludes sdr as a
selectable type for the same reason (its template has a comment: "SDR
sources should be added via Settings > Radio page").
Removed
SDRSourceAdapterand its exclusiveRADIO_AVAILABLE/RadioManagerimport (app_core/audio/sources.py, 1855 → 1428 lines). Thecreate_audio_source()factory's'sdr'branch now raises a clearRuntimeErrorexplaining SDR sources are managed viaRedisSDRSourceAdapterinstead of silently constructing dead code.RBDS_PROGRAM_TYPES(still imported by the liveredis_sdr_adapter.py) is untouched.- 2026-08-26 - Capacity and sizing documentation
Pre-v3 punch-list item: document how much CPU/RAM/thermal headroom a given SDR-receiver/EAS-monitor configuration actually needs, using a real labeled snapshot from a production reference deployment rather than a synthetic benchmark.
Added
docs/hardware/CAPACITY_AND_SIZING.md: reference-deployment snapshot (Pi 5, load average, memory/swap, thermal, disk) plus a sizing table by configuration (web-only, +1 SDR receiver, +stream monitors, 2+ receivers). Linked fromdocs/README.mdanddocs/guides/HARDWARE_QUICKSTART.md's Requirements section, which previously covered only peripheral (GPIO/I2C) compatibility, not CPU/RAM planning.
[2.193.5] - 2026-08-26 - Orphaned SDR audio sources no longer thrash forever
Investigating the punch-list item on eas-unified-WNCI/ERN-LUC "Underrun"
log noise turned up a live, unrelated bug: a deleted radio receiver's
audio source (sdr-wxmon, from a receiver identifier no longer in the
database) had been quarantine-retrying every ~30s-4min for hours in
eas-station-audio.service, respawning its FFmpeg process and Icecast
connection each cycle. Deleting a receiver sends one best-effort
source_delete Redis command to tear the source down; if that single
notification is ever missed (process briefly unreachable, a dropped
pub/sub message) there was no other mechanism to remove it -- the adapter
stayed registered in AudioIngestController and its stall supervisor just
kept retrying a source that can never succeed (nothing publishes audio for
a receiver id the database no longer has).
Added
AudioCommandSubscriber.reconcile_orphaned_radio_sources()(app_core/audio/redis_commands.py): removes anyRedisSDRSourceAdapterwith no matchingAudioSourceConfigDB(source_type='sdr', managed_by='radio')row, via the same EAS-monitor/Icecast/controller teardown thesource_deletecommand already used (extracted into a shared_remove_source_everywhere()helper). Runs every 5 minutes fromeas_monitoring_service.py's main loop as a self-healing safety net -- the one-shot Redis notification stays the primary removal path.
Investigated
- The WNCI/ERN-LUC "Underrun" warnings themselves are confirmed benign: the
SAME-decoder tap's 0.1s read timeout is tight enough to time out waiting
for the last chunk of a read even when its buffer is 1400+/1600 samples
full, and
AudioArchiverwrote continuous, gap-free files for both sources throughout the investigation window. Documented in a code comment at the warning site (app_core/audio/broadcast_adapter.py) so this isn't re-investigated from scratch later. A source that's actually starved logsbuffer=0on every read, assdr-wxmondid above.
[2.193.4] - 2026-08-26 - Structural audit test: every airchain trigger must reach Icecast
Twice in the 2.193.x investigation, a broadcast-trigger function was found that keyed the GPIO relay and played audio locally but never pushed audio into the live Icecast stream queues at all (Manual Send in 2.193.1, and implicitly the same class of bug the 2.193.3 Audio Archive fix guards against). Both were only caught because a user noticed silence and asked pointed questions -- nothing in the test suite would have caught a future regression of the same shape.
Added
tests/test_broadcast_reaches_icecast_audit.py: an AST-based structural regression test that walks every function underapp_core,app_utils,webapp, andscriptscallingset_broadcast_active()and asserts the same function also callsinject_eas_audio()orinject_raw_eas_audio().app_core/rwt_scheduler.py::trigger_rwt_broadcastis exempted via a documentedKNOWN_DELEGATING_TRIGGERSallowlist -- it sets the marker synchronously for instant overlay display, then delegates actual playout/injection to_drive_rwt_airchain()on a background thread, which the audit scans (and passes) on its own. A second test pins a minimum count of known broadcast-trigger functions so the audit can't silently go blind ifset_broadcast_active()is renamed or moved.
[2.193.3] - 2026-08-26 - Resends now show up in Audio Archive
Audio Archive (/audio, "Browse and manage EAS broadcast recordings")
lists one row per EASMessage. A resend replayed the original row in
place instead of inserting a new one, so a retransmission was invisible
there -- only the original generation event ever showed up, even though
the resend keyed GPIO and injected real audio into the live air-chain (see
2.193.1). For a compliance log, a retransmission is itself a loggable
event.
Added
scripts/resend_eas_broadcast.py: each resend now clones the sourceEASMessagerow (audio blobs,same_header, CAP-alert link, etc.) into a new row with a freshcreated_at, taggedmetadata_payload: {resend: true, resend_of_message_id, resent_by}. It sorts, filters, and downloads exactly like an original send on the Audio Archive page. Written unconditionally -- even a failed resend attempt -- matching the SystemLog/audit-ledger entries already written there for the same reason: a real event shouldn't silently vanish. Storage is duplicated per resend (there's no blob-dedup mechanism on this model); acceptable for a rare, human/scheduled-triggered action.
[2.193.1] - 2026-08-26 - Manual Send and RWT never reached the Icecast air-chain
A user manually sent a Required Weekly Test and heard nothing on any
Icecast stream. Auditing every broadcast-trigger code path (every call
site of set_broadcast_active()/play_broadcast_audio()) found that
Manual Send and RWT (both the automated weekly test and the operator
"Run Test Now" button/GPIO trigger) had never injected audio into Icecast
at all -- only Resend and live auto-forward/OTA-relay did. Both paths
keyed GPIO and played audio locally via audio_player_cmd (e.g. aplay),
but nothing pushed the composite WAV into the live stream queues. The
weekly compliance test had been airing nowhere a stream listener could
hear it.
Also: a separate, smaller gap in the same audit -- only the live
auto-forward path overrode each Icecast stream's "now playing" title with
the alert text during a broadcast (app_core.audio.alert_metadata, an
in-process-only singleton); every other path silently no-op'd if it tried
the same call, since it doesn't share a process with the audio service's
live IcecastStreamer objects.
Fixed
app_core/audio/redis_commands.py: addedinject_raw_eas_audio, a sibling of the existinginject_eas_audio(resend's mechanism) for callers that have composite WAV bytes in hand but noEASMessagerow to reference by id -- Manual Send and RWT persist aManualEASActivation, not anEASMessage. Same base64-in-JSON patternabort_injected_audioalready uses for its EOM burst.webapp/eas/workflow.py(Manual Send) andapp_core/rwt_scheduler.py(_drive_rwt_airchain, shared by the automated weekly RWT, the "Run Test Now" button, and the GPIO RWT trigger) now callinject_raw_eas_audioright after keying the broadcast marker, mirroring exactly howscripts/resend_eas_broadcast.pyalready does it. Best-effort and non-fatal, same as every other injection call site -- a Redis/audio-service hiccup can never block relay keying or local playback.eas_monitoring_service.py: added_reconcile_broadcast_metadata(), polled from the existing ~4 Hz main loop, which mirrors the Redis broadcast-state marker'slabelonto every Icecast stream's title. Since every broadcast path already writes that marker (to key the GPIO relay and drive the countdown overlay), this covers Manual Send, RWT, Resend, and auto-forward automatically -- no per-caller wiring, unlike the old direct-call approach that only worked for auto-forward. The two direct calls inapp_core/audio/auto_forward.pywere removed as redundant.app_utils/eas.py(EASBroadcaster.handle_alert) andscripts/resend_eas_broadcast.py: both now prefer the original alert'sheadlineover the generic event-type name for the broadcast label when one is on file, matching what auto-forward's now-removed direct call already did -- more specific text ("Severe Thunderstorm Warning issued until 2:15 PM by NWS Cleveland" vs. just "Severe Thunderstorm Warning") for both the countdown overlay and the new stream-metadata override.
[2.193.0] - 2026-08-26 - Split FM/AM demodulation into its own service
A user watched a resend's EAS audio get injected into all three configured
Icecast mounts ("EAS stream injector: pushed 73.9s of EAS audio ... to
source 'X' broadcast queue" -- confirmed in the logs) yet reported never
having heard a clean one air. journalctl showed why: all three mounts
hit "Icecast buffer running low" / "buffer completely empty -- Audio
source starved" every 10-30 seconds, continuously, all day -- not just
during EAS injections, interrupting normal programming too.
Profiling the live eas-station-audio.service with py-spy record --gil
(non-blocking, 15s @ 30Hz) found the cause: the redis-sdr-* thread that
receives IQ samples from eas-station-sdr.service dominated GIL-held
samples. On every incoming IQ chunk it ran several
scipy.signal.oaconvolve FFT convolutions inline (stereo pilot detection,
RBDS extraction -- app_core/radio/demod/fm.py), starving the audio
service's three real-time Icecast feeder threads sharing the same
interpreter, each of which needs to wake roughly every 50ms to keep its
buffer fed.
Added
services/demod/: a neweas-station-demod.servicesubprocess that owns FM/AM demodulation, mirroring the queue + dedicated-worker-thread patternapp_core/radio/demod/rbds_worker.pyalready used for RBDS decoding (a demodulator is stateful/order-dependent and must never be called from more than one thread or on out-of-order chunks). Subscribes tosdr:samples:<receiver_id>, publishes demodulated PCM audio todemod:audio:<receiver_id>and decoder status (stereo lock, RBDS PS/PI/radiotext) todemod:status:<receiver_id>. Listens on port 5106, matching the existing per-subsystem-service pattern (docs/architecture/SDR_SERVICE_ARCHITECTURE.md,docs/troubleshooting/FIREWALL_REQUIREMENTS.md).
Fixed
app_core/audio/redis_sdr_adapter.py:RedisSDRSourceAdapterno longer runs a demodulator inline -- it's now a thin consumer of the demod service'sdemod:audio:*channel, so the DSP work that was starving the Icecast feeders can never again share a GIL with them. RBDS/stereo metadata (the ~300-line block_update_metrics()builds for the UI) is unchanged; it now reads a pickledDemodulatorStatussnapshot fromdemod:status:*instead of a local demodulator reference.eas_monitoring_service.py: anumpy.float32-> psycopg2 type-adapter failure (can't adapt type 'numpy.float32') was silently breaking everyaudio_source_metricswrite, once a second, since the writer was added -- the table had never actually been populated in production.peak_level_db/rms_level_db/sample_rate/etc. are now coerced to native Python types before reaching SQLAlchemy. Found during the same investigation; confirmed viapy-spythat it was not the cause of the Icecast starvation (a separate, real bug worth fixing anyway).
[2.192.1] - 2026-08-24 - Fix stale version claims in tech-stack shields and docs
A user asked "why is Redis showing version 7?" -- the footer badge and README
had been advertising versions that no longer matched what's actually running,
because install.sh doesn't pin these packages and nobody had gone back to
re-check the shields since they were first written.
Fixed
- Redis: badge/README claimed 7.1; the running server is 8.0.2
(
redis-cli INFO server,redis-server --version).install.shinstallsredis-servervia apt with no version pin, so the shield now reads Redis 8 and no longer implies a pin that doesn't exist. - PostGIS: badge/README/Mermaid diagrams/architecture docs claimed 3.4;
the actual extension is 3.5.2 (
SELECT postgis_full_version()).install.shonly targets the major series (postgresql-${PG_MAJOR}-postgis-3), not a minor version. - Nginx: badge said "Alpine", implying a Docker
nginx:alpineimage. The real deployment is a bare-metalapt-get install nginxpackage (confirmed vianginx -v-> 1.26.3); there is no Dockerfile or docker-compose anywhere in the project that usesnginx:alpine. Badge and attribution table now say "system package" / "system (apt)" instead. - Chart.js: badge/README claimed a single version, 3.9.1. Two vendored
copies are actually in concurrent use --
static/vendor/chartjs/chart-v3.min.js(v3.9.1, used by 5 dashboard templates) andchart.min.js(v4.4.0, used bysystem_health.htmlandrepo_stats.html) -- an unconsolidated dual-version situation, not simple staleness. Badge and README now disclose both versions and both files.
Updated surfaces: templates/partials/tech_stack_badges.html, README.md
(top badge row, Mermaid diagram, infrastructure prose, attribution table),
docs/architecture/SYSTEM_ARCHITECTURE.md,
docs/architecture/THEORY_OF_OPERATION.md, and
docs/assets/diagrams/system-deployment-hardware.svg.
[2.192.0] - 2026-08-24 - Close the API reference's docstring gap, add structured param docs
Follow-up to 2.191.0's live /api-reference page. It shipped reporting
89% documentation coverage (231 of 259 /api/* routes) and only a
one-line summary per route, even for endpoints that take a JSON body or
query params -- enough to say "this endpoint exists" but not enough for
someone integrating against it to know what to send.
Added
- Structured
Body:/Query:/Path:/Returns:docstring sections, parsed byapp_utils.api_reference._parse_docstring_sections()and rendered as a labeled block under each route's narrative on the reference page.Args:/Params:are also recognized, since ~100 existing route docstrings already used one of those loosely before this parser existed -- those routes get the richer rendering for free. A section header must be exactlyBody:(etc.) on its own line; trailing qualifier text on the header line (Body (optional):) is not recognized and falls back into plain narrative text -- documented indocs/development/AGENTS.md's Flask Route Pattern, along with the section-header convention itself, so new routes follow it going forward.
Fixed
- The 28 routes with no docstring at all now have one -- audio
archive management (12 routes), the LED sign controller (9), SDR
receiver CRUD (4), the alert self-test harness, the diagnostics
validation suite, and the radio monitoring snapshot. Coverage is now
259/259 (100%), verified against the live app after every change
(
SKIP_DB_INIT=1avoids needing a real database just to readapp.url_mapand docstrings -- no HTTP request is made, sobefore_request's database initialization never runs).
Testing
- New tests for
_parse_docstring_sections()(narrative/section splitting, header-order normalization, empty-section handling) and forcompute_api_reference()exposingnarrative/sectionsper route. - Full local test suite: 2655 passed, 0 failed.
- Re-verified against the live app's real
app.url_mapafter fixing the two docstrings that used theBody (optional):trailing-text mistake the new AGENTS.md warning now calls out -- caught only because the narrative/sections split was checked against real route data instead of trusting the docstrings were written correctly.
[2.191.0] - 2026-08-24 - Add a live REST API reference page
There was no backend API reference at all: docs/README.md pointed at
docs/frontend/JAVASCRIPT_API.md and labelled it "REST API reference", but
that file documents frontend JS globals (EASApi, EASWebSocket, ...), not
the ~260 /api/* Flask routes, and its own banner already says large parts
of that content are aspirational. Separately, docs/guides/API_KEY_MANAGEMENT.md
describes a planned-but-unimplemented key-based auth system for external API
access.
Added
/api-reference(and its JSON sibling/api/api-reference) -- a live reference of every/api/*route, computed fromapp.url_mapon each request rather than a hand-maintained file that immediately goes stale. Mirrors the/repo-statsidiom exactly (a page the codebase already criticizes its own predecessor for being "a committed HTML blob regenerated by a script"). Groups routes by module, shows HTTP methods, the view function's docstring (already present on ~89% of/api/*handlers), and its access requirement. Verified against the real running app (real Postgres + PostGIS, a real logged-in session): 259 routes, 231 documented, 182 access-gated, correctly listing itself.eas_auth_requirementintrospection marker on every permission decorator (require_auth,require_role,require_permission,require_any_permission,require_all_permissionsinapp_core/auth/decorators.py/app_core/auth/roles.py, andrequire_permission_or_setup_modeinwebapp/admin/environment.py) -- stamped onto the wrapped view function (survivesfunctools.wraps, which only copies__doc__/__name__) so the reference page can show a route's real access requirement without re-deriving it from source text via regex, the same "trust the live app" principleapp_utils/repo_statsalready established for route counting.- New
webapp/navigation/registry.pyentry (Analytics section) andtests/test_api_reference.py(16 tests): route filtering, docstring/ summary extraction, auth-marker introspection for all six decorators, nav registration, and template wiring.
Fixed
docs/README.md's developer table no longer labels the JavaScript-globals doc as the REST API reference; it now points at the real live page and flagsAPI_KEY_MANAGEMENT.mdas not-yet-implemented design reference.- Corrected a changelog authoring mistake from the 2.189.x/2.190.x entries above: 2.190.1 had been inserted between 2.189.1 and 2.190.0 instead of above both, and the repo-stats entry's heading had been overwritten to "2.189.1" (duplicating the resend-logging entry's version number) while keeping its original repo-stats content. Reordered newest-first and restored the repo-stats entry's correct heading, 2.189.0.
[2.190.1] - 2026-08-24 - Upgrade dependencies for security
Bumped every pinned dependency in requirements.txt to its latest available
version (37 packages: Flask/Werkzeug/Flask-SocketIO/Flask-WTF/Flask-Caching/
Flask-Compress, SQLAlchemy/Alembic/GeoAlchemy2/psycopg2-binary/greenlet,
requests/certifi/feedparser, redis/hiredis, gunicorn, pytz, psutil, pytest/
pytest-asyncio, lxml/Pillow/scipy, and others) — most notably certifi
(CA bundle freshness, required for IPAWS TLS) and pytz (timezone database
freshness, called out in its own comment as "CRITICAL FOR PROPER TIME
DISPLAY").
Changed
redis7.1.0 → 8.1.0 andgunicorn23.0.0 → 26.1.0 (both major version jumps) were checked against their actual release notes before bumping, not just pinned blindly: redis-py 8.0's headline change (RESP3 becomes the default wire protocol) explicitly preserves legacy RESP2 response shapes for existing code, and gunicorn 26's breaking change (dropping theeventletworker class) doesn't apply here since this project runs--worker-class gevent, which gunicorn 26 still supports. Verified redis-py 8.1.0 directly against a real local Redis server (get/set/delete,decode_responses, pub/sub — the exact patternsapp_utils/eas.pyandapp_core/audio/redis_commands.pyuse) rather than relying on mocked tests alone.numpystays at 2.3.5, not bumped to the latest 2.5.2:numbacarries a pre-existing, intentional<0.64.0cap (documented reason -- 0.64+ pulls a new llvmlite requiring a 55 MB download, expensive on the Raspberry Pi hardware this runs on), and every numba release under that cap requiresnumpy<2.4.pip install -r requirements.txtoutright refuses to resolvenumpy==2.5.2against that constraint -- this isn't a judgment call, it's a real, reproducible dependency conflict. 2.3.5 is the newest numpy release compatible with the existing numba pin.- Synced every version number this changed in
README.md's tech-stack badges and dependency-attribution tables andtemplates/partials/tech_stack_badges.html's footer badges, including several rowstests/test_tech_stack_badges.pydoesn't cover (redis-py, hiredis, Flask-SocketIO, Flask-WTF, Flask-Caching, GeoAlchemy2, gpiozero, greenlet, orjson, psutil, psycopg2-binary, pyproj, pyshp, pytest, pytest-asyncio, pytz, requests, ujson, luma.oled) -- found via a full cross-reference sweep ofrequirements.txtpins against every README table row mentioning each package, not just the ones the existing test happened to check.
Testing
- Full local test suite against a venv built from the fully upgraded
requirements.txt: 2634 passed, 267 skipped, 61 xfailed, 6 xpassed, 0 failed -- run twice (once mid-upgrade, once after the badge/table fixes) to confirm no regression crept in between. redisupgrade verified against a real, running local Redis server, not only mocks (see above).gunicorn'sgeventworker class confirmed importable under 26.1.0.
[2.190.0] - 2026-08-24 - Hold to Abort Broadcast now stops injected Icecast audio too
"Hold to Abort Broadcast" (the web button and the physical GPIO Dump/Abort
input) only ever stopped a local playback subprocess by PID. A broadcast's
audio is also pushed directly into the live Icecast air-chain
(eas_stream_injector.inject_eas_audio(), queued into each source's
BroadcastQueue up front) -- a completely separate pipeline the local kill
never touched, and the one that station operators and stream listeners
actually hear. On a station with no local audio player configured at all
(Icecast-only, a real supported deployment mode called out in the codebase's
own comments), there was never a PID to find, so abort_current_broadcast()
returned immediately ("nothing is currently playing") and the alert played
out to completion regardless of how long the button was held.
Added
abort_injected_audio()(app_core/audio/eas_stream_injector.py) -- purges every active source's queued EAS audio from itsBroadcastQueuesubscribers (using the existingclear_subscriber_queue()), releases the injection gate, and can optionally inject a replacement WAV (the EOM burst) immediately after, so stream listeners hear a compliant sign-off instead of a hard cut to dead air.- New
abort_injected_audioRedis command (app_core/audio/redis_commands.py):AudioCommandPublisher.abort_injected_audio(eom_wav=None)(base64-encodes the EOM bytes for the JSON-only command channel) and the matchingAudioCommandSubscriberdispatch handler, mirroring the existinginject_eas_audiocommand's shape.
Fixed
abort_current_broadcast()no longer treats "no local PID" as "nothing to abort." It now checks the broadcast-active marker too, and always attempts the Icecast purge (with the EOM burst injected into the stream) regardless of whether a local subprocess was ever running. The audit ledger'sEAS_CANCELLATIONentry now also recordsinjected_audio_cleared(chunk count) alongsideeom_sent.POST /api/broadcast/abortno longer 409s when there's no trackable PID. That case used to mean "couldn't actually abort anything"; it now correctly means "aborted via the Icecast purge instead of a local kill," sinceabort_current_broadcast()handles it.- Extended
tests/test_gpio_dump_broadcast.pyandtests/test_broadcast_phase_and_web_abort.pyto cover the no-PID/ Icecast-only abort path and the newinjected_audio_clearedaudit field; newtests/test_eas_abort_injected_audio.pycovers the purge itself (multi-subscriber clearing, gate release, EOM re-injection) and the new Redis command's publisher/dispatcher wiring.
Real-hardware verification still needed
This closes the gap identified when 2.189.1 shipped, but per this project's standing practice for broadcast-control changes (see the 2.184.0/2.184.1 Dump/Abort Broadcast history below, where mocked unit tests alone missed two real gaps), the on-air behavior -- does an Icecast listener actually stop hearing the alert, and does the injected EOM burst actually play -- should be confirmed against a real running audio-service and Icecast mount before this is fully trusted in production.
[2.189.1] - 2026-08-24 - Fix resend logging and its dead local-playback config
Fixed
- A resend ("Resend on Air") was never actually reaching the tamper-evident
audit ledger. Every other broadcast trigger (manual Send, RWT, live/
auto-forwarded alerts) writes an
EAS_BROADCASTaudit-log entry either via the SQLAlchemyafter_insertlistener onEASMessage(new transmissions) or an explicitAuditLogger.log()call (webapp/eas/workflow.py). A resend replays an existingEASMessagerow rather than inserting a new one, so neither mechanism ever fired for it — the only record was a plainSystemLogrow written at the very end ofscripts/resend_eas_broadcast.py, and even that vanished with zero trace if anything raised earlier in the script, since its stdout/stderr are redirected toDEVNULLby the Flask route that launches it. The script now writes an explicitEAS_BROADCASTaudit entry (taggedresend: true) unconditionally, and the whole playout body is wrapped so a failure is always recorded (as anERROR-levelSystemLogrow and asuccess: falseaudit entry) instead of disappearing silently. - A resend's local audio playback — and therefore its PID, which is what
makes a broadcast abortable — was dead code on every real deployment.
scripts/resend_eas_broadcast.pyresolved its player command from anAUDIO_PLAYER_CMDapp.config/env key that nothing else in the codebase ever sets; every other broadcast path resolvesaudio_player_cmdthroughload_eas_config()(theEAS_AUDIO_PLAYERenv var or theEASSettings.audio_playerDB column). Because of this, a resend never played through a locally configured player and never published a PID viaplay_broadcast_audio()— so on a station with a local player configured, the "Hold to Abort Broadcast" button had nothing to find and kill for a resent alert. Now reads the sameload_eas_config()value as manual Send, RWT, and live/forwarded alerts. - New
tests/test_eas_resend_logging.pypins both fixes:audio_player_cmdcomes fromload_eas_config(), a successful resend writes both theSystemLogrow and theEAS_BROADCASTaudit entry, and a resend that raises mid-playout still writes both, flagged as a failure, and returns a non-zero exit code instead of looking like a silent success.
[2.189.0] - 2026-08-24 - Fix undercounted stats and add git history to Repo Stats
Fixed
- Blueprints undercounted on
/repo-stats.count_components()only scannedapp_core/andwebapp/for= Blueprint(, missing the four production blueprints defined inservices/*/api.py(zigbee, network, gps, displays) -- the same modules whose routes the source-scan fallback already counted, so the Routes and Blueprints tiles disagreed with each other. The scan now coversservices/too (37 -> 41). - Scripts undercounted on
/repo-stats. The Scripts tile only counted files underscripts/, missing every root-level operator entry point:install.sh,update.sh,uninstall.sh,diagnose.sh,app.py,wsgi.py,eas_monitoring_service.py,sdr_hardware_service.py. These now count too (73 -> 81).
Added
- Git history tile on
/repo-stats-- commit count, distinct contributor count, and the most recent commit date, read viagit loginapp_utils/repo_stats/scanner.py:git_history(). Hidden automatically when the checkout isn't a git repository (matches the existinggit ls-filesvs. filesystem-walk fallback) rather than rendering misleading zeroes.
[2.188.1] - 2026-08-23 - Redirect /admin to Settings once an admin exists
Changed
/adminnow redirects to/settingsfor authenticated visitors instead of rendering a stats-overview stub. The route itself can't be removed --app.py's auth gate exempts this exact endpoint from the login-wall specifically so a fresh install with zero admin users can reach the create-first-admin form without being able to log in yet (theadmin_setup_modeallow-list in thebefore_requesthook) -- but once an admin exists there was nothing left on the page worth a dedicated view for: the same Active Alerts / Total Boundaries counts already appear on the homepage. The first-time setup wizard (setup_mode) is unaffected and still renders normally.- Removed the "Admin Dashboard" entry from Settings -> Configuration
(
href="/admin") -- it had become a circular link from inside Settings back to Settings itself.
[2.188.0] - 2026-08-23 - Retire the Admin Dashboard's tabbed interface (phase 5, final)
This completes the admin.html//settings consolidation started in 2.186.2
(phases 1-4: #2455, #2456, #2458, #2459). templates/admin.html goes from
~2099 lines to 135 -- a stats overview plus the first-time setup wizard,
nothing else.
Added
- New
/admin/location-settingspage -- station jurisdiction, timezone, map defaults, and monitored counties/zones (SAME/FIPS + NOAA zone picker). Extracted verbatim (same pill-tab structure, same element IDs) from the Admin Dashboard's System tab. - New
/admin/eas-encoder-settingspage -- SAME/EAS encoder configuration: station identity, pre/post-alert tone signaling (bell/beep/three-tone/QC-II/DTMF/MDC1200), event filtering, and dedup windows. Extracted verbatim from the Admin Dashboard's Broadcast tab, which had no other content -- Broadcast is now gone from admin.html entirely. - Both new pages share
static/js/admin/location-settings.jsunmodified -- it already self-initializes three independently-guarded features (initLocationSettings,initZoneSearch,initEasSettings) onDOMContentLoaded, each a no-op when its target elements are absent, so loading the same file on two different pages was safe without any per-page glue code.
Removed
- The Admin Dashboard's entire tabbed interface. With System and
Broadcast (the last two tabs) extracted, nothing was left in the tab
shell -- removed it along with the now-fully-dead inline
EAS_*/BOUNDARY_TYPE_CONFIG/boundaryCache/etc. globals block,#confirmationModal(nothing on the page callsshowConfirmation()anymore),#operationStatus(dead since the Operations tab was removed in phase 2), and a "Keyboard Shortcuts" hint for a feature (setupKeyboardShortcuts()) that was already never actually invoked anywhere in the codebase. Replaced with a short pointer card to/settings.core.js,boundary-management.js,alert-management.js,zone-catalog.js, andlocation-settings.jsare no longer loaded onadmin.html-- onlyutilities.js(forshowStatus()) anduser-management.js(the first-time setup form) remain. - Simplified
dashboard.py's/adminroute to match -- dropped all the location/EAS/boundary-stats context computation (location_settings,eas_event_codes,eas_fips_states,eas_recent_messages,boundary_stats, etc.) that admin.html no longer reads. It now only computes the four stat-card counts andsetup_mode. Removed the now-unused imports this left behind (Dict/List,EASMessage,get_location_settings,build_county_forecast_zone_map,PRIMARY_ORIGINATORS,SAME_HEADER_FIELD_DESCRIPTIONS,manual_default_same_codes,ORIGINATOR_DESCRIPTIONS,P_DIGIT_MEANINGS). - Updated
tests/test_admin_dashboard_fixes.py'stest_dashboard_uses_current_app, which pinned to two specificcurrent_app.config.get(...)calls that were part of the now-removed EAS-message-stats computation. The regression it actually guards against (an undefined bareappvariable) is still covered by thecurrent_app.logger.*assertions, which remain.
[2.187.4] - 2026-08-23 - Fix broken boundary upload on Data Management page
Fixed
- GeoJSON/Shapefile upload forms on
/admin/data-managementdid a plain HTML submission instead of the AJAX upload flow. #2459 extracted this page from the Admin Dashboard's Data tab but missed a script include: despite its name,static/js/admin/alert-management.js-- notboundary-management.js-- owns the#uploadForm/#shapefileUploadFormsubmit handlers (historical, not a naming mistake worth fixing right now). Added the missing<script src>.
[2.187.3] - 2026-08-23 - Extract Data Management from Admin Dashboard (phase 4 of consolidation)
Added
- New
/admin/data-managementpage (templates/admin/data_management.html,dashboard.py:data_management_page()) -- boundary polygon upload (GeoJSON/Shapefile, any type: electric, fire, school, custom, ...), the NOAA zone catalog (.dbf upload/reload/search), and bulk manage/delete, extracted verbatim from the Admin Dashboard's Data tab. Confirmed this does NOT duplicate the already-registered/admin/county_boundaries-- that page manages NOAA county/zone reference lookup data (FIPS/SAME code resolution) via entirely different backend endpoints (/admin/county_boundaries/*) from this page's general boundary-polygon management (/admin/upload_boundaries,/admin/clear_boundaries/*, etc.). - Extracted structurally verbatim rather than redesigned: same
pill-tab sub-navigation, same element IDs, same
static/js/admin/boundary-management.jsandzone-catalog.jsfiles -- both already expect these exact IDs and Bootstrapshown.bs.tabevents, so relocating the markup as-is was the lowest-risk option.
Removed
Admin Dashboard's Data tab. System is now the dashboard's first (default-active) tab.
This completes the multi-phase admin.html/
/settingsconsolidation (phases 1-4: #2455, #2456, #2458, this one). Remaining in admin.html: System tab's Location Settings/Alert Filtering and Broadcast's EAS Encoder Settings -- both share one 1,475-line interactive JS module (FIPS pickers, live zone search, EAS tone/DTMF UI) and are the last piece.
[2.187.2] - 2026-08-23 - Extract Alert Management from Admin Dashboard (phase 3 of consolidation)
Added
- New
/admin/alert-managementpage (templates/admin/alert_management.html,dashboard.py:alert_management_page()) -- the stored-alerts table (search/edit/delete) and mark/delete-expired operations that used to be the Admin Dashboard's "Alerts" sub-tab. No dedicated page existed for this, so it was extracted rather than deleted, reusing the exact samestatic/js/admin/alert-management.jsand edit-alert modal markup (faithful move, not a rewrite, given how much interactive logic is in that file).
Fixed
alert-management.jswould have crashed on any page without the Data tab's boundary-upload form. Despite its name, this file also unconditionally wires up#uploadForm/#shapefileUploadFormsubmit handlers (document.getElementById(...).addEventListener(...)with no null check) -- historical, not something worth renaming/splitting right now. Guarded both, matching the defensive pattern already used elsewhere in the same file for#alerts-subtab. Required to load this file safely on the new standalone page; behavior on the Admin Dashboard's Data tab, where those elements do exist, is unchanged.
Removed
Admin Dashboard's Alert Management sub-tab and its now-orphaned Edit Alert modal (nothing else in admin.html triggers it once the sub-tab and its "Edit" buttons are gone).
alert-management.jsitself stays loaded on admin.html -- it still runs the Data tab's boundary-upload handling.Remaining in admin.html: Data tab, System tab's Location/Alert Filtering, Broadcast's EAS Encoder Settings -- all three share one 1,475-line interactive JS module (FIPS pickers, zone search, EAS tone/DTMF UI) and are next.
[2.187.1] - 2026-08-23 - Remove duplicate navigation entries
Removed
- Hardware Settings and Zigbee were listed twice -- once under Monitor -> Station Hardware, again under Settings -> Hardware, same pages, two different icons/labels. Both are configuration screens, not live-status views, so Settings is their one home now; Monitor -> Station Hardware keeps only GPS & Time (a real live-status dashboard).
- Audio Ingestion (Monitor) duplicated Audio Streams (Settings) -- both
pointed at
/admin/audio-sources. Kept the Settings entry. - Settings -> Hardware's entry now references
endpoint="hardware.hardware_settings_page"instead of a hardcodedhref="/admin/hardware"-- same URL, but matches how the rest of the registry references pages that can move, and keepstest_both_settings_pages_are_reachable_from_the_navigation(which checks for that specific endpoint reference) passing now that it's no longer duplicated under Monitor. - Left Security Center duplicated on purpose -- also listed under both
Monitor -> Diagnostics and Settings -> Security & Access with the same
LOGS_VIEWpermission gate. Removing the Settings copy left a LOGS_VIEW-only viewer with a Settings link that opens to zero cards (caught bytest_settings_link_never_leads_to_an_empty_hub) -- it's the only Settings-hub item that permission level can see, so both copies stay. - Left Security Settings/Security Policies duplicated on purpose -- one copy lives in the signed-in user's own account menu ("your password, MFA and active sessions"), the other in the Settings hub ("password policy, session limits and MFA settings"). Same URL, but a personal quick-link and a general-discoverability entry serve different purposes, similar to how most sites surface account security in both places.
[2.187.0] - 2026-08-23 - Remove Admin Dashboard's Operations and Security tabs (phase 2 of consolidation)
Added
- "Trigger Poll Now" and "Manual Alert Import" on Admin -> Alert Poller
Settings (
/admin/poller) -- these had no home other than the Admin Dashboard's Operations tab, so their forms and JS moved here rather than being deleted with the rest of that tab. - New
/admin/user-accountspage (templates/admin/user_accounts.html,dashboard.py:user_accounts_page()) extracting the Admin Dashboard's Security tab's "Users & Roles" sub-tab -- the full create/list/reset/ delete-user UI. This one was NOT a duplicate: the nav registry's "User Accounts" entry linked straight at/admin/users, which turned out to be a JSON-only API (dashboard.py'sadmin_users(), GET returns a user list, POST creates), not a page -- clicking that Settings link served a raw JSON blob. The Admin Dashboard's tab was the only working UI for user management, so it was extracted rather than deleted, and the registry entry fixed to point at the new page instead of the API.
Removed
Admin Dashboard's entire Operations tab (CAP Alert Polling, One-Button Backup, One-Button Upgrade, Import Archived Alert, Database Optimization, Fix Intersections, Environment Config, Database Health, Alert Purge). Confirmed the backup/upgrade/DB-optimize/fix-intersections/DB-health buttons called the exact same backend endpoints (
/admin/operations/backup,/admin/operations/upgrade,/admin/optimize_db,/admin/recalculate_intersections,/admin/check_db_health) as the already-registered/admin/operationspage, which itself links onward to the more capable/admin/backupsfor full backup management -- these weren't just duplicate links, they were independent frontends for the same backend actions, and any future change to one would silently not apply to the other. Environment Config duplicated/admin/environment's own editor the same way; deleted its modal along with the two forms.static/js/admin/operations.js-- entirely dead code once the Operations tab was removed; nothing else loaded it.hardware-settings.js's redundant<script>include on admin.html -- found while auditing the other includes: nothing on the page calls any of its functions (it's the GPS sky-plot panel for the already-registered Hardware Settings page, which still loads it correctly on its own).Alert Purge was an orphaned page -- had no navigation entry anywhere, reachable only via this tab or a direct URL. Added to Settings -> Data & Storage, next to Backups, instead of just deleting its only link.
Admin Dashboard's entire Security tab -- "Users & Roles" moved to the new
/admin/user-accountspage above; "Local Authorities" was already a separate registered page, just linked from here too.This continues the admin.html/
/settingsconsolidation from 2.186.2. Remaining tabs (Data, System, Broadcast's EAS Encoder Settings form) still need the same treatment.
[2.186.2] - 2026-08-23 - Remove pure link-grid tabs from the Admin Dashboard
Removed
Admin Dashboard's Services and Hardware tabs, and the "Broadcast Tools" and "Security Monitoring" link grids inside the Broadcast and Security tabs. All four were pure card-grids linking to other pages -- zero inline forms or JS actions -- and every single link duplicated a page already registered in the nav (
webapp/navigation/registry.py/registry_settings.py), same class of drift that caused #2454. Verified each of the ~25 removed links against the registry before deleting (two, "EAS Workflow" and "RWT Schedule", are registered viaendpoint=rather than a literalhref=matching their admin.html URL, which is why an earlier plain-text grep missed them -- double-checked those against the actual endpoint names before confirming duplication).This is phase 1 of a larger admin.html/
/settingsconsolidation -- admin.html's remaining tabs (Data, System, Broadcast's encoder form, Security's user management, Operations) contain real inline functionality, including several that duplicate the implementation of a separately registered page (not just the link) -- e.g. its own user-CRUD UI, its own DB-optimize/recalculate-intersections buttons, its own backup trigger, and its own.enveditor, each independent of the dedicated page for the same feature. Those need to be resolved (confirm equivalence, then delete one side) or extracted into their own registered pages, tab by tab, as separate follow-up work -- left as-is here since it changes application behavior, not just navigation.
Fixed
- Admin Dashboard's stat cards didn't match the rest of the app.
static/css/admin.csscarried a stale, plainer.stat-card/.stats-griddefinition (flat primary/secondary gradient, 16px radius, no shimmer);static/css/styles.cssowns the current "iOS 26 vibrant mesh gradient" version used everywhere else. Same selector, same specificity, andadmin.cssloads afterstyles.css(base.html'sextra_cssblock), so the cascade tie silently went to the older rule -- the exact trap already documented and fixed once in this file for.nav-tabs .nav-link, just not caught here. Removed the stale duplicate so the Admin Dashboard picks up the same stat cards as every other page.
[2.186.1] - 2026-08-23 - Fix Tickstem/Heartbeat nav placement
Fixed
- Tickstem Uptime Monitor didn't appear on the Settings hub. #2453 added
it (and a duplicate "Uptime Heartbeat" entry) to the navbar's Diagnostics
section, on the mistaken belief that Heartbeat had no menu entry at all --
it did, in
webapp/navigation/registry_settings.py(a separate module fromregistry.pythat a plain grep missed), already surfaced correctly on/settings. Removed the duplicate/misplaced entries and added Tickstem next to Heartbeat in the Settings hub's Configuration group instead.
[2.186.0] - 2026-08-22 - Add Tickstem uptime-monitor integration
Added
- New Admin -> Tickstem Uptime Monitor page. Manages an inbound Tickstem
uptime monitor (polling this box's public
/healthendpoint) directly from the admin UI via Tickstem's bearer-token Monitors API -- create, pause, resume, and delete the monitor, and view its recent check history, without leaving EAS Station. Distinct from the existing outbound dead-man's-switch Heartbeat page: that one pings out on a schedule (works with any healthchecks.io-style receiver, no API key); this one is Tickstem polling in, authenticated with an account API key (app_core/tickstem_client.py,TickstemSettingsmodel/tickstem_settingstable). The account API key is never returned to the browser after being saved. Both the Heartbeat and new Tickstem pages were also added to the navigation registry (System Health group) -- the Heartbeat page previously had no menu entry at all.
[2.185.8] - 2026-08-22 - Widen img-src CSP and fix heartbeat ping method
Fixed
- Album art blocked by CSP on
/audio-monitor-- station metadata (ICYStreamTitle) can point at any CDN the broadcaster happens to use (seen:image.iheart.com); the CSP'simg-srconly allowlisted three specific hosts, so art from any other station silently failed to load.img-srcnow allows anyhttps:origin, since this only affects passive image loading, not script execution. - Uptime heartbeat pings 401'd against some healthchecks.io-alternative
services (e.g. Tickstem) --
send_heartbeat_ping()always sentGET; those services only acceptPOSTon the ping route and (confusingly) reply 401 "missing authorization" to aGET, which looks like a credential problem but isn't one. Now sendsPOST, which healthchecks.io itself also accepts, so this is backward compatible.
[2.185.7] - 2026-08-22 - Fix broken audio streaming and several console errors site-wide
Fixed
- Icecast audio playback was silently blocked everywhere -- the CSP had
no
media-srcallowance, so every<audio>element pointed at Icecast (a different origin/port than the Flask app) failed to load with no visible error beyond the browser console.media-srcnow includes the Icecast origin, resolved per-request fromIcecastSettings(the host:port is user-configurable, so it can't be hardcoded). - The
/audio-monitorlive players never rendered --getIcecastStreamUrl()readmetadata.icecast_mount, a field the API has never actually returned (it'smetadata.icecast_stream_url). Derived the same-origin path the nginx proxy (location ~* ^/(?!eas-)([a-z0-9_-]+\.mp3)$, already correctly configured) expects from the field that does exist. Verified live:currentTimeadvancing during actual playback, not just a clean network response. /admin/audio-sourcescards rendered correctly for ~30s, then flipped to "UNKNOWN" / "? Hz . ? ch" / frozen "-- dBFS" labels and stayed broken -- three compounding bugs: (1) the periodic WebSocket push (_emit_audio_sources_update) sends a deliberately thin payload with noconfig/metrics, but the frontend replaced the richer REST-fetched source objects wholesale instead of merging, discarding sample rate/channel/level data every ~30s; (2) that same push'sidfield was the numeric DB primary key while the REST endpoint'sidis the source name, breaking the merge's DOM-element lookups once merging was fixed; (3) itstypefield assumedsource_typewas an Enum with a.valueattribute when it's actually a plain string column, sotypesilently resolved toNone-> "unknown" -> "UNKNOWN" on every source, every time. Fixed the merge to be a merge, and fixed the push to serializeid/typethe same way the REST endpoint already does.- Peak/RMS level labels were permanently stuck on their "-- dBFS"
placeholder on any page not loading
realtime-vu-meters.js(e.g.admin/audio_sources.html) -- the update guard skipped writing whenever the label's current text already contained "dBFS", which the static HTML placeholder always does, so the check could never tell "still the placeholder" from "already owned by the other script" apart. Replaced it with an explicitdata-live-vumarkerrealtime-vu-meters.jssets only when it actually starts driving a label. bootstrap is not definedcrashed/settings/stream-profileson every load --bootstrap.Modal.getOrCreateInstance(...)ran at the top of a synchronous inline<script>, butbootstrap.bundle.min.jsloads via<script defer>and hadn't executed yet at that point in parsing. Deferred the modal instance creation into the page's ownDOMContentLoadedhandler./audio/health/dashboardthrew "Canvas is already in use" on a significant fraction of ordinary page loads (no theme switch involved) -- a race betweentheme.js's ownDOMContentLoadedlistener (registered earlier, since it loads inbase.html) synchronously dispatchingtheme-changedas part of applying the saved theme, and this page'sDOMContentLoadedhandler creating the same charts unconditionally afterward. MadeinitializeCharts()idempotent (destroys any existing instances first) instead of relying on each call site to remember to.- Removed two dead
<script src="https://cdn.socket.io/...">tags on/audio-monitor-- Socket.IO is already loaded globally from the vendored copy inbase.html; these were both redundant and blocked outright by CSP (no externalscript-srcallowance). - Removed a premature
typeof showLoading !== 'function'check on/admin/environmentthat logged a false-positive console error on every load (same "runs before the deferred script" pattern as the bootstrap fix above, just self-correcting rather than crashing). - Added
https://www.debian.orgtoimg-srcfor the detected-OS logo on/system_health(a fixed, single, trusted domain, unlike the broadcast-metadata-sourced album art CSP gap on/audio-monitor, which is deliberately left as-is -- that content can point at literally any station's arbitrary third-party CDN, so it isn't safe to broadly allowlist).
[2.185.6] - 2026-08-22 - Fix modals trapped below the backdrop (unclickable, unscrollable)
Fixed
- Four modals rendered completely unusable -- visible, even appearing
to scroll, but every click landed on the backdrop instead of the
modal's own buttons, with no way to dismiss short of reloading the
page:
#trafficSettingsModal(Security Center's Traffic Collection Settings, reported live after the previous scrollable-modal fix still didn't resolve it),#envEditorModaland#editAlertModal(Admin Panel), and#purgeModal(Audio Archive). Root cause:static/css/styles.css's.page-shell > *:not(.orb):not(.modal)rule gives every direct child of<main class="page-shell">its own stacking context (z-index: 1) so page content can't bleed through the sticky navbar; a.modalis excluded from that rule only when it is itself a direct child of.page-shell. All four were instead nested inside the page's.container-fluidwrapper (in#trafficSettingsModal's case, inside an{% include %}ed partial), inheriting that wrapper's stacking context and rendering trapped below Bootstrap's body-level backdrop (z-index: 1040) -- a pattern this codebase had already hit and fixed once before, ontemplates/admin/certbot.html's#logModal. Moved each modal's markup to be a direct child of{% block content %}, matching that existing fix. Verified live with real Playwright clicks (not just computed-style checks, which is how this shipped unnoticed the first time) confirming the actual close/cancel button -- not the backdrop -- receives the click at its real screen position while the modal is open. - Added
tests/test_modal_stacking.py: a static,{% include %}-resolving check over every page template that flags a.modalnested inside a non-modal, non-.orbwrapper within{% block content %}, with a documented allowlist for the one legitimate exception (#confirmationModal, relocated to<body>at runtime bystatic/js/admin/core.jsbefore it can ever be shown). Catches this exact bug class in CI for any modal added or moved in the future.
[2.185.5] - 2026-08-22 - Theme readability audit: badges, invisible SAME codes, blank icons, off-screen dropdown
Fixed
.status-badge.success/danger/warning/inforendered under WCAG AA (as low as ~2.1:1) on every theme becausecolor:read straight from--success-coloretc. instead of the AA-tuned--*-inksplit.text-successand friends already use. Switched to the-inkvariables and reduced each variant's background tint (20% down to 1-2%, depending on how much headroom that variant's-inkcolour has) until all four variants clear 4.5:1 pixel-sampled across all 20 themes. Added.status-badge.*probes toscripts/diagnostics/check_theme_contrast.pyso this can't silently regress again.- SAME/FIPS code chips on the EAS broadcast workflow page
(
.same-chip code, e.g. "039003") were completely invisible on 7 dark themes (aurora, charcoal, lightning, midnight, nebula, obsidian, slate) -- measured at a 1.00 contrast ratio (text and background the literal same colour). Root cause:.same-chip's own dark-text override (added specifically for this "near-white chip on a dark theme" pattern) setscoloron the chip container hoping to cascade to children, but the globalcode { color: var(--text-color) }rule is a direct declaration on the<code>element itself, and inheritance always loses to an explicit rule on the descendant regardless of the ancestor selector's specificity. Added acode-specific override alongside the existing.text-mutedone in the same block, following the exact same pattern. - Inactive tabs in the admin panel's tab bar (
.nav-tabs .nav-link) read in a hard-to-see muted grey on several dark themes.static/css/admin.csshad its own.nav-tabs .nav-link { color: var(--text-secondary) }rule that -- at identical specificity tostatic/css/styles.css'scolor: var(--text-color)rule for the same selector, added specifically "to ensure readability on all themes" -- won the cascade tie by loading second on admin pages, silently re-introducing the exact problem the other rule was written to fix. Removed the redundant, regressive declaration. - The navbar's Help/Reports/Settings dropdown menus opened off the right
edge of the viewport (confirmed clipped ~140px past a 1400px-wide
viewport), because the shared nav-section dropdown template never got
Bootstrap's
dropdown-menu-endmodifier -- already used correctly for the user-avatar dropdown at the far right, just missing here. Added it. - ~150 icons across 6 pages (Security Center, Certbot/SSL admin, TTS
settings, Audio Health Dashboard, both audio error pages) rendered as
nothing at all --
bi bi-*(Bootstrap Icons) classes, but the project only ever loads Font Awesome (vendor/fontawesome); Bootstrap Icons' font was never included, so everybi-*class drew an empty glyph. Mapped all 37 distinct icon names used to their closest Font Awesome equivalent (e.g.bi-shield-lock->fa-shield-halved,bi-arrow-clockwise->fa-sync-alt) and replaced every occurrence, including ones built dynamically in inline JS template strings.
[2.185.4] - 2026-08-22 - Fix OLED-absent memory leak that repeatedly OOM-killed services
Fixed
eas-station-displays.serviceleaked memory at ~17 MB/s whenever the Argon OLED panel was enabled in settings but not physically present (no ACK from I2C address0x3C), triggering the kernel OOM-killer three times in under an hour (confirmed live:13:02,13:26,13:51, each time after the process's RSS reached ~13 GB) and taking down whatever else the kernel picked alongside it. Root cause:scripts/screen_manager.py's main loop runs at 60 FPS, and several render paths (notably the scroll-animation tick,_update_active_oled_scroll()) fall back tooled_module.oled_controller or initialise_oled_display(logger)with no throttle of their own -- when the controller is absent, that callsinitialise_oled_display()fresh on every single frame. Building the display driver allocates awidth*height-sized pixel offset/bitmask table (luma.oled'sssd1306.__init__, ~100-300 KB for a typical 128x64+ panel) before it ever reaches the I2C handshake that fails, so each doomed attempt costs a real allocation, not a cheap syscall -- at ~60/s this outpaced glibc/pymalloc ever trimming freed memory back to the OS. (_ensure_oled_button_listener()in the same file already throttles its own retries to every 5s for exactly this "loop runs at 60 FPS" hazard -- the scroll/render paths just never got the same treatment.)initialise_oled_display()inapp_core/oled.pynow caches a failed probe for 5 seconds (module-level cooldown) before it will attempt the hardware handshake again, protecting every caller uniformly instead of requiring each call site to remember its own throttle. Verified live: retry frequency dropped from ~60/s to 1/5s and RSS stayed flat (~270-281 MB, stable) over a monitored 30s window after the fix, versus climbing ~52 MB every 3s before it.
[2.185.3] - 2026-08-22 - Fix live waterfall/scope zoom desync and unscrollable modals
Fixed
- The Live Waterfall / Spectrum Scope toggle buttons on Radio Diagnostics
silently reverted to their "off" label ("Live Waterfall") every ~1s while
the feed was still actively running, and any click-drag pan gesture on
the waterfall/scope canvas longer than ~1s was silently cut short. Root
cause: the receiver detail panel is fully re-rendered from scratch on
every WebSocket
radio_status_updatepush (roughly once a second), which re-emitted the toggle button with its static off-label (the server-side markup has no notion of the client-onlyliveWaterfallState) and handed the canvas a brand-new DOM node each time, silently abandoning any in-progress pointer-captured drag.updateReceiverDetails()intemplates/admin/radio_diagnostics.htmlnow moves the real.waterfall-container/.spectrum-scope-containerDOM node (canvas bitmap, zoom/pan listeners and all) out before the re-render and back into the freshly-rendered placeholder afterward, and explicitly re-syncs the toggle labels to the actual running state. Verified live against a running SDR receiver: the toggle label now stays correct across dozens of refresh cycles, and a deliberate multi-second drag-to-pan now completes without the canvas being replaced mid-gesture. - Modals with content taller than the viewport had no internal scrollbar
and simply clipped off-screen, cutting off the Save/Cancel buttons in the
footer (reported via the Security Center's Traffic Collection Settings
modal, but the same gap existed repo-wide: 23 of the site's 35
.modal-dialoginstances were missing Bootstrap'smodal-dialog-scrollablemodifier, which the project's ownstatic/css/styles.cssalready has full, tested support for). Addedmodal-dialog-scrollableto every modal missing it acrosstemplates/base.html,templates/admin.html,templates/security_settings.html,templates/security/security_center.html,templates/security/_traffic_content.html,templates/screens.html,templates/screen_editor.html,templates/audio_history.html,templates/gpio_control.html,templates/docs/rbac_visual.html, andtemplates/admin/{local_authorities,tts_pronunciation,rbac_management,certbot,audio_archives,backups,network}.html. Header and footer now stay pinned in place with only the body scrolling internally, matching Bootstrap's intended behavior for tall modal content.
[2.185.2] - 2026-08-21 - Poller no longer runs a duplicate copy of the web app's background workers
Fixed
eas-station-poller.servicewas accidentally running a second, independent copy of every one of the web app's background workers -- the uptime heartbeat ping, backup/retention/auto-purge schedulers, fail2ban sync, RWT scheduler, GPIO input listener, system metrics sampler, and web-traffic recorder. Root cause:poller/cap_poller.pydoesfrom app import (db, CAPAlert, ...)to reuse the web app's SQLAlchemy models instead of maintaining a second copy -- but that import executesapp.py's entire module body as a side effect (first import wins), which is also where all of those workers get started, regardless of whethercreate_app()is ever called. Confirmed live with apy-spystack dump on the running poller process: a thread literally named"HeartbeatWorker"insidecap_poller.py --continuous, sending a duplicate ping every 5 minutes alongside the two the web app's gunicorn workers already send. Since the poller is a long-running--continuousprocess that asystemctl restart eas-station-webnever touches, this duplicate could also keep executing stale, pre-deploy bytecode for hours after a real fix shipped -- which is what made the 2.185.1 health-check User-Agent fix (see below) initially look incomplete: the two gunicorn workers were correctly fixed immediately, but the poller's duplicate kept sending the unfixedpython-requests/2.32.5default until it was separately restarted.poller/cap_poller.pynow setsSKIP_DB_INIT=1(the same flagalembic/env.pyalready uses for exactly this "import app.py for its models, not its side effects" situation) immediately before importing fromapp, so none ofapp.py'sskip_background_services-gated workers start a second time. The poller's own dedicated schedulers (its polling loop, its gated-alert scheduler) are untouched -- verified via a freshpy-spydump post-fix showing only the poller's own threads, and a live polling cycle completing normally immediately after restart.app.py'sSKIP_DB_INITcomment updated to document that it's a general-purpose "don't start background services" flag for any script importingapp.pyfor its models, not just an Alembic-specific one.
Added
- New
tests/test_cap_poller_skip_db_init.py-- a structural regression test asserting theSKIP_DB_INITsetdefault appears in the source before thefrom app import (...)line that triggersapp.py's execution, so a future edit can't silently reorder them back into the bug (a live-import test isn't used here deliberately: even in the fixed state, importingcap_poller.pyhas real side effects -- DB/Redis connections, thread pool creation -- unsuitable for a fast unit test).
[2.185.1] - 2026-08-21 - Outbound health checks now identify the station
Fixed
- Outbound "health check" requests were going out with the bare
python-requests/x.xdefault User-Agent instead of identifying the station, unlikepoller/cap_poller.py(which NOAA's Weather API terms require to carry a descriptive User-Agent with contact info). Affected: the uptime dead-man's-switch heartbeat ping (app_core/heartbeat_worker.py), the Icecast health probe feeding the dashboard/system-health status strip (app_core/system_health.py::collect_icecast_status()), and both Icecast admin routes (webapp/admin/icecast.py'stest_connection()andget_status()). All four now send the same User-Agent the CAP poller uses, so a third-party monitor's or an Icecast server's request log shows "EAS Station" instead of an unidentified Python client.
Added
- New
app_core/http_defaults.py::get_default_user_agent()-- single source of truth for the DB -> env-var -> hardcoded-default fallback chain that used to be duplicated inline inpoller/cap_poller.py(and had already started drifting: five slightly different User-Agent strings were scattered across the codebase before this).cap_poller.pyitself now calls the shared helper instead of inlining the same logic. - New
tests/test_http_defaults.py(6 tests): the DB/env/hardcoded fallback precedence (including a blank-DB-value edge case), that a DB lookup failure (no app context, DB unreachable) degrades gracefully instead of raising, and that the heartbeat ping actually sends the resolved value as itsUser-Agentheader.
Testing
- Verified against a real local HTTP server: called the actual
send_heartbeat_ping()function inside a real Flask app context and confirmed the request it sent carriedUser-Agent: EAS Station (+https://github.com/KR8MER/eas-station; support@easstation.com)-- this station's real DB-configuredPollerSettings.noaa_user_agentvalue -- rather than the barepython-requests/2.32.5reported before the fix.
[2.185.0] - 2026-08-21 - Broadcast overlay: phase indicator + web abort button
Added
- Broadcast phase indicator on the full-screen countdown overlay. The
overlay now shows which phase a broadcast is in — Sending Header,
Narration, or Sending EOM — instead of just a flat countdown.
app_utils/eas.py::set_broadcast_active()gained two new optional parameters,header_secondsandeom_seconds(elapsed-time thresholds marking the end of the SAME header burst and the start of the EOM burst), which all four broadcast paths (RWT, manual Send, resend, live/forwarded alerts) now compute from the WAV segment durations they already have on hand and pass through. Pre-/post-alert chime duration (where configured) folds into the adjacent phase rather than getting a phase of its own, since chimes play immediately before the header or after the EOM. Both default to0.0("unknown"), so a caller that doesn't supply them shows the old plain countdown rather than a wrong phase — no breaking change for any code that isn't touched by this release. - "Hold to Abort Broadcast" button on the countdown overlay. The
browser-side equivalent of holding a physical GPIO Dump/Abort Broadcast
input for 3 seconds: a press-and-hold gesture on the on-screen button
(fill-bar animation showing progress, releasing early cancels with no
effect) calls the new
POST /api/broadcast/abortroute (webapp/routes/broadcast_control.py, gated on theeas.cancelpermission — Admin and Operator roles have it by default), which calls the exact sameabort_current_broadcast()the GPIO input dispatch loop uses: interrupts the in-progress message but always attempts the required EOM burst (47 CFR 11.61(a)) before releasing the relay, and writes an audit ledger entry with the logged-in operator's username (rather than the genericgpio-inputoperator a physical button records), so a compliance review can see who ended the broadcast from the web UI. - New
tests/test_broadcast_phase_and_web_abort.py(9 tests): phase breakpoints round-trip throughset_broadcast_active()/get_broadcast_state()(including theNone-vs-omitted-kwarg edge case), and the abort route's permission gate, 409-when-idle case, 409-when-no- trackable-PID case (the state marker said "active" but nothing was published yet — a narrow timing window), 500-on-internal-failure case, and that the audit-facing operator identity comes from the session.
Testing
- Verified against real hardware in the lab: triggered a genuine RWT,
confirmed
header_seconds/eom_secondsappear correctly in/api/broadcast/stateduring playback (SAME burst ≈7.2s, EOM burst ≈3.2s for a 14.4s RWT, matching its no-narration FCC-mandated shape), then called the realPOST /api/broadcast/abortroute (via Flask's test client with a genuine authenticated admin session, not a direct function call) against the live broadcast and confirmed: the message was cut, the EOM burst audibly played from a secondaplayinvocation, the relay released only after that finished, and the audit ledger recordedusername: 'admin'witheom_sent: true.
[2.184.1] - 2026-08-21 - Dump / Abort Broadcast: real-hardware fixes
Real-hardware verification of 2.184.0's Dump/Abort Broadcast action (triggering a genuine RWT and aborting it against real GPIO/audio hardware, per the project's "test on real hardware before merge" standard) surfaced two gaps that mocked unit tests couldn't have caught. Both are fixed here, in the same still-open PR, before merge.
Fixed
_run_command()'s PID tracking only ever covered the live/auto-forwarded alert path (app_utils.eas.EASBroadcaster._play_audio()) — RWT broadcasts (app_core/rwt_scheduler.py::_drive_rwt_airchain()), manually "Sent" activations (webapp/eas/workflow.py), and resends (scripts/resend_eas_broadcast.py) each launched their own separate, untrackedsubprocess.run(...). A physical Dump/Abort press against any of those three — RWT is the most routinely-tested broadcast on the station — silently no-op'd:get_broadcast_pid()returnedNoneeven whileaplaywas audibly running. All three now call the sameapp_utils.eas.play_broadcast_audio()entry point_run_command()grew for this, so every broadcast, regardless of trigger, is abortable.- A GPIO-forced abort could end a broadcast without ever sending the
required End-Of-Message burst (47 CFR 11.61(a)) — the original
implementation just killed the player process.
abort_current_broadcast()now reads the isolated EOM tone-burst audio (published to Redis alongside the PID by the sameplay_broadcast_audio()/_run_command()) and plays it synchronously — via the sameaudio_player_cmd— before releasing the transmitter relay or writing the audit entry. The audit ledger'sEAS_CANCELLATIONentry now recordseom_sent: true/falseso an abort that genuinely could not send one (no audio player configured, playback failure) is visible in the compliance trail rather than silently missing. The relay is still always eventually released — a broadcast stuck on-air forever is its own violation — but only after the EOM attempt completes. - The EOM audio Redis key needed base64 encoding.
get_redis_client()returns a client configured withdecode_responses=True(every other key this module publishes is text); raw WAV bytes are not valid UTF-8, so the first hardware test run showed the PID publishing correctly butget_broadcast_eom_audio()silently returningNoneon every read — the UTF-8 decode failure was swallowed by that function's own best-effortexcept Exception: return None._publish_broadcast_pid()now base64-encodes the EOM payload;get_broadcast_eom_audio()decodes it back. - Corrected an inaccurate claim in the 2.184.0 entry below:
_run_command()was not already the chokepoint every broadcast path funneled through — see the first fix above.
Testing
tests/test_gpio_dump_broadcast.pygrew from 11 to 20 tests: EOM publish/clear round-trip through_run_command(),play_broadcast_audio()delegating correctly (including its newtimeoutparam, replacing the three call sites' ownsubprocess.TimeoutExpiredhandling),abort_current_broadcast()always attempting the EOM burst beforeclear_broadcast_active()/the audit write (asserted via ordering checks inside the mocked EOM-play callback), and the fail-safe cases (no EOM audio published, EOM playback failure) still completing and flaggingeom_sent: falserather than hanging the relay forever.- Verified against real hardware in the lab: triggered a genuine RWT
(audible, keyed GPIO pin 17, real
aplayPID published), calledabort_current_broadcast()against it mid-playback, and confirmed: message audio cut bySIGTERM, the isolated EOM burst audibly played from a secondaplayinvocation, the relay released only after that finished, and anaudit_logsrow written witheom_sent: true.
[2.184.0] - 2026-08-21 - GPIO input action: Dump / Abort Broadcast
Added
- New GPIO input action:
Dump / Abort Broadcast— the fourth and final input action, completing the GPIO input framework. A physical button assigned this action forcibly stops whatever is currently on air: sendsSIGTERM(escalating toSIGKILLafter a 2s grace period if needed) to the playback subprocess, releases the transmitter relay via the sameclear_broadcast_active()path a normal broadcast completion already uses, and writes an entry to the tamper-evident Ed25519-signed audit ledger (AuditAction.EAS_CANCELLATION) — an operator-forced abort of a life-safety broadcast is exactly the class of event that ledger exists for. No-ops (logged) when nothing is currently playing. - Safety default: requires a sustained 3-second hold, not a tap
(
gpiozero'swhen_held, admin-configurable 1–10s per pin) — a momentary bump or contact bounce must never be able to abort a live EAS broadcast. This is the one input action that arms differently from the other three, which fire on a plain press. app_utils/eas.py::_run_command()— used by the live/auto-forwarded alert broadcast path — now publishes its playback subprocess's PID to Redis (eas:broadcast_pid) for the duration of the call and clears it on completion (success, launch failure, or exception mid-wait, via afinally). Behaviourally unchanged for every existing caller: still blocks until the command exits, still swallows and logs rather than raises.subprocess.Popen(...).wait()replaces the previous baresubprocess.run(...)— functionally identical, with the PID observable mid-flight by another process. Correction (2.184.1): at the time this entry was written, RWT/manual-Send/resend were believed to already funnel through this same function — real-hardware testing showed they did not, each running its own untrackedsubprocess.run(). See the 2.184.1 entry above for the fix.- New
app_core/audio/gpio_input_actions.py::abort_current_broadcast()— reads the published PID, drives the terminate/kill sequence, and writes the audit entry; never raises (runs inside the GPIO input-event dispatch loop). - New Pin Map UI control: "Hold time to confirm (seconds)", shown only
when Dump / Abort Broadcast is selected, wired through the existing
input_hold_confirm_secondsfield. - New tests in
tests/test_gpio_dump_broadcast.py(11 tests) — the heaviest coverage of the four input-action phases per the original design plan, given this is the one that touches the shared playback call path:_run_command()regression (still blocks, still swallows exceptions, PID published/cleared correctly including on failure), the SIGTERM→SIGKILL escalation, the no-op-when-idle case, the already-exited-process case, the audit entry's fields, and dispatch wiring.tests/test_gpio_input_watcher.py's hold-vs-press test no longer needs to temporarily unlock the action (it's genuinely implemented now), and its "not yet implemented" test was rewritten to validate theGPIO_INPUT_ACTION_IMPLEMENTEDfilter mechanism generically, since all four real actions are implemented as of this release. docs/architecture/THEORY_OF_OPERATION.md's Broadcast Orchestration section documents the abort path, per this project's standing rule that touching broadcast/verification logic keeps that doc in sync.
[2.183.0] - 2026-08-21 - GPIO input action: Acknowledge Dead Air
Added
- New GPIO input action:
Acknowledge Dead Air. A physical button assigned this action silences the dead-air rack buzzer the same way the Acknowledge button on the Audio Health dashboard does -- the tower light stays lit until audio actually returns, matching standard alarm-panel behavior. Acknowledgement is station-wide (scoped to the current alarm episode, not a single source, since dead-air detection is per-source but the buzzer/light output is aggregated), so the input needs no source targeting. - Refactor: the acknowledge logic moved out of the Flask route into a
new shared function,
app_core/audio/dead_air_alarm.py::acknowledge_dead_air()-- extracted so the GPIO input action and the web UI's Acknowledge button call the exact same Redis-backed logic instead of two copies drifting apart, the same pattern already used forRun RWT Now(trigger_rwt_broadcast()).webapp/admin/audio_ingest/routes_dead_air.py'saudio_dead_air_acknowledge()route is now a thin wrapper with no behavior change (same status codes, same Redis keys/TTL). - New
app_core/audio/gpio_input_actions.py::acknowledge_dead_air_alarm()-- the GPIO-side wrapper, logging the outcome; never raises. - New tests in
tests/test_gpio_dead_air_ack.py(8 tests) covering the shared core function directly (active-alarm requirement, episode matching, un-acknowledge, no-Redis case) plus the GPIO wrapper and dispatch wiring.tests/test_dead_air_monitoring.py's existing source-level checks were updated to point at the new location of the extracted logic.
[2.182.0] - 2026-08-21 - GPIO input action: Forward Last Alert
Added
- The
Forward Last AlertGPIO input action is now implemented (was a placeholder enum value since v2.181.0). A physical button assigned this action re-broadcasts whichever EAS message was most recently generated, the same way a message's "Resend" button does: same live-broadcast guard, same detached-subprocess launch (scripts/resend_eas_broadcast.py) so GPIO is never keyed from inside the web process. - New
app_core/audio/gpio_input_actions.py(forward_most_recent_alert()) -- finds the most recentEASMessagewith stored audio via the blob-deferred query pattern already used elsewhere on that model, so the lookup never pulls a multi-megabyte audio blob into memory just to find an ID. No-ops (logged, never raises) when nothing broadcastable exists yet. - New tests in
tests/test_gpio_forward_last_alert.py(4 tests): correct message picked by recency, the broadcast-in-progress skip, the no-broadcastable-message no-op, and the dispatch wiring.
[2.181.0] - 2026-08-21 - GPIO input pins: framework + Run RWT Now
Added
- GPIO pins can now be configured as INPUTs, not just relay outputs
(Admin → GPIO → Pin Map: select Input, then choose an action). The
entire
app_utils/gpiostack was output-only before this; a pin is read viagpiozero.Button(same library and debounce mechanism as the Argon OLED's front-panel button) by a newGPIOInputWatcher, running inside theeas-station-gpiosubprocess. A press publishes an event on a new Redis channel (eas:gpio_input_events); the main web app subscribes and runs the actual action, since it — not the GPIO subprocess — owns things like the RWT scheduler. - First implemented action: Run RWT Now — a physical button wired to
an input pin triggers the same
trigger_rwt_broadcast()call, with the same live-broadcast guard, as the existing manual "Run Test Now" button on the RWT Schedule page. - The action enum ships complete (
Forward Last Alert,Dump / Abort Broadcastare defined values) so later phases don't need anothergpio_pin_mapJSONB shape change, but onlyRun RWT Nowis offered in the UI and does anything today — the others are inert placeholders. - Validation at save time: an input pin cannot also carry an output
behavior, and each action may only be assigned to one pin — both
hard-blocked (
webapp/admin/hardware.py's newvalidate_gpio_input_pin_config()), not just warned about. - No migration required —
direction/input_action/input_bounce_msfields were added to the existinggpio_pin_mapJSONB shape, the same wayflash_partner_pinwas added previously. - New tests in
tests/test_gpio_input_watcher.py(14 tests): button/queue wiring, debounce pass-through, the publish/subscribe event round trip, RWT dispatch (including the live-broadcast skip), config-loader parsing with back-compat defaults, and the save-time validation.
[2.180.0] - 2026-08-21 - Relay interlock groups (mutual exclusion)
Added
- Relay Interlock Groups (Admin → GPIO → Interlocks,
/admin/gpio/interlocks): named groups of 2+ relay pins that must never be energized at the same time — the motivating case is two PTT lines that would key two transmitters into each other if both fired. By default, activating a pin while another group member is already active is refused (and logged as a failure in the GPIO Activation Log); an opt-in per-group "Force-release conflict" setting instead de-energizes the active relay first. Enforcement lives inGPIOController.activate()— the single chokepoint every activation passes through, whether manual or alert-driven — so the guarantee holds regardless of trigger path. - A save-time warning (surfaced on both the Interlocks page and the GPIO Control page's diagnostics) flags interlock groups whose members share a broadcast-holding behavior (Transmitter PTT, Audio Playout, Duration of Alert, Audio Mute): during a real alert only one would ever actually key, and the interlock would silently refuse the other.
- New tables
relay_interlock_groups/relay_interlock_members(migration20260821_relay_interlocks). Like the rest of GPIO configuration, group definitions are read once ateas-station-gpiostartup — changes require restarting the service. - New tests in
tests/test_gpio_relay_interlock.py, including a regression test for the interaction withGPIOBehaviorManager.start_alert()'s "key every configured pin" fallback (a refused pin must not spuriously trigger it).
[2.179.1] - 2026-08-21 - Fix false-positive GPIO Pin Map conflict on BCM 14
Fixed
- The GPIO Pin Map showed a conflict on BCM 14 whenever GPS or Zigbee was
enabled on the Pi's default primary UART, even with the Argon OLED
completely disabled and nothing else configured on the pin. BCM 14 is
statically reserved for the Argon OLED module's wiring in
app_utils/pi_pinout.py-- true regardless of whether the OLED feature is actually enabled -- but the pin map's conflict formula (_build_pin_entry()inwebapp/routes/system_controls.py) treated that static label as a live claimant unconditionally. The equivalentoled_enabledgate already existed on the relay-config validation side (app_utils/gpio/config_loaders.py) but was never carried over to the pin map. The conflict-detection formula now only counts the Argon OLED reservation whenoled_enabledis actually true, via a new standalonepin_reservation_is_active()helper (with regression coverage intests/test_gpio_pin_map_reservations.py). - The conflict banner also under-explained itself: it enumerated the dynamic hardware claims but never mentioned a fixed reservation (like the Argon OLED wiring) even when that reservation was the second claimant causing the conflict, so the message could say "claimed by more than one thing" while naming only one. The banner and the "Reserved & system pins" reference table now both surface the fixed reservation when it's part of the conflict, and the reference table marks an inactive reservation as "not active" so it's clear why a statically-reserved pin isn't blocking anything.
[2.179.0] - 2026-08-20 - Uptime diagnostics pack
Added
- systemd hang watchdog for
eas-station-audioandeas-station-poller(Type=notify+WatchdogSec=). PreviouslyRestart=alwaysonly caught process crashes -- a live-but-deadlocked process (e.g. stuck in a blocking call) kept running forever. Both services now sendWATCHDOG=1heartbeats from their main loops (app_utils/system/sd_notify.py); a hang now gets systemd to kill and restart the unit. - Outbound dead-man's-switch heartbeat: every existing health check was inward-facing (a page
someone has to look at). Settings → Uptime Heartbeat (
/admin/heartbeat/) configures a periodic ping to an external monitor (e.g. healthchecks.io) that fires unconditionally on schedule, so total box/network failure -- not just degraded internal health -- raises an alarm on a channel outside this appliance. - Scheduled backup-restore verification: Admin → Backups now has a "Backup Restore
Verification" panel.
tools/verify_backup_restore.pyrestores a backup's database dump into a throwaway scratch database, runs sanity checks, and always drops the scratch database afterward -- proving a backup actually restores, not just that it was created. Opt-in "Verify after each backup" toggle on the auto-backup schedule, plus a manual "Verify Latest Backup Now" button and history table. - Clock/NTP drift monitoring: the system health snapshot (
/system_health) now has a Clock Sync card, backed by a new consolidated collector (app_utils/system/clocksync.py). SAME timing and RWT scheduling both depend on correct system time; drift beyond 1 second or a desynchronized clock now raises a compliance alert. - Combined NOAA+IPAWS feed-loss alarm: the existing poller-liveness check looked at the single
most recent poll across all sources, so a live NOAA feed masked a dead IPAWS feed. An Alert
Feeds card on
/system_healthnow shows each feed's staleness independently, and an email/SNMP alert fires specifically when both feeds have stalled past the configurable threshold (Alert Poller settings → "Combined Feed-Loss Alert Threshold").
[2.178.0] - 2026-08-20 - Add a System Status strip to the main dashboard
Added
- The main dashboard (
/) now shows a live System Status strip above the map: active alert count, EAS decoder monitor state (running/stopped, audio flowing/silent), overall app health (from/health) and the number of configured radio receivers. Previously the "Emergency Alert Dashboard" only showed the map — an operator had to know to navigate to Monitor → EAS Decoder Monitor or Diagnostics → Health Dashboard to see whether the decoder was actually listening. All four values come from endpoints that already existed and are already public (/api/alerts,/api/eas-monitor/status,/health); no new backend code was needed. Each tile links to the relevant detail page. New files:templates/components/dashboard_status_strip.html,static/js/pages/dashboard_status.js(self-refreshes every 30s, independent of the map's own 5-minute refresh cycle).
[2.177.2] - 2026-08-20 - Give the SDR capture service scheduling priority
Fixed
- The WBKS SDR receiver was logging ~1
SOAPY_SDR_OVERFLOW(buffer overflow) error every 15 minutes, around 96/day. Root cause isn't new --app_core/radio/drivers.pyalready documents that doubling the RTL-SDR read buffer eliminated these errors but broke RBDS decoding far worse (sustained RBDS SYNC LOST), so the buffer size was deliberately kept small and the occasional overflow accepted as the lesser regression. What was never tried:eas-station-sdr.serviceran at default process niceness, identical to gunicorn, ffmpeg, and everything else on the box, despite being the most latency-sensitive process on the system -- it has to service USBreadStream()calls promptly enough to drain the driver's ring buffer before it overflows. - Added
Nice=-5tosystemd/eas-station-sdr.serviceso the CFS scheduler favors the capture loop under load, without touching the buffer/MTU trade-off already tuned for RBDS. No capability elevation required -- systemd applies the nice value before dropping to theeas-stationuser.
[2.177.1] - 2026-08-20 - Pin Map now catches GPS/Zigbee UART pin conflicts
Fixed
- The GPIO Pin Map's conflict detector (added in 2.177.0) only checked the
GPS PPS pin, missing the far more common case: GPS and Zigbee both default
to the Pi's primary hardware UART (
/dev/serial0for GPS,/dev/ttyAMA0for Zigbee inHardwareSettings' own column defaults), which is permanently wired to BCM 14 (TXD0) / BCM 15 (RXD0). On this station specifically, GPS is enabled on/dev/serial0while GPIO 15 also carries a relayplayoutbehavior -- confirmed live withpinctrl(both pins reporting alt-functiona4, the UART) -- meaning every EAS/RWT broadcast very likely interrupted GPS reception (and therefore chrony's GPS time source) for the length of the broadcast. _dynamic_hardware_reservations()now recognizes/dev/serial0,/dev/ttyS0, and the/dev/ttyAMA*family (covers the Pi 3/4 PL011 UART naming and the Pi 5 RP1 UART naming alike) as the primary UART and claims BCM 14/15 for whichever of GPS or Zigbee is configured to use it. The conflict check also now folds in the static Argon OLED reservations, so a dynamically-claimed pin that collides with fixed hardware wiring (not just another dynamic claim or a relay behavior) is flagged too.
[2.177.0] - 2026-08-20 - GPIO Pin Map now shows every pin actually in use
Added
- The GPIO Pin Map only knew about relay behaviors and the fixed Argon OLED wiring -- four real hardware features that claim a GPIO pin through a Hardware Settings field (the dead-air rack buzzer, the NeoPixel indicator strip, the GPS PPS timing input, and the Argon OLED's physical button) were invisible to the page, so their pins showed up as plain "available" GPIO even while genuinely in use. Worse, NeoPixel and GPS PPS both default to BCM 18 with no way for the page to warn about it.
/admin/gpio/pin-mapnow computes these reservations live fromHardwareSettingson every render (not hardcoded), routes claimed pins into the existing "Reserved & system pins" reference table alongside the static Argon reservations, and adds a red conflict banner plus per-pin conflict badges when two features -- or a hardware feature and a relay behavior -- claim the same pin.
Note
- This entry was dropped from the changelog when PR #2429 was merged shortly
after PR #2428 -- both touched
VERSION/CHANGELOG.md, and the merge conflict resolution kept #2428's2.176.2bump but discarded #2429's2.177.0entry even though the GPIO Pin Map code itself merged cleanly. This entry restores the record after the fact; no functional change.
[2.176.2] - 2026-08-20 - Stop the test suite from keying real GPIO hardware
Fixed
- Running
pyteston a box that also hosts the liveeas-station-gpioservice could key a real relay.tests/conftest.pyseeded a defaultDATABASE_URLfor import-time isolation but never did the same for Redis, soapp_core.config.redis_configfell back tolocalhost:6379db0-- identical to production. Tests that exercise the RWT/airchain code path (test_airchain_fringe_cases.py,test_alert_gating.py,test_coverage_and_signature.py,test_ecig_compliance.py,test_forwarded_alert_audio_flow.py) callset_broadcast_active(), which writes theeas:broadcast_activemarker straight into that shared Redis. The always-runningeas-station-gpioservice watches that exact key to key the physical relay, so it dutifully activated real hardware under fake alert identifiers likeTEST-001andTEST-RWT-001-- dozens of times over several days, logged in the GPIO activation audit trail as genuine automatic broadcasts.tests/conftest.pynow defaultsREDIS_HOST,REDIS_PORT, andREDIS_DB(to a dedicated db15) alongside the existingDATABASE_URLdefault, so the test suite can never again share a broadcast-state marker with a live relay-keying service.
[2.176.1] - 2026-08-20 - Fix certbot renewal: stale systemd path and never-clearing lock
Fixed
- Automatic certificate renewal was silently doing nothing. The deployed
certbot.servicestill pointed at a pre-migration config directory (/home/user/eas-station/certbot_data/...) with no certificates in it, so the twice-daily renewal timer ran, always exited successfully, and never touched the real certificate — which then expired. Fixed the path insystemd/certbot.serviceto match wherecertbot_data/actually lives. - Renewal also failed whenever it did target the right certificate,
because the stored renewal config used the
standaloneACME authenticator (needs port 80), but nginx already holds port 80 in production andcertbot renewnever stops it. The live certificate has been re-obtained via thewebrootmethod instead, which nginx already serves via its/.well-known/acme-challenge/location block, switching the stored authenticator for future renewals. - A stale
.certbot.lockleft by a crashed run blocked every later renewal attempt, including manual clicks in the admin UI, with "Another instance of Certbot is already running" — until the app process was restarted._ensure_certbot_directories()only cleared locks at Flask app startup. Addedclear_stale_locks()inwebapp/admin/certbot/paths.py, called before each renew/obtain invocation, guarded by apgrepcheck so a genuinely in-progress run isn't clobbered.
[2.176.0] - 2026-08-20 - Show originating equipment on the live received-alert page
Added
- The received-alert detail page now shows which ENDEC model produced
the SAME transmission.
app_utils/eas_demod.py'sdetect_endec_mode()(a port of EAS-Tools' encoder-fingerprinting method, voting on terminator-byte signatures, a leading-null quirk, and inter-burst gap timing) already computed and stored this for every decoded alert — live and manually uploaded — but the labeled "Originating Equipment" badge only ever rendered on the manual Audio Decoder upload tool. On the actual Received Alerts detail page the same value sat unlabeled inside a raw JSON dump. The badge is now on both pages, sharing one definition (templates/components/endec_badge.html) so the labels can't drift apart the way they would have as two copies. - Documented in
templates/help.html's Received Alerts section.
[2.175.4] - 2026-08-20 - Fix update.sh silently un-stamping alembic_version on every run
Fixed
scripts/database/recover_split_location_settings.pywas rewindingalembic_versionback to20260506_split_location_settingson every single run ofupdate.sh, no matter how far ahead the database actually was. The script exists to heal one specific historical problem: a database where the May 6th20260506_split_location_settingsmigration got half-applied by an olddb.create_all()fallback, leaving the oldlocation_settingscolumns dropped butalembic_versionnever advanced. Its detection was "the old columns are gone, andalembic_versionisn't literally equal to20260506_split_location_settings" -- but that condition is also true of a database that has moved on dozens of migrations past that point, since those columns were dropped once, by that migration, and stay dropped forever after. Every update stamped the version backward regardless, discovered live on this station:alembic currentreported20260506_split_location_settingsimmediately after an update that had just correctly carried the database to20260818_dead_air_per_source, 47 migrations later.- The actual schema was never affected -- confirmed directly against
Postgres that every table/column from the 47 "missing" migrations
(
gated_alerts,admin_users.mfa_last_totp_counter,hardware_settings.dead_air_buzzer_enabled, etc.) was genuinely present. Only the version bookkeeping was wrong, which is still a real hazard: it misleads anyone readingalembic current, and analembic upgrade headrun while mis-stamped would attempt to replay every migration since May. - Fixed by walking the actual Alembic migration graph
(
ScriptDirectory.walk_revisions) to ask "is the current revision this target, or downstream of it" instead of a plain string comparison. Also fixed an adjacent latent bug in the same stamp step: it used a bareUPDATE alembic_version SET version_num = ...with noINSERTfallback, which silently does nothing on a database whosealembic_versiontable exists but has no row yet. - Live database restamped to the correct head (
20260818_dead_air_per_source).
[2.175.3] - 2026-08-19 - Fix RBDS History never having any data, and its unreadable chart labels
Fixed
- RBDS History always showed "No stored RBDS snapshots in this window
yet.", no matter how long a station had been running. The history API
(
/api/audio/sources/<name>/rbds/history) and the audio-health analytics aggregator both read theaudio_source_metricstable on the documented assumption that the audio service persists a snapshot roughly once a second -- but nothing in production ever wrote to that table; only tests instantiatedAudioSourceMetricsdirectly.eas_monitoring_service.py's main loop already publishes live metrics to Redis at 4 Hz for the VU meters/RSSI/RBDS UI; it now also snapshots each running source intoaudio_source_metricsat ~1 Hz, on its own background thread so a slow commit can never stall that 4 Hz loop. Covered bytests/test_audio_metrics_snapshot_writer.py. - The RBDS History chart's time-axis labels rendered at a steep diagonal
and were hard to read, especially on a phone. The default
toLocaleTimeString()label (e.g. "10:09:34 AM") was too wide for Chart.js to keep horizontal at the configured tick count, so it fell back to auto-rotation. Axis labels now drop seconds ("10:09 AM"), the chart requestsmaxRotation: 0so Chart.js skips ticks rather than tilting them, and full-precision timestamps (with seconds) still appear in the hover tooltip title.
[2.175.2] - 2026-08-19 - Fix modals with a form wrapper not scrolling on mobile
Fixed
- The Edit Audio Source modal (and every other scrollable modal that wraps
its header/body/footer in a
<form>, e.g. Add Audio Source, Radio Receiver, Stream Profile) was un-scrollable on mobile, cutting the form off partway through with no way to reach the fields below or the Save button. Bootstrap's scrollable-modal CSS gives.modal-bodya bounded, scrollable height viaflex: 1 1 auto-- but that only works when.modal-bodyis a direct child of the flex container.modal-content. These modals put a<form>in between so one submit handler can collect every field, and a plain<form>is not a flex container, so the flex chain broke silently:.modal-body'soverflow-y: autohad no bounded height to scroll within, and.modal-content's ownoverflow: hiddenjust clipped the tail of the form instead. The Radio Receiver modal had been individually patched around this with a hardcoded inlinemax-height: calc(90vh - 130px)rather than the root cause being fixed..modal-dialog-scrollable .modal-content > formis now itself a flex column filling the bounded height, so header/body/footer behave exactly as if there were no form wrapper at all -- fixing every modal with this pattern at once.
[2.175.1] - 2026-08-18 - Fix waterfall/scope zoom controls going inert after a refresh
Fixed
- The Live Waterfall's and Spectrum Scope's zoom controls looked normal
but did nothing when clicked.
updateReceiverDetails()'s periodic re-render (WebSocket-driven, roughly every 1s) preserved.waterfall-containerand.spectrum-scope-containeracross the refresh by capturing theirinnerHTMLand restoring it afterward -- butinnerHTMLserialization carries neither a<canvas>'s rendered bitmap nor anyaddEventListenerbindings on cloned elements. The restored zoom buttons and canvas gesture handlers were visually identical but completely inert clones. This is the exact same defect class that made the Historical Trends charts "disappear almost immediately" (fixed for trends in #2414 by watching for DOM replacement and rebuilding instead of innerHTML-restoring), just never generalized to the waterfall/scope panels once they grew live zoom/pan gestures. Removed both from the innerHTML-preserve mechanism entirely --.diagnostic-container(plain text/tables, no canvas, no listeners) is the only panel type that actually needs it. The waterfall and scope already detect their own DOM replacement and rebuild themselves with fresh listeners every 500ms vialiveSpectrumTick(), so nothing else had to change.
[2.175.0] - 2026-08-18 - Make dead-air monitoring per-source
Changed
- Dead-air (silence) alarming moved from one station-wide policy to a
per-source setting. Shipped in 2.174.0 as a single enabled flag and
threshold set applied identically to every monitored source, it could not
express "alarm on silence for this continuous broadcast monitor, but
never for that state-relay source that's supposed to be silent except
when relaying an actual alert." Confirmed live on this station: a
state-relay source (
ERN-LUC) sat in a false dead-air alarm for 39 minutes straight because the station-wide policy had no way to exempt it without also disabling alarming for every source that genuinely needed it. - Detection policy (enabled, hold-off, silence level, open-carrier sensitivity) is now configured per source in that source's Add/Edit dialog on the Audio Sources page, stored alongside the source's other settings. The station-wide "Dead Air Monitoring" settings card on that page is now a live status readout only.
- The physical output side (rack buzzer GPIO pin, tower-light colour) is unchanged: one buzzer and one light for the whole station, configured on the Hardware page, same as before.
/api/audio/dead-air/settings(GET/POST) is removed;/api/audio/dead-air/statusand/api/audio/dead-air/acknowledgeare unchanged.- Database: the five station-wide detection columns
(
dead_air_enabled,dead_air_level_threshold_db,dead_air_detect_open_carrier,dead_air_flatness_threshold_pct,dead_air_duration_seconds) are dropped fromhardware_settings; the output columns (dead_air_buzzer_enabled,dead_air_buzzer_gpio_pin,tower_light_silence_*) are untouched. - Fixed 7 failing tests in
tests/test_gpio_alert_indicators.pythat read this station's liveeas:dead_airRedis key instead of a mocked one, so they passed or failed based on whatever this station's real alarm state happened to be at test time.
[2.174.0] - 2026-08-18 - Retire the carrier-squelch feature
Removed
"Carrier Squelch" is gone from receivers. It never did what its name and help text promised.
_apply_squelch()gated on the RMS of the demodulated audio, not on carrier presence, so the gate was inverted against its own purpose. Measured against the real code path at the panel's own defaults (threshold -60 dBFS, open 200 ms, hang 900 ms):| Input | Level | Squelch | |---|---|---| | Off air, open-carrier hiss | -6 dBFS | passed through | | Off air, hiss | -20 dBFS | passed through | | Off air, weak hiss | -40 dBFS | passed through | | Truly dead feed (digital silence) | -240 dBFS | muted |
It muted silence -- a no-op, since muting silence produces silence -- and passed hiss, the only thing worth muting. The panel promised to "automatically mute white noise when the carrier drops", and that is precisely the case it could not handle: an off-air FM receiver emits unsquelched noise tens of dB above any usable threshold. Its only working behaviour was therefore a no-op, so nothing functional is lost by removing it.
"Raise alarm on carrier loss" is gone with it. It wrote a log line and a metadata flag consumed by a single status badge; it drove no GPIO, no tower light and no notification. Dead-air monitoring (added in 2.172.0) supersedes it properly: it detects the open-carrier case via spectral flatness rather than level, debounces it, and drives the tower light and rack alarm buzzer with an operator acknowledgement.
Removed across the stack: the audio-path gate, the five
radio_receiverscolumns (migration20260818_retire_carrier_squelch, with a downgrade that recreates them), theensure_radio_squelch_columnsstartup backfill, the service-config presets, the API payload validation and serialization, and the Edit Receiver form section with its status badges.
Changed
- The legacy instantaneous
silence_detectedmetric took its thresholds from the squelch columns, which was a coincidence of naming rather than a real relationship. It now usesAudioSourceConfig's own defaults (-60 dBFS / 5 s), which are the same values the old fallbacks used, so behaviour is unchanged. The debounced dead-air alarm was never affected -- it reads its own station-wide policy. - Renumbered the database-initialisation log steps in
app.py, which were already inconsistent before this change (twelve labelled/15and four/16, against sixteen actual steps). They now read[N/15]across the fifteen remaining steps.
[2.173.1] - 2026-08-18 - Code-review fixes for the dead-air relocation
Fixed
- Operator-supplied source names were interpolated into
innerHTML.AudioSource.nameis free text with no markup validation and reaches the browser through Redis, so a source named with a tag would have executed in another operator's session on both the Audio Health dashboard and the Audio Ingestion page. Both renderers now build text nodes; verified in Chromium with an<img src=x onerror=...>payload, which renders literally and does not fire. - An active dead-air alarm was invisible to the people meant to watch for
it. The navigation registry gates the Audio Health dashboard on the
view-role trio (
alerts.view/receivers.view/logs.view), but the status endpoint requiredsystem.configure-- so a radio watcher could open the dashboard while every poll returned 403 and the alarm banner silently never appeared. Status is now readable at the dashboard's own level; acknowledging stays restricted, and the response carriescan_acknowledgeso the UI hides that control rather than offering a button that would only fail. - An acknowledgement could mute a later, unrelated outage. The ack was a bare flag with a 24 h TTL and could be written with no alarm active. The publisher now mints an episode id when the alarm goes active and drops it on recovery; the ack stores that id, the API refuses to acknowledge when nothing is wrong or when the operator's page is showing a stale episode, and the GPIO reader only stays silent for the episode that was actually acknowledged.
- A failed settings load could silently rewrite stored thresholds. The
numeric inputs are populated by JavaScript rather than server-rendered,
so an empty form posted
Number('') == 0, which the API clamped to each field's minimum -- turning a 20 s hold-off into 1 s. Saving is now disabled until a load succeeds, with the failure surfaced in a dedicated error line kept separate from the live alarm readout so the two cannot overwrite each other. - Completed the page-exclusivity tests, which checked fewer fields than
they claimed:
dead_air_detect_open_carrierwas not asserted absent from Hardware, and only the buzzer pin was asserted absent from Audio Sources.
[2.173.0] - 2026-08-18 - Move dead-air settings to where the operator looks for them
Changed
Dead-air monitoring is no longer configured entirely on the Hardware page. It shipped there in 2.172.0 because that is where the tower-light settings already were -- placement by implementation adjacency rather than by task. Every threshold it exposes is an audio quantity (dBFS, spectral flatness, a hold-off in seconds), and GPIO is only today's output: an email or SMS notifier added later would have had to read its detection policy out of the GPIO page. The controls now split along the seam that actually matters, detection policy versus output device:
| Control | Page | |---|---| | Enable, hold-off, silence level, open-carrier sensitivity, detect-unmodulated-carrier | Audio Ingestion (
/admin/audio-sources) | | Rack buzzer enable + GPIO pin, tower-light colour / enable / buzzer | Hardware Settings (/admin/hardware) | | Live alarm state + Acknowledge | Audio Health dashboard (/audio/health/dashboard) |All three cross-link to each other. This is the same separation the system already makes elsewhere: the alert pipeline decides what constitutes an alert, and hardware settings only decide what colour the light goes.
Acknowledging moved off the settings form entirely. It is an operational act taken while an alarm is sounding, not configuration, so it now lives on the Audio Health dashboard as a banner that appears only when a source is actually silent -- naming the source, the reason and how long it has been dead. Acknowledging still silences the buzzer while leaving the indication up.
No migration: the settings columns are unchanged, only which page edits them. Detection fields are no longer writable from the hardware form, so the two pages cannot fight over the same values.
Added
webapp/admin/audio_ingest/routes_dead_air.py--GET/POST /api/audio/dead-air/settingsfor the detection policy, plus/api/audio/dead-air/statusand/acknowledge. The equivalents under/admin/hardware/dead-air/*are removed rather than left as duplicates.- Navigation entries for two pages that never had them. Neither
/admin/hardwarenor/admin/audio-sourceswas inwebapp/navigation/registry.py-- "Station Hardware" is a NavGroup label, not a link, so the hardware settings page could only be reached by typing the URL or via a card on the Admin panel. Both are now properNavItems, which is the actual fix for "I could not find this". - Regression tests pinning the split: detection fields on the audio page and absent from Hardware, output wiring the other way round, Acknowledge only on the dashboard, all three pages cross-linking, and both pages present in the navigation registry.
[2.172.1] - 2026-08-18 - Fix undefined logger in the dead-air buzzer pin resolver
Fixed
_dead_air_buzzer_pin()inservices/gpio/__main__.pyreferenced a module-levelloggerthat does not exist -- that module resolves its logger inline vialogging.getLogger(__name__)at each call site. Caught byruff(F821) in CI. Not merely a lint nit: theNameErrorin theexceptclause would have escaped uncaught, so a failure to read the buzzer pin would have taken down GPIO service startup instead of falling back to "no buzzer configured".
[2.172.0] - 2026-08-18 - Dead-air monitoring with tower light and rack alarm buzzer
Added
- Dead-air (silence) monitoring for monitored audio sources, wired to the USB tower light and an optional GPIO rack alarm buzzer. Every other health check in the system watches process liveness -- service up, Redis reachable, SDR locked, buffers healthy -- and all of those can be green while the audio itself is dead. For an EAS monitoring station a silently dead assigned source means monitoring has stopped, and until now nothing said so.
app_core/audio/silence.py-- the detector. Classifies on two independent axes, because a level threshold alone cannot do this job:- level: RMS below a floor (default -65 dBFS) catches true digital silence -- a stopped file, a muted feed, a dead stream.
- flatness: spectral flatness (Wiener entropy) catches an unmodulated carrier. This is the axis that matters for an SDR. When an FM station leaves the air the receiver does not go quiet, it outputs unsquelched noise at full scale -- measured at -6 dBFS in testing, which every level-only detector in this codebase reports as "audio present". Noise spreads energy evenly and scores 0.40-0.57; speech and music concentrate it into harmonics and score below 0.01. The default threshold of 0.25 sits in a two-to-three order of magnitude gap, so it is not a delicate calibration.
- Timing reuses
SilenceDetectorinapp_core/audio/metering.pyrather than adding a fourth parallel implementation. That class already had duration debouncing, recovery-edge detection and an alert callback fan-out, and had been dead code since it was written -- exported but never instantiated outside tests. It now takes an optional explicit verdict so the flatness axis can drive the same state machine. - Tower light: a new
silencestate inresolve_tower_state(), ranked with the existing fault tier -- above every alert indication, because an alert pipeline can look perfectly healthy while the source feeding it is dead. Deliberately not suppressed by quiet hours: an overnight schedule must never hide that monitoring has stopped. Configurable colour, optional tower buzzer, and can be switched off. - Rack alarm buzzer on a configurable GPIO pin, with an operator
acknowledgement. This does not reuse the existing
_key_relay_on_edgespath, which is edge-triggered off the broadcast marker and backed by a 300 s watchdog sized for an alert playout; dead air is a level condition that can persist for hours, so it gets its own pin held for as long as the condition lasts. Acknowledging silences the buzzer but leaves the tower light lit -- standard alarm-panel behaviour, since an acknowledgement means the fault was noticed, not fixed. The audio service clears the acknowledgement when audio returns, so the next outage sounds again instead of starting pre-silenced. - Admin UI at Admin -> Hardware (existing "Station Hardware" page):
enable toggle, hold-off, silence level, open-carrier sensitivity,
buzzer pin, tower-light colour, and a live status readout with an
Acknowledge button. Controls live in a new
static/js/admin/dead-air.jsrather than extendinghardware-settings.js, which is already well past the size guidance. - New endpoints
GET /admin/hardware/dead-air/statusandPOST /admin/hardware/dead-air/acknowledge. - Redis key
eas:dead_air(30 s TTL) carries the aggregate state from the audio service to the GPIO service. A missing key reads as not alarming: absence means the feature is off or the publisher is gone, and neither should strand a rack buzzer on -- audio-service liveness already has its own monitoring.
Fixed
SilenceDetectorreported silence immediately when its first observed chunk was silent, bypassing the duration debounce entirely. On a service restart, where a source simply has not produced its first audio yet, that would have sounded the rack buzzer straight away. Alarm consumers now passassume_prior_signal=Trueso the hold-off always applies.
Notes
- Dead-air thresholds are a station-wide policy, installed once via
set_default_criteria()and re-read every 30 s by the source watchdog, so changes in the admin UI take effect without a service restart. - The pre-existing
AudioMetrics.silence_detectedflag is unchanged. It is an instantaneous per-chunk comparison with no debounce -- it flips on every pause between words -- and continues to feed the analyticssilence_detection_rateonly. It is not what drives the alarm.
[2.171.1] - 2026-08-18 - Code-review fixes for the spectrum zoom
Fixed
- A null frequency axis rendered as
0.00000 MHzinstead of blank.build_spectrum_payload()emitsfreq_min/freq_maxasNonewhen the centre frequency cannot be coerced, which serialises to JSONnull-- andNumber(null)is0, which passesNumber.isFinite. The guard invisibleFreqRange()therefore let it through and labelled both axis edges0.00000 MHzacross a zero-width span. It now rejects explicit nulls and degenerate (zero or inverted) ranges before converting. Older services that omit the keys entirely were never affected --undefinedbecomesNaN, which the finite check already caught. - The mouse wheel swallowed the page scroll at both zoom limits.
preventDefault()ran before the zoom was evaluated, so at 1x every scroll-down and at the 64x cap every scroll-up cancelled the page scroll while doing nothing, leaving the operator unable to scroll past a full-width canvas. The default is now cancelled only when the gesture actually changes the zoom. - The Spectrum Scope's peak-hold trace clipped against the top edge.
The y-axis auto-scale computed its range from the live spectrum only,
but
scaleY()clamps to that range and the peak-hold line retains historical maxima above it. Peaks were pinned flat aty=0instead of the axis scaling to show them. Both traces are now included in the range.
Changed
- Corrected an overstated claim in the 2.171.0 notes and the template's header comment. Zoom recovers the ~125 Hz bin resolution the unzoomed view discards -- 2048 bins over ~900 CSS pixels is ~2.3 bins per pixel (~5.7 on a phone), dropped rather than averaged by the nearest-neighbour upscale -- so the genuine gain is ~2-6x depending on panel width, not the "~16x" originally claimed. Past one bin per pixel, zoom magnifies for legibility without adding information.
- Pan repaints are coalesced to one per animation frame. Pointer events can
outpace the display and each repaint recolours up to
count x 200bins, which matters on the Raspberry Pi hardware this runs on. - The two status lines are built by shared
waterfallStatusText()/scopeStatusText()helpers instead of being assembled separately in the poll tick and the zoom refresh, so the two paths cannot drift apart. - Rebuilt zoom controls seed their readout and reset-button state from live
zoom state. The panels rebuild roughly once a second, and a hardcoded
1xcontradicted the already-zoomed canvas until the next poll. tests/test_spectrum_frequency_axis.pyreads the template with an explicit UTF-8 encoding (the platform default is ASCII under a C/POSIX locale), asserts the tworenderSpectrumAxiscall sites individually rather than by a count that also matched the declaration, and matches the history-buffer invariant with a whitespace-tolerant regex.
[2.171.0] - 2026-08-18 - Zoom and pan on the live waterfall and spectrum scope
Added
- Zoom and pan on the Live Waterfall and Spectrum Scope
(
/admin/radio_diagnostics). The SDR service already publishes a 2048-bin FFT across the whole effective span -- ~125 Hz per bin at 256 kHz -- but both views downsampled that to roughly 900 CSS pixels: ~2.3 bins per pixel (~5.7 on a phone), dropped rather than averaged by the nearest-neighbour upscale, so a one-bin spur could miss the screen entirely. Zoom crops the published bins instead of retuning, so it costs no extra SDR work and no extra requests, and recovers the full ~125 Hz bin resolution the unzoomed view discards -- a genuine ~2-6x, depending on panel width. Past the point where one bin fills one pixel, further zoom magnifies for legibility without adding information; the cap and bin floor bound that so the UI never implies resolution the FFT lacks. Controls:- Zoom in / out / reset buttons in each panel header, with a live magnification readout.
- Mouse wheel to zoom about the cursor, holding whatever is under the pointer still.
- Click-drag (or touch-drag) to pan; double-click resets to full span.
- Capped at 64x with a 32-bin floor, so the UI cannot pretend to resolution the FFT does not have. Going finer needs a narrower capture or a larger FFT server-side. Zoom is shared between the two views -- they render the same payload, so panning one and not the other would only be confusing -- and survives the WebSocket panel re-render that rebuilds the cards roughly once a second.
- The frequency axis and status line now describe the visible window
rather than the full span: edge labels gain decimal places as you zoom
(up to 5 dp on a few-kHz window), the status line reads e.g.
64.0 kHz of 256.0 kHz · 125 Hz/bin, and the centre label drops its "(carrier)" annotation and switches to the window centre once panning takes the tuned frequency off screen.
Changed
- The live waterfall's history buffer now stores raw per-bin power instead of finished RGBA pixels. Cropping a pixel buffer can only stretch what was already rasterised at full-span scale, so zoomed scrollback would have been a blur of upscaled blocks. Keeping the values means every repaint re-renders the entire 200-row history at the current zoom, so scrolling back through history at 16x shows real per-bin detail. The buffer also got smaller (2048 bins x 200 rows = 400 KB, a quarter of the RGBA cost), and colour mapping moved to a precomputed 256-entry lookup table so re-colouring the full history each frame stays cheap.
- The Spectrum Scope's y-axis auto-scale now follows the visible crop rather than the whole span, so zooming in on a weak feature is not defeated by a strong carrier elsewhere keeping ownership of the axis. Its peak-hold trace is indexed by absolute bin, so zooming or panning no longer discards peaks already accumulated off-screen.
[2.170.5] - 2026-08-18 - Fix live spectrum frequency axis labelled with the pre-decimation rate
Fixed
- The Live Waterfall and Spectrum Scope on
/admin/radio_diagnosticslabelled their frequency axis with the receiver's configured sample rate instead of the rate the displayed FFT is actually clocked at, drawing every signal several times wider than it is. Reported from a receiver tuned to an FM broadcast station at 93.900 MHz: the axis read 93.388-94.412 MHz, so a US FM channel -- 200 kHz wide by regulation -- appeared to occupy a full megahertz, which is physically impossible. High-rate SDRs are decimated in the USB callback before samples reach the ring buffer (Python cannot process multi-megahertz IQ in real time), so the samples every spectrum view FFTs are clocked at the post-decimation effective rate. On the reported receiver, 1.024 MHz configured decimates by 4 to 256 kHz, meaning the bins covered 256 kHz of RF while the axis claimed 1.024 MHz -- a 4x overstatement, and up to 10x on an Airspy at 2.5 MHz. The root cause was a missing field:sdr_hardware_service.pypublishedsample_rate(correctly, the effective rate) but neverfreq_min/freq_max, so/api/radio/spectrum/<id>always hit its fallback ofreceiver.frequency_hz ± receiver.sample_rate / 2-- the hardware rate off theRadioReceiverrow. The service now publishes the axis explicitly via a new testablebuild_spectrum_payload(), alongsidehardware_sample_rateandearly_decim_factor; the route resolves the axis through a new_spectrum_axis()helper that prefers the published values and recomputes the effective span locally when an older sdr-service omits them. The same fallback in the command-queue path (which defaulted to 2.5 MHz for Airspy) is fixed identically. - Capture requests asked the SDR service for decimation-factor times more
samples than the requested duration.
/api/radio/diagnostics/capture,/analyzeand/waterfalleach named a localeffective_ratebut assignedreceiver_record.sample_rate-- the pre-decimation rate -- to it. Because the capture tap fires after early decimation, a 5 s request against an Airspy at 2.5 MHz (decim 10) queued 50 s worth of samples while the client waited onlyduration + 20 s, so longer captures timed out by construction. All three now convert duration at the effective rate.
Added
app_core/radio/decimation.py-- the early-decimation threshold, factor and effective-rate math as one dependency-free module (no numpy, SciPy or SoapySDR imports) so the web layer can label a frequency axis with the same numbers the driver decimates by.app_core/radio/drivers.pynow calls it instead of carrying its own copy, which is what let the two drift apart in the first place.- The Live Waterfall and Spectrum Scope status lines now show the RF span
the FFT covers, annotated with the decimation when it is active (e.g.
256.0 kHz span (1.024 MHz ÷ 4)), so an axis that disagrees with the hardware rate is visible on screen rather than silently wrong. tests/test_spectrum_frequency_axis.py-- regression coverage pinning the reported case (1.024 MHz configured must render a 256 kHz span, never 1.024 MHz), the recompute path for an older sdr-service, the service-payload/route round trip, and the capture duration conversion.
[2.170.4] - 2026-08-18 - Fix SDR Diagnostics charts vanishing and running narrower than the page
Fixed
- The per-receiver "Historical Trends" charts on
/admin/radio_diagnosticsdisappeared almost immediately after being shown, and were never as wide as their container. The page's WebSocket-driven auto-refresh (renderReceivers(), firing roughly once a second) replaces the entire receiver-card tree, and apreservedPanelsmechanism cloned each open panel'sinnerHTMLacross that replacement to survive it -- which works for the Run Diagnostic / Waterfall / Spectrum Scope panels, but not for the trends panel's Chart.js line charts: a<canvas>element's drawn pixels are never serialized into its HTML, and Chart.js's inlinestyle="width:...px"(baked in at whatever moment the clone was taken) froze the chart at a stale size instead of tracking the container. Net effect: the chart flashed onto screen and then went blank on the very next refresh tick, and any width it did retain was a snapshot from whenever it was last captured rather than the current layout.templates/admin/radio_diagnostics.htmlnow excludes.trends-containerfrom the clone-and-restore list entirely, andstatic/js/radio_diagnostics_trends.jsadds aMutationObserveron the receivers container (trendsRebuildActive()) that rebuilds and refetches any trends panel left open once the WebSocket refresh clobbers it, so the chart is always constructed fresh against the DOM's current width instead of replayed from a frozen snapshot. - The whole SDR Diagnostics page -- including the Live Waterfall and
Spectrum Scope canvases -- was narrower than it needed to be on any
viewport at or above the 576px Bootstrap breakpoint. The page wrapped
its content in
<div class="container">, which caps at 540px/720px/ 960px/1140px/1320px regardless of the actual viewport width, instead of<div class="container-fluid">, which the comparable GNSS & Time Dashboard (templates/admin/gps_dashboard.html) already uses for the same reason: chart-heavy diagnostics pages should use the full width available rather than being centered in a fixed-width column. Both canvases already sized themselves to 100% of their parent, so this wasn't a canvas bug -- the parent itself was capped.
[2.170.3] - 2026-08-18 - Fix spectrum saturation behind the "still zoomed in" waterfall/scope
Fixed
- The Live Waterfall and Spectrum Scope both still looked "zoomed in"
after 2.170.2's client-side auto-scale fix -- because the underlying
data was actually saturated, not just badly rendered.
sdr_hardware_service.py'scompute_spectrum()converted the raw FFT magnitude to dB without dividing by the window's coherent gain (sum(window)) first, so forFFT_SIZE=2048with a Hanning window, a full-scale tone's peak bin read around +54 dB raw -- far above the fixedSPECTRUM_DB_MIN=-80/SPECTRUM_DB_MAX=0window the normalization assumed. Confirmed against a live capture: 1781 of 2048 bins (87%) clipped to the normalized maximum of 1.0, flattening both views into a saturated "brick" with no legible spectral shape. A single fixed dB window can't hold across every receiver/gain/antenna combination anyway, so the fix replaces it with per-capture normalization: stretch this capture's own dB min/max across 0-1, floored by a newMIN_SPECTRUM_DB_SPANconstant so a near-silent capture (no antenna, receiver just started) isn't auto-amplified into apparent noise. Addedtests/test_sdr_spectrum_calibration.py(7 tests).
[2.170.2] - 2026-08-18 - Fix Spectrum Scope clipping and Historical Trends chart growth
Fixed
- Spectrum Scope drew directly against a fixed 0-1 range instead of
auto-scaling to the current payload's actual min/max, unlike the
sparkline strip on the same page which already did this correctly.
Reported live: the trace looked "too zoomed in," with FFT edge-bin
DC-offset spikes pegging the top of the canvas with no headroom while
the real passband signal collapsed into a thin band near the bottom.
scopeRender()now computeslo/hifrom the current spectrum array (with an 8% margin) each tick, the same auto-scale approachdrawSparkline()already uses, and the scope's status line now shows the current auto-scaled y-axis range so operators can see it's adaptive rather than a fixed scale. - Historical Trends charts grew without bound ("kept scrolling").
Chart.js's
responsive: true+maintainAspectRatio: false(used for all four trend charts) requires the canvas's parent element to have an explicit, bounded CSS height -- the canvas's ownheight="120"HTML attribute only sets its intrinsic pixel resolution and is ignored once responsive mode takes over. The charts sat directly in unconstrained.col-md-6grid cells, so each one could resize upward indefinitely. Wrapped each chart canvas in aposition:relative; height:220px;div, the pattern Chart.js's own docs require for this configuration.
[2.170.1] - 2026-08-18 - Fix a silently dropped IPAWS shelter-in-place alert
Fixed
CAPPoller._convert_cap_alert()never extracted<eventCode>from IPAWS-sourced CAP XML. Reproduced against two live alerts from the same sender:- A county "Natural gas leak" shelter-in-place warning (severity
Extreme, urgency Immediate, instruction "Shelter in place.") carried
a correct
<eventCode><valueName>SAME</valueName><value>SPW</value></eventCode>block in its raw CAP XML, butproperties['eventCode']was never populated, so the event code was unresolvable and the alert was silently rejected by the auto-forward allowlist ("Event 'UNKNOWN' is not in the configured forwarding allowlist") even though SPW itself was already on the allowlist. It was never broadcast. - Three days earlier, an alert whose
eventfield read exactly like a routine "Severe Thunderstorm Warning" (matching headline, radar- indicated hail/wind description, "prepare to seek shelter" instruction) but whose eventCode explicitly said SAME=SPW was actually broadcast asZCZC-CIV-SVR-039137+0130-2212336-KR8MER-(event code SVR) -- a shelter-in-place alert masquerading as a severe thunderstorm warning, because the missing eventCode meant name-based resolution took over.
The IPAWS XML parser built
<parameter>, geocode, and<resource>extraction from the CAP<info>block, but had no equivalent for<eventCode>at all. AddedCAPPoller._extract_cap_event_codes()(mirrors the existing_extract_cap_parameters()pattern) and wired it intoproperties['eventCode'].- A county "Natural gas leak" shelter-in-place warning (severity
Extreme, urgency Immediate, instruction "Shelter in place.") carried
a correct
app_utils.eas._collect_event_code_candidates()silently dropped a dict-shapedeventCode({"SAME": ["SVR"], "NationalWeatherService": ["SVW"]}-- the shape both NWS's CAP-JSON feed and the now-fixed IPAWS XML parser produce). It only handled flat strings/lists, so resolution always fell back to matching the alert's plain-Englisheventname instead of the source-supplied SAME code. Caught while investigating the alert above: NWS "Severe Thunderstorm Warning" VTEC continuation statements (eventCode.SAME=SVS, meaning "statement/update, not a fresh warning") were resolving to SVR by name instead of SVS by code. A separate, correct VTEC-action check already suppressed those from broadcast, so this hadn't caused a visible problem, but it's a real defect in the resolver itself.app_core.audio.auto_forward._resolve_event_code()was an independent, weaker duplicate of the same resolution logic, checking neither theeventCodeblock nor the event-code registry's aliases -- only an exact match against the CAPeventname. This is the function that actually gated the forwarding-allowlist check for the dropped alert above; fixing the parser and the dict-handling bug wouldn't have been enough without also fixing this. Replaced its body to delegate to the shared resolver so the SAME-header-generation path and the allowlist-check path can never resolve an alert differently again.Added
tests/test_ipaws_event_code_extraction.py(19 tests) covering all three fixes, including a replay of both real-world alerts.
[2.170.0] - 2026-08-18 - Strengthen SDR Diagnostics: spectrum scope, trend charts, full checklist
Fixed
- The Live Waterfall's fast path was silently broken.
sdr_hardware_service.pycomputed and published a spectrum FFT to Redis every 100ms per receiver, but under its own ad-hoc key (sdr:spectrum:{id}) that never matched whatwebapp/radio_settings/routes_signal.pyread (eas:spectrum:{id}, the keyRedisChannels.SPECTRUM_PREFIXactually defines). Every spectrum poll — including every waterfall tick — missed the Redis cache and fell through to a slow ~5s command-queue round trip instead. Both sides now importRedisChannels.SPECTRUM_PREFIXfromapp_core/config/redis_config.pyas the single source of truth.
Added
- Spectrum Scope, a classic frequency-vs-power line trace with a
peak-hold overlay, next to the existing Live Waterfall on
/admin/radio/diagnostics. Shares the waterfall's 500ms poll and Redis spectrum feed rather than opening a second one — running both at once for the same receiver costs no extra requests. - Historical Trends: a new 2-tier Redis archive (
app_core/radio/trends.py, modeled onservices/gps/trends.py's bucket/rollup pattern — raw 10s samples capped at 1h, 5-minute rollups capped at 7d) tracks signal strength, lock percentage, sample-rate health, and ring-buffer overflow/underflow per receiver, sampled fromsdr_hardware_service.py's existing publisher loop on its own 10s throttle. Served byGET /api/radio/diagnostics/trends/<id>?window=1h|6h|24h|7d(webapp/radio_settings/routes_trends.py) and rendered as four Chart.js line charts behind a new "Historical Trends" toggle, plus an always-on compact sparkline strip (signal strength, buffer drops) on every receiver card. - "Run Full Diagnostics" button: a pass/warn/fail checklist
(
app_core/radio/diagnostics_report.py) covering the SDR service heartbeat, per-receiver running/locked/sample-flow status, ring-buffer drops, and spectrum-cache freshness, read from the live Redis state the separatedeas-station-sdr.serviceprocess publishes.scripts/diagnostics/check_sdr_status.pyis now a thin CLI wrapper around the same shared function (previously it read a local, almost-always-empty in-processRadioManagerleft over from the pre-separated-architecture design, so most of its checks had gone dead).
[2.169.2] - 2026-08-18 - Fix the EAS decoder stream and VU meters not following live audio
Fixed
/api/eas/decoder-stream("Listen to EAS Decoder Feed") produced zero audio bytes. Reproduced live: a 15+ second connection to the endpoint received no data at all, despite the server correctly subscribing to every running source and starting its ffmpeg encoder.eas_monitoring_service.pyruns a continuous, numpy-heavy, CPU-bound monitor loop (SAME/FSK decode across every audio source) that holds Python's GIL for long stretches under Python's default 5ms switch interval — long enough to starve the decoder stream's I/O-bound ffmpeg-feeder thread of the scheduling it needs to keep the encoder fed in real time. Observed live: this process's CPU spiked well past 100% the moment a stream request came in. Addedsys.setswitchinterval(0.001)near the top of the service so the interpreter hands off the GIL more often.- VU meters didn't follow the actual playing audio. Three separate
code paths in
templates/audio_monitoring.htmlwere all writing to the samepeak-meter-*/rms-meter-*bar andpeak-label-*/rms-label-*text elements: the current 60Hzrealtime-vu-meters.jssystem (a Web Audio API tap on the actual<audio>element — the only one that reflects what's really audible), plus two older systems (updateLevelMetersFromSnapshot(), fed by a WebSocket push as often as every 250ms, andupdateMetricsOnly(), fired every 30s) that wrote server-reported metrics computed from the same decode pipeline affected by the GIL-starvation bug above. The faster, more frequent WebSocket path in particular kept stomping the accurate live values with a slower/staler snapshot. Trimmed both older functions down to only the work that isn't already owned by the client-side tap — the PEAK/RMS cards and the RF RSSI meter, neither of which the Web Audio API can derive on its own — and removed the now-dead EMA-smoothing state and constants that existed solely to feed the redundant bar/label writes. - Investigated general per-source live audio playback (item reported
alongside the above): backend tested healthy on live verification
(Icecast mounts serve real audio both directly and through nginx's
existing proxy rule) — a prior nginx routing fix for exactly this
symptom (
/etc/nginx/sites-available/eas-station's per-source.mp3mount proxy block) is already deployed and working. No further backend changes made for this one; if still reproducible after this update, it needs browser-side detail (which source, console error) to pin down further.
[2.169.1] - 2026-08-18 - Fix three efficiency problems found in a performance audit
Fixed
- SDR service polled Redis 10x/second forever for a queue that's empty
99.9%+ of the time.
sdr_hardware_service.py's main loop calledprocess_commands()(a non-blockingLPOPonsdr:commands) every 100ms regardless of whether any command was pending — SDR commands (device discovery, receiver start/stop) are rare, human-triggered admin actions, so this spent on the order of 864,000 wasted Redis round-trips per day. Switched to a blockingBLPOPwith a short timeout so the process sleeps until a command actually arrives instead of polling. - Audio service's metrics loop silently ran at half its documented
rate.
eas_monitoring_service.py's main loop is commented "publish metrics at 4 Hz" (metrics_interval = 0.25) but only woke up every 0.5s, so the 0.25s threshold could never be checked more often than every 0.5s — the loop actually ran at 2Hz, halving how fresh VU meters/RSSI/RBDS updates reaching the UI were. The 0.5s sleep was sized for a "check for commands every 500ms" step that no longer exists in this loop (removed in 2.168.3). Shortened the sleep to 0.1s so the 4Hz target is actually achievable. - Two admin routes recalculated intersections with a per-boundary query
loop instead of the already-existing batched query.
/admin/ calculate_all_intersectionsre-fetched the entire boundaries table on every alert iteration and ran a separate PostGIS query per alert×boundary pair — with N alerts and M boundaries, N×M individual round-trips instead of N./admin/calculate_intersections/<id>and/admin/ calculate_single_alert/<id>had the same one-query-per-boundary pattern for a single alert. All three now callcalculate_alert_intersections()(app_core/alerts.py), the single-batched-query helper the other three routes in the same file already used — this was a duplicate, unmodernized implementation left behind, not a new algorithm.
[2.169.0] - 2026-08-17 - Fix broken VTEC chain links, add a lifecycle view to the alert trail
Fixed
- VTEC chain links silently dropped for alerts with an unparsed
vtec_action.poller/cap_poller.py::_insert_new_alert()only called_mark_vtec_chain_superseded()whennew_alert.vtec_actionwas both truthy and not'NEW'— a follow-on product whose action code failed to parse (falsy) skipped linking entirely, with nothing to ever retry it. The guard now only excludes actual'NEW'issuances. vtec_yearcame back NULL for any product whose VTEC segment carries the all-zeros "ongoing" sentinel (000000T0000Z) on both begin and end times — seen on every KIWX Flood Warning CON/EXT product in the live database.app_utils/vtec.py::extract_vtec_identity()only tried the VTEC times themselves; with neither usable, the chain key (office+phenomenon+significance+etn+year) is incomplete and_mark_vtec_chain_superseded()correctly refuses to link it — by design, but with nothing else to supply the missing year, the link was simply never made. Now falls back to the year of the CAP envelope's ownsenttimestamp when both VTEC times are the sentinel.- Added a self-healing sweep the poller runs every poll cycle —
repair_vtec_chain_gaps()— that finds any alert with complete VTEC identity, nosuperseded_by_id, and a newer sibling in the same event chain, and links it. Covers any future gap regardless of cause. It only ever fills a NULL; an existing link is never touched. Windowed to the last 30 days for cost. - Audit against the live database found these bugs left roughly a third
of
cap_alertsVTEC chains with at least one broken link — alerts that should have been hidden as superseded on/alertswere instead lingering as if still current. A new migration,20260817_repair_vtec_chain_gaps, re-derivesvtec_yearfor the ~41 affected existing rows from their storedraw_jsonusing the fixed parser, then backfills everysuperseded_by_idgap that newly makes linkable (plus any other pre-existing gap) using the same logic as the original20260402_backfill_vtec_superseded_alertsmigration.
Added
- Event Lifecycle panel on the alert trail page
(
/alerts/<identifier>/trail). Previously the trail page only showed the pipeline events for one specific CAP product (one NEW, EXT, CON, or CAN), so following a warning through its updates meant finding each update's row on/alertsone at a time.app_core/alert_trail.pynow queries every product sharing the alert's VTEC identity directly (not by walkingsuperseded_by_id, so it renders correctly even where that column has a gap) and the trail page shows them all as one clickable timeline, with the current state and the entry you're viewing both marked.
[2.168.3] - 2026-08-15 - Follow-up cleanup after the duplicate-decoder retirement
Fixed
- Orphaned Redis publish loop.
eas_monitoring_service.py's_redis_publisher_monitor_loop()existed solely to feed audio to the now-deletedeas_service.py; it had zero subscribers left but kept running a thread every cycle regardless. Removed it along withapp_core/audio/redis_audio_publisher.py, the module it exclusively depended on (confirmed via repo-wide grep — no other caller existed). - No-op command stub.
eas_monitoring_service.py'sprocess_commands()was a leftover no-op from the same "separated architecture" migration (# Do nothing - all SDR hardware access is in sdr-service.py), still called every 250ms in the main loop for no effect. Removed the function and its call site. - Wrong systemd unit name in three diagnostics scripts.
scripts/collect_sdr_diagnostics.sh,scripts/diagnostics/check_sdr_status.py, andscripts/diagnostics/diagnose_audio_chain.pyall referenced a unit namedeas-station-sdr-hardware.service, which has never existed — the real unit iseas-station-sdr.service. Every SDR status/log check these tools printed has been silently returning "unit not found" since they were written. - Stale references to the already-retired
eas-station-hardware.service(superseded byeas-station-hardware.targetin the Phase 4 hardware split, well before this cleanup) in.vscode/tasks.jsonand.vscode/VSCODE_SETUP.md, alongside ahardware_service.pyfile path that no longer exists in either doc. - Added an explanatory comment to
uninstall.sh's legacyeas-station-ipaws-poller.service/eas-station-noaa-poller.servicestop lines (pre-unification poller units), matching the pattern already used for the other legacy-unit cleanup blocks in the same script.
[2.168.2] - 2026-08-15 - Retire the duplicate EAS decoder (eas_service.py)
Fixed
- Two independent processes were decoding the same live EAS/SAME audio.
eas_service.py(eas-station-eas.service) ran its ownEASMonitorsubscribed to the same Redis audio streameas_monitoring_service.py(eas-station-audio.service) already decodes via its "V3 Unified EAS Monitor" (app_core/audio/eas_monitor_v3.py). Git history shows this was leftover from a "3-tier separated architecture" experiment (2025-12-05) that the project's own maintainers reversed three days later (2025-12-08, "BREAKING: Clean service rename - remove confusion, fix architecture" — which merged EAS monitoring back into what becameeas_monitoring_service.py).eas_service.pywas never removed when that reversal happened, and both processes have been running in production ever since, with hacky heartbeat-based coordination logic just to stop them fighting over the sameeas:metricsRedis key.create_fips_filtering_callback()forwards a FIPS-matched alert toward the broadcast pipeline before any storage-level dedup runs; since a SAME header repeats 3x over ~3 seconds and both processes decoded independently with no lock between them, there was a real (if narrow) double-broadcast race window.eas_monitoring_service.pyis now the sole EAS decoder. - Deleted
eas_service.py,systemd/eas-station-eas.service, andapp_core/audio/redis_audio_adapter.py(used only by the retired service); removed the unit fromeas-station.target'sWants=list and fromEAS_SERVICESinapp_core/config/services.py.update.shnow actively stops/disables/removes the leftover unit on existing installs, mirroring the existing legacy-hardware-service retirement pattern. - Corrected several docs that had drifted from reality around this area:
app_core/audio/README_EAS_MONITORS.mddidn't mentioneas_monitor_v3.pyat all;docs/audio/EAS_TEST_SIGNAL_PIPELINE.mddocumented a two-process pipeline that no longer exists;docs/architecture/SDR_SERVICE_ARCHITECTURE.mdhedged with "eas-station-eas.service(or-audio.service)" instead of stating the actual single decoder plainly.
[2.168.1] - 2026-08-15 - Fix a DB session leak in the CAP poller's error handling
Fixed
- A single DB error during broadcast forwarding could wedge the poller's
session, silently stalling all future alert processing.
CAPPollerreuses one long-liveddb_sessionacross its entire process life. Threeexceptblocks inpoller/cap_poller.py— aroundauto_forward_cap_alert(),process_intersections(), andpoll_and_process()'s own top-level catch-all — logged the failure but never calleddb_session.rollback(). A DB error there (a failed commit inside auto-forwarding, a PostGIS error during intersection calculation) left the session's transaction aborted, so every subsequent statement raisedInFailedSqlTransactionuntil a manual service restart, even though the poller's own retry/backoff loop kept running. Addedrollback()to all three, matching the pattern already used elsewhere in the same file; the already-committed alert row can't be lost since it's saved earlier in the same method, before any of these three blocks run.
[2.168.0] - 2026-08-15 - Require a shared-secret token on the hardware subsystem APIs
Added
- Shared-secret auth on the network/zigbee/gps/displays hardware services.
Ports 5101-5104 bound
0.0.0.0with no application-layer auth, so anything on the LAN that could reach them (a firewall misconfiguration, a non-bare-metal install, a future reverse-proxy slip) could rewrite WiFi credentials, the hostname, GPS wiring, or open the Zigbee pairing window with no credentials at all.app_core.config.get_hardware_service_token()derives a shared token fromSECRET_KEY(already required, already loaded by every subsystem process) andservices.common.bootstrap. install_service_auth()requires it viaX-Hardware-Authon every route but/health. The GPIO subsystem has no HTTP command surface beyond/health, so it's unaffected.docs/troubleshooting/FIREWALL_REQUIREMENTS.mddocuments this as a second line of defense, not a replacement for keeping those ports off the LAN.
Fixed
- OLED screen-editor preview used a hardcoded 128x64 panel size.
render_oled_elements_preview()now reads the operator's actual configured panel dimensions fromHardwareSettings, falling back to 128x64 only when no DB/app context is available (matches the client-side/screenscanvas thumbnail fix below). - OLED preview rendering could race under concurrent requests. The
preview builder patched
app_core.oled.i2c/ssd1306— shared module globals — withunittest.mock.patch, which isn't safe when two preview requests overlap inside the same gunicorn/gevent worker; a losing greenlet could leave the real driver reference clobbered by a stale mock for the rest of the process's life. Replaced with a controller built via__new__plus a_NullOLEDDevicestand-in that touches no shared state. /screenslist-page cards showed raw, unresolved template syntax ({status.status},{gps.lat}, etc.) instead of realistic sample values. The page now hydrates each card from the existingGET /api/screens/<id>/previewendpoint (the same resolution path the screen editor's live preview already used) instead of rendering the rawtemplate_datadirectly./screensOLED canvas thumbnails were hardcoded to 128x64 client-side, same as the server-side preview bug above —buildOledElementsPreview()now sizes the canvas from the hardware status bar's live OLED width/height instead of a fixed 128x64.
[2.167.0] - 2026-08-14 - Make alert-dedup windows configurable, add an audio-ingest confidence floor
Added
- Configurable cross-source deduplication windows. The two suppression
windows in
is_duplicate_broadcast()— 15 minutes for the FIPS-set-only match, and 24 hours (1440 minutes, previously a hardcodedHEADER_KEY_DEDUP_WINDOW_MINUTESconstant) for the callsign-independent SAME-header-key match — are nowEASSettings.cross_source_dedup_minutesandEASSettings.header_key_dedup_minutes, editable from Admin → EAS Settings under a new "Deduplication & Audio Detection" card. The old module constants remain as fallback defaults only. - Minimum confidence floor for headerless audio detections.
EASSettings.min_log_confidence_percent(0–100, default 0 = disabled) lets an operator stop low-confidence, no-decoded-header audio detections from being logged on the Received Audio Alerts page at all. The gate only applies when there is no decoded event code — a real decode (e.g. from a nationwide EAS relay network correctly reporting an alert outside this station's coverage area) is always stored regardless of confidence. - New idempotent migration
20260814_eas_dedup_settingsadds the three columns toeas_settings.
Fixed
- Reviewing a user-reported spike of "false decodes" on the Received Audio
Alerts page traced most of the volume to a correctly-configured
nationwide EAS relay stream legitimately decoding out-of-area alerts —
not a bug. The remaining genuine noise (headerless, low-confidence
detections) is now suppressible via
min_log_confidence_percentinstead of requiring a code change.
[2.166.0] - 2026-08-14 - Bring the screen editor UI up to date with the OLED/VFD/LED graphics engines
Added
- LED graphics/Dots mode in the screen editor. A "Message Type" toggle
(Scrolling Text / Graphics) on the LED options panel switches the canvas
to the sign's real 160×16 Picture File resolution and the element
palette to the icon/text/rectangle/hline/vline/bar vocabulary
render_led_elements()actually supports — previously the editor could only ever emit the legacy 4-line{lines, color, mode, speed, font}shape, so a custom LED graphics screen could not be built through the UI at all; only the 3 migration-seeded ones existed. compassandsegmentselement types, and VFD added toicon/gauge/circle's available displays — the editor's element palette had drifted behind the backend all session: VFD'siconandgaugesupport, and both engines'compass, were invisible in the UI even thoughapp_core/oled.pyandscripts/vfd_controller.pyalready rendered them.- Pixel-accurate live preview.
POST /api/screens/previewrenders a draft screen's actualtemplate_data(unsaved edits included) through the same Pillow pipeline that drives real hardware —render_oled_ elements_preview()(new),render_vfd_elements_preview(),render_led_ elements_preview()/render_led_preview()— instead of the editor's own client-side canvas approximation, which the Preview button now only falls back to if the server render is unavailable. - Icon picker now includes
satellite,gps_pin,bolt(added to the shared icon set earlier this session but never added to the editor'sICON_NAMESlist). - The text element's Font Size dropdown is now display-aware: VFD and LED
graphics mode only recognise a literal
"large"value (their 14pt/15pt bold hero size) — offering OLED's 5-size list there let an operator "pick" a size with no effect on the real device.
Fixed
_render_led_elements()(scripts/screen_renderer.py) silently droppeddotted_hlineelements entirely (no matching case) instead of folding them to a plainhlinethe wayrender_vfd_screen()already does — hit as soon as the editor's shared H-Divider element (Dotted checkbox on) became reachable in LED graphics mode.
[2.165.0] - 2026-08-14 - Give the LED sign a graphics engine; seed its default screens for the first time
Added
- LED graphics/Dots mode.
render_led_elements()(scripts/led_sign_controller.py) composes a 160×16 bitmap from text/icon/rectangle/hline/vline/bar elements — reusing the same_ICON_RENDERERSglyph set andmax_width/overflowtruncation the OLED/VFD engines use — andAlpha9120CController.render_frame()pushes it as one M-Protocol Picture File frame viasend_dots_graphic(). The sign's real graphics capability existed at the protocol layer and was only reachable manually from the Dots tab; it's now wired into the screen-template/rotation system the way OLED and VFD's icon/gauge/bar vocabulary already was. - 3 new LED graphics screens —
led_status_graphic(clock, large time, small active-alert count; leads the rotation),led_alert_graphic(large active-alert count in red), andled_system_graphic(large CPU percentage plus MEM/DSK) — each a single hero row, since the 160×16 canvas is half the VFD's 32 rows and a quarter of the OLED's 64. - LED sign Memory Configuration allocation. A file's type
(TEXT/STRING/DOTS) is fixed when it's allocated in the sign's Memory
Configuration table, and nothing in this codebase had ever allocated
one —
send_message()'s text file relied entirely on whatever the sign shipped with from the factory, almost certainly no DOTS file at all. AddedAlpha9120CController.configure_graphics_memory()(confirm-gated, destructive — erases every message stored on the sign) and an Admin-only/api/led/configure_graphics_memoryroute plus a "Sign Memory" panel on the LED control page's Dots tab, so this is a deliberate one-time action against a specific physical sign, never something run automatically. - LED elements preview —
render_led_elements_preview()(services/displays/preview_render.py) renders graphics-mode screens as an authentic glowing amber/red/green dot-matrix PNG at the sign's native 160×16 resolution, reusingrender_led_elements()so the preview can never drift from the real hardware output.
Fixed
- The LED sign's default screens were never actually seeded.
scripts/create_example_screens.pydefines 6 default LED screens and aled_default_rotation, but no Alembic migration had ever inserted them — production had zero rows atdisplay_type='led'. This release seeds them for real, for the first time, the same way20260814_upgrade_vfd_screens.pydid for the VFD. - LED rotation froze solid during an active alert — the same bug
fixed on the VFD earlier today.
led_default_rotation.skip_on_alertmade_check_led_rotation()return before ever reaching the rotation loop whenever a CAP alert was active, freezing the sign on whatever was last drawn; the LED has no OLED-style alert-preemption path, soled_alert_summary(existing scrolling-text screen, unmodified here) had never actually been shown during a real alert. Seeded withskip_on_alert=falsefrom the start.
[2.164.0] - 2026-08-14 - Show active alerts on the VFD; bring it to screen parity with the OLED
Added
vfd_status— a new default VFD screen showing the time, date and active-alert count together; leads the rotation as the at-a-glance home screen.vfd_alert_status— a new default VFD screen showing the active CAP alert's event type and area, mirroringoled_alert_summary's fields (same/api/alertsdata source).vfd_gpio_status,vfd_eas_decoder,vfd_audio_health,vfd_ipaws_poll_watch,vfd_receivers— VFD counterparts of the 5 OLED screens that had no VFD equivalent, bringing the VFD's default screen count to 11, matching the OLED.vfd_eas_decoderuses thegaugeprimitive for its health score — part of the shared graphics vocabulary since the last release, but not used on any VFD screen until now.- Text truncation on the VFD (
max_width/overflow, "trim" or "ellipsis") — ported from the OLED engine's_fit_text_to_width(). The VFD'srender_vfd_elements()had no text clipping at all before this; a long alert event name or county list just drew straight past the 140px edge. - A second, larger VFD font (
"font": "large", 14pt bold, vs. the flat 7px base) — the OLED has a small/medium/large/xlarge size hierarchy that gives its screens real visual weight; the VFD had one size for every row on every screen.vfd_status(the clock),vfd_gpio_status(active relay count),vfd_eas_decoderandvfd_audio_health(health percentages), andvfd_ipaws_poll_watch(new-alert count) now each lead with one large hero value instead of a wall of same-sized text.
Fixed
- VFD rotation froze solid during an active alert. None of the VFD's
default screens showed whether a CAP alert was active — not because no
screen tried, but because
vfd_default_rotation.skip_on_alertmade_check_vfd_rotation()return before ever reaching the rotation loop whenever_has_active_alerts()was true, leaving the panel frozen on whatever was last drawn for the alert's whole duration. The OLED has its own dedicated scroll-text alert-preemption path for this; the VFD had no equivalent.skip_on_alertis nowfalsefor the VFD rotation, so it keeps cycling — including through the newvfd_alert_statusscreen — while gated pending alerts (a separate, still-preempting concept) are unaffected. vfd_system_meters' DSK row clipped 1px off the bottom of the 140×32 canvas (text drawn at y=26 with a 7px font bottoms out at y=33). Retimed the three meter rows to y=9/17/25.
[2.163.0] - 2026-08-14 - Fix OLED element collisions; give the VFD the same graphics engine as the OLED
Fixed
- OLED element collisions and clipping, found by rendering every default
screen at real size (128x64) instead of eyeballing the template JSON:
- The
compassprimitive's cardinal labels sat on top of its own tick marks and needle. Labels now render outside the ring, and the needle is shortened so it can never reach them; the GPS Status screen's compass and text columns were repositioned to fit. AUDIO HEALTHandIPAWS POLLER's header banners overlapped their own right-aligned stat text at small font — the audio one duplicated the Score row already shown below (dropped), the poller's title was shortened (IPAWS POLLER→POLLER).- The GPIO Status header's
"{count} active"overlapped its title; shortened to a bare count, matching every other screen's convention. - The Clock Face's IP address was squeezed into a ~60px column next to
the date and truncated to
"192.168."or worse; moved to a full-width footer row (the analog clock face shrank slightly to make room). - The
satelliteicon (rectangles + ellipse + diagonal line) was illegible below ~16px; redrawn as a bolder body-dot-with-spokes glyph that reads clearly at 9px.
- The
- VFD custom screens were completely non-functional. Both render paths
called
vfd_controller.clear_display()— a method that does not exist onNoritakeVFDController(the real method isclear_screen()) — raisingAttributeErroron the very first command of every render. Underneath that, every text element also calleddraw_text()with its arguments in the wrong order ((text, x, y)instead of the real(x, y, text)signature), which would have silently garbled positioning even once the first bug was fixed. Both are pinned by regression tests that fail against the original code and pass against the fix.
Added
- The VFD display now has the same icon/gauge/compass/bar-chart engine as
the OLED, plus a VFD-only
segmentsprimitive (a classic segmented LED VU-meter look — discrete lit/unlit blocks instead of one continuous fill). The Noritake GU140x32F-7000B already supported full bitmap pushes viadraw_bitmap(), but the template system only ever drove it through discrete GU-7000 primitive commands (text/rectangle/line).scripts. vfd_controller.render_vfd_elements()is a new hardware-independent PIL renderer — the same pure functionNoritakeVFDController.render_frame()uses to build the real bitmap and the web preview uses to render an accurate PNG — reusing the OLED's icon glyph library directly rather than a second copy of the drawing code. Switched the default font from 10px to 7px (matching the panel's native 5x7/7x10 sizes) since 10px only left room for ~2 text rows on the 32px canvas. - The 3 default VFD screens were reflowed onto the new engine, and a 4th — GPS Status, with a heading compass — was added for parity with the OLED's flagship screen: System Meters (bar meters), Audio VU Meter (segmented meters), GPS Status (compass), Network Status (icon+banner). All 4 are seeded if missing rather than only updated if already present — some installs never had them (no VFD hardware attached at setup time).
- README screenshot gallery: pixel-accurate renders of all 11 OLED screens and all 3 VFD screens (authentic blue-green phosphor glow), generated directly from the driver code rather than mocked up.
[2.162.0] - 2026-08-14 - Add a GPS OLED screen and richer display graphics
Added
- New OLED render primitives:
compass(an N/E/S/W heading dial with an optional needle — omit/nullheadingfor a bare dial when there's no fix yet) andbars(a multi-value vertical bar chart, e.g. one bar per visible satellite's SNR — distinct from the existing single-meterbar). Both are implemented inapp_core/oled.py'srender_frame(), mirrored inscripts/screen_renderer.pyforDisplayScreentemplate support (with live-data binding:headingresolves a{var.path}template to a float,values_sourceresolves a dot-path directly to a live array rather than a single scalar), and previewed in the/screensadmin builder's canvas. - Three new OLED icon glyphs:
satellite,gps_pin, andbolt(lightning bolt, for GPIO/relay activity), added to the existing hand-drawn vector icon library alongside antenna/shield/warning/etc.boltcloses a gap where the/screensadmin builder's preview already had a glyph mapped for it but no backend renderer existed. - New GPS status OLED screen ("GPS Status") added to the default OLED
rotation: a heading compass, fix-quality badge (NO FIX / 2D FIX / 3D FIX),
lat/lon/altitude/speed readout, and a per-satellite signal-strength bar
chart. Sourced from a new public-but-local-only
/api/gps_statusendpoint (webapp/admin/api/routes_system.py), which flattens thegps:statusRedis keyGPSManageralready publishes — same tier as/api/system_statusin the deny-by-default gate (app.py'sLOCAL_API_GET_PATHS), since GPS coordinates describe the machine's physical location and must not be internet-public. - Restyled the GPIO Status OLED screen to match every other default
screen's icon+banner+divider design language. It was the one screen
still on the legacy plain-text
linesformat, and its header usedinvert: truewith no filled rectangle behind it — which renders as black-on-black, i.e. invisible.
Fixed
- A migration seeding a new screen into an existing
screen_rotationsrow used a rawsa.text()UPDATEto write the JSONBscreenscolumn from a plain Python list of dicts —psycopg2.errorscan't adapt type 'dict', since atext()bind parameter carries no column type information. Switched to the table's typedupdate()construct, which serialises JSONB correctly. Caught by running the migration against a real clone of the production database rather than relying onpy_compileand the single-head check alone — this exact append-to-existing-rotation code path had never actually been exercised by a passing CI run before.
Added
- Pending Alerts now show on the USB tower/stack light. A non-empty
gated-alerts review queue (
GatedAlertrows withstatus="pending") now drives a newgate_pendingtower-light state — configurable color (default blue, flashing) — ranked below an active/incoming alert (a live alert can never be visually buried) but above quiet hours (an operator action item is never silenced by a dark schedule). New settings:tower_light_gate_pending_enabled/tower_light_gate_pending_colorin Admin → Hardware Settings. - Website navbar stack light mirrors the same state, subscribing to the
existing
pending_alerts_updateWebSocket push (with a polling fallback) so the on-page indicator and the physical tower light never drift apart. - Pending Alerts now show on the OLED, LED sign, and VFD displays. A new
"Pending Review" scene rotates in — showing the queue depth and the
top few headlines/event codes — whenever the queue is non-empty, and
disappears entirely at zero. Ranked below an active/incoming alert
(which always preempts it) but above the normal screen rotation. The web
preview/control pages (
/displays) now also surface the pending count.
Fixed
- Audio service could silently stop reporting itself as healthy for
hours, showing "Audio service metrics are unavailable" / "source failed
to start" on the Live Audio page even though audio was still streaming.
Root cause:
AutoStreamingService._monitor_loop()(app_core/audio/auto_streaming.py) calledremove_source()for a stopped source while still holdingself._lockfrom an outerwithblock;remove_source()re-acquires that same non-reentrantthreading.Lock(), so the monitor thread deadlocked against itself. Every other caller ofget_status()— including the main loop that publishes the Redis heartbeat the web UI's health status is read from — then blocked forever on the same lock. Fixed by collecting the names to remove while holding the lock and callingremove_source()after releasing it, matching the pattern already used a few lines above for the EAS-ingest-stream cleanup step. Regression test:tests/test_auto_streaming_lock.py.
[2.160.0] - 2026-08-13 - Fix silently-broken automatic boundary/intersection calculation
Fixed
- Root cause found for "boundaries aren't calculated automatically, I have to run a command":
every raw-SQL intersection query bound
alert.geomdirectly as a parameter.psycopg2has no adapter registered forgeoalchemy2.elements.WKBElement— the type the ORM returns for any already-persisted geometry column — so this raisedpsycopg2.ProgrammingError: can't adapt type 'WKBElement'on every call, silently caught and logged as a non-fatal error (by design, so a PostGIS hiccup can never block saving or forwarding an alert). This affected:poller/cap_poller.py::process_intersections()— the automatic, ingest-time calculation for every new/updated CAP alert.poller/cap_poller.py::_has_geometry_changed()— silently defeated an optimization (every update was treated as "geometry changed").app_core/alerts.py::calculate_alert_intersections()— used by the Fix Intersections / Recalculate All Intersections admin buttons (webapp/admin/intersections.py).- Confirmed against a real PostGIS database: identical failure with the
pre-fix code, resolved by stringifying the geometry (
str(alert.geom)— its EWKB hex encoding, which PostGIS parses natively as raw SQL text) before binding. The admin routes built on SQLAlchemy's expression language (func.ST_Intersects(alert.geom, ...)inside adb.session.query(...)) were never affected — SQLAlchemy applies theGeometrycolumn's bind processor for those automatically, which is presumably the "command" that was working.
Added
- Boundaries now auto-recalculate on upload. Uploading a GeoJSON or
shapefile boundary (
webapp/admin/boundaries.py) now automatically recalculates intersections for active (unexpired) alerts, instead of requiring a manual "Fix Intersections" click afterward. - Background retry sweep for intersection calculation, mirroring the
existing forwarding-decision catch-up sweep:
poller/cap_poller.py's poll loop now also retries intersection calculation (within a 24-hour window) for any recent alert that has geometry but no intersection rows yet — closing the "a transient failure was silently swallowed and never retried" gap now that the underlying calculation itself is fixed.
[2.159.0] - 2026-08-13 - Statistics page: remove fabricated metrics, add gated-alert stats
Added
- New Gated Alerts section on
/stats: a headline "Alerts Gated" count, plus a Pending / Auto-Released / Approved Early / Cancelled breakdown, backed by a newwebapp/public/stats_sections/gating.pysection querying thegated_alertstable added in 2.158.0.
Removed
- "Days Online" stat card — it was never backed by real data. The number
came from a per-browser
localStoragevalue the user had to type in manually (defaulting to a hardcoded2024-01-01on first visit), not any server-side install or first-alert date. Removed the card, its JS, and the now-orphaned "System Start Date" settings panel that existed only to let someone manually correct the fake number. - Dead
lifecycle_timelineplaceholder — computed on every page load and threaded all the way into the client-sidestatsDataobject, but never read by any chart or table ("a chart that is not wired up yet," per the original comment). Removed from both the Python aggregator and the template's JS payload.
Fixed
- "Reliability" card silently showed a fabricated 99% when there was no poller history to compute a real success rate from, rather than indicating the number wasn't real. Now shows an em dash with "No poll history yet" instead of a made-up figure.
- "Avg Broadcast Latency" showed a literal
N/Acard when there were no EAS broadcasts yet. The card is now hidden entirely in that case, matching how every other data-dependent card/section on the page already behaves. - "System Performance" section header could float over an empty grid —
its guard checked for the presence of the
pollingdict (always non-empty, even with zero poll history) rather than whether either chart inside it actually had anything to show. Guard now matches the inner per-chart conditions. - Fixed the "EAS Received" card using an undefined
yellowstat-card color class (silently falling back to unstyled default rendering).
[2.158.1] - 2026-08-13 - Fix missing CAP-side gated-alerts release scheduler
Fixed
- CAP-sourced gated alerts (2.158.0) could be held and manually approved,
but never auto-released once their hold-off timer expired. The CAP
poller (
poller/cap_poller.py) got theGatedAlert/AlertGatingSettingsmodel mirrors needed to read gate state, but the background sweep that actually releases expired holds was only wired up for the OTA path (app_core/gating_scheduler.py, running in the EAS-monitor service) — the CAP-side scheduler was never implemented.- Added
_CapPollerGatedAlertSchedulertopoller/cap_poller.py, started fromCAPPoller.__init__. Since this process has no Flask app (unlike the OTA scheduler), each sweep opens its own plain SQLAlchemy session from asessionmakerbound to the poller's own engine, and always queries explicitly via that session (session.query(GatedAlert)) rather than the Flask-SQLAlchemyModel.queryshortcut, which requires an app context this process doesn't have. - Releases expired CAP-sourced holds by re-invoking
auto_forward_cap_alert()— the same function used at ingest time — so the broadcast tail is never duplicated. - Added regression tests in
tests/test_alert_gating.pycovering the sweep's release path and its resilience to a single row's release failing without aborting the rest of the sweep.
- Added
[2.158.0] - 2026-08-13 - Gated alerts: hold-off timer with manual operator override
Added
- New optional feature: lower-priority CAP/OTA alerts can now be held for
a configurable hold-off timer before broadcasting, instead of airing
immediately. An operator watching the new Pending Alerts queue can
approve a held alert early or cancel it outright; if nobody acts, it
auto-releases and broadcasts normally once the timer expires. Disabled by
default — enabling it changes nothing about which alerts broadcast,
only when the lower-priority ones do.
- Immediate urgency or Extreme severity alerts always bypass the gate and broadcast immediately, unchanged from today — gating never delays a Tornado Warning or similarly urgent product.
- Applies to both ingest paths: the CAP poller (NOAA/IPAWS feed alerts) and the OTA relay (alerts decoded off the air).
- New
gated_alerts/alert_gating_settingstables and aGatedAlertstate machine (pending → approved | cancelled | released) inserted into the existingauto_forward_cap_alert()/auto_forward_ota_alert()check chains inapp_core/audio/auto_forward.py— release simply re-invokes the same function, so the broadcast tail (dedup, audio generation, GPIO, notifications) is never duplicated. - Background release schedulers run in the poller and EAS-monitor services (never the web app), sweeping for expired holds roughly every 15–30 seconds.
- New admin pages: Settings → Alert Gating (enable/disable, hold-off duration) and Broadcast → Pending Alerts (live countdown, Approve/ Cancel, real-time via WebSocket push with automatic polling fallback).
- New optional GPIO behavior, Gated Alert Pending, for a lamp or buzzer that stays active while the queue is non-empty — useful for an unattended studio. Assignable from the existing GPIO pin map.
- See
docs/guides/GATED_ALERTS.mdfor the full operator guide.
[2.157.1] - 2026-08-13 - Overlapping alerts on the dashboard map no longer blob together
Fixed
- The dashboard map drew every active alert in raw API-return order with
identical styling, so overlapping alerts stacked into an undifferentiated
blob. This is an everyday NWS pattern, not an edge case — e.g. a
county-wide Winter Storm Watch with a small, more urgent Tornado Warning
polygon inside it. With no z-ordering, which alert ended up on top (or
completely hidden under a bigger, less urgent one) was arbitrary, and
every alert additionally got its own white "casing" halo stroke —
several of those overlapping is what actually produced the blob look,
more than the fill colors themselves.
displayAlerts()intemplates/index.htmlnow sorts alerts before drawing: county-wide/broad alerts first (bottom), specific polygon alerts last (top); within each group, ascending by severity, so the single most specific + most severe alert on the map is always the one guaranteed visible on top.- Broad/county-wide alerts are restyled as context rather than a second subject: thinner outline (was actually thicker than specific alerts before this fix — backwards), lighter fill (0.3 → 0.15), and no white casing halo. Specific polygon alerts are unchanged (full casing, normal weight, 0.4 fill) so they clearly read as the primary subject.
- Added
EASMap.severityRank()tostatic/js/core/map_theme.js, exposing the same severity-ordering tablerefreshGlow()already used internally, so the dashboard's sort uses one source of truth instead of a second copy of that table that could drift from it. - Added an optional
casingflag toEASMap.hazardLayer()(defaulttrue, unchanged for the single-alert pages — alert detail, VTEC trail — that already called it) so the dashboard can turn the halo off per-alert without a second code path.
- Verified the sort/styling logic against a realistic four-alert overlap scenario (county-wide Watch + county-wide Advisory + two specific polygons of different severities): confirmed the most specific + most severe alert always sorts last (drawn on top), county-wide alerts always sort first (drawn on the bottom), and each group still orders correctly by severity internally.
[2.157.0] - 2026-08-13 - Installer runs in a real full-screen dialog
Added
install.sh/update.shnow drive an actualwhiptail --gaugefor the whole run — a real bordered full-screen TUI dialog, the same widget Debian-installer and Slackware'ssetupuse, rather than plain text on the bare terminal (the 2.156.0 static screen). A persistent gauge is opened via a FIFO the first timeecho_stepfires (deliberately not fromui_banner— both scripts showwhiptail --yesnoconfirmations between the banner and the first real step, and a gauge already holding the screen would fight those dialogs), fed livepercent + single-line messageupdates for the rest of the run, and closed byshow_celebration/cleanup_on_exitbefore either draws its own dialog.echo_step/echo_info/success/warning/error/progress/header, the spinner, andui_apt_install's per-package percentage all redirect into it automatically when it's active — no caller ininstall.sh/update.shchanged.- The gauge widget only renders one line of message text (confirmed
directly against this whiptail build: sending a 3-line message shows
only the first line) — a real constraint of the widget, not a bug here.
Every caller collapses to one evolving status line as a result: the
global percent comes from
STEP_NUM/TOTAL_STEPSand stays put across sub-progress (apt/pip detail, the spinner) so it only advances at step boundaries, exactly like 2.156.0's fixed-row layout. - Falls back automatically, in order, when whiptail can't drive the show:
2.156.0's fixed-row static layout when whiptail is missing or
/dev/ttyisn't usable, then further to the pre-2.155.0 scrolling behavior underNO_COLORor no real TTY at all. - Two real bugs this testing caught, both the kind that only show up
when actually run, not read:
- A genuine deadlock: opening the FIFO write-only (
exec 9>fifo) blocks until a reader connects, and whiptail's own read (<fifo) blocks symmetrically until a writer connects — with neither side timing out, a fixed open order against a backgrounded whiptail could hang forever. Fixed by opening read-write (exec 9<>fifo) instead, the standard idiom for driving a FIFO from a script without racing the other end — a read-write open never blocks regardless of which side connects first. exec 9<>fifo 2>/dev/null(and the matching close) looked like it only suppressed one command's stderr, butexecwith no command applies every listed redirect permanently to the current shell — that line was silently discarding all subsequent stderr for the rest of the script, not just its own error path. Fixed by scoping the redirect to a brace group ({ exec 9<>fifo; } 2>/dev/null) instead of the bareexec, which reverts after the group instead of persisting. Caught both by tracing actual execution (set -x, isolating each piece down to a minimal reproduction) after a real run hung for the tool's full timeout with no diagnostic output — not by reading the first version of the code, which looked correct.
- A genuine deadlock: opening the FIFO write-only (
- The gauge widget only renders one line of message text (confirmed
directly against this whiptail build: sending a 3-line message shows
only the first line) — a real constraint of the widget, not a bug here.
Every caller collapses to one evolving status line as a result: the
global percent comes from
[2.156.0] - 2026-08-13 - Installer screen is now static, not scrolling
Added
install.sh/update.shnow render on a genuinely static screen instead of a scrolling log — the DOS-installer restyle in 2.155.0 changed the colors and box art but not the interaction model; this changes the model itself. Contained entirely toscripts/lib/ui.sh, as before.- Banner stays fixed at the top. Below it, three rows are established
once and redrawn in place via cursor positioning for the rest of the
run: a global progress bar (
[step/total] [bar] pct%), the current operation (whatecho_step/echo_progressannounce), and the last status message (echo_info/success/warning/errorand the spinner) — only the latest message is visible on screen at a time; full history is unchanged, still in the log file. ui_stream()(wrapspip install,apt-get install,alembic upgrade head) no longer tees the subprocess's raw output live to the screen — that was the single biggest source of scrolling. It now runs the command in the background with a spinner on the status row, still fully captured to the log. Ctrl+C is explicitly forwarded to the child process (kill, wait, thenexit 130) so aborting still kills the real work instead of leaving it orphaned in the background — a background job doesn't inherit terminal process-group signal delivery the way the original foreground pipeline did, so this had to be added explicitly, not just inherited for free.ui_apt_install()'s live per-package percentage now redraws the operation row instead of scrolling a\r-updated line.- Falls back to the previous scrolling append-only behavior whenever the
static layout can't be established: before
ui_bannerhas run, underNO_COLOR, or when there's no real TTY to position a cursor against.
- Banner stays fixed at the top. Below it, three rows are established
once and redrawn in place via cursor positioning for the rest of the
run: a global progress bar (
- Verified with a real terminal emulator (
pyte), not by eyeballing escape codes: fed a captured run (banner → 6 steps with mixed info/success/ warning/error calls → a spinner → a simulated failure → completion) into a simulated 90×30 screen and confirmed the progress bar, operation line, and status line each end at their correct final state with nothing else printed below them — i.e. actually static, not just visually plausible. Separately verifiedui_stream's Ctrl+C handling end-to-end: sent a realSIGINTmid-run and confirmed the backgrounded child was killed (no orphaned process), the script exited 130, and the trap chain tocleanup_on_exitstill fired. - Known pre-existing gap, not introduced by this change: the scrolling
fallback path in
echo_step/echo_info/etc. doesn't check_ui_tty_supports_colorbefore emitting color codes, soNO_COLOR=1isn't fully honored in that fallback. Flagged, not fixed here — separate from what this change was about.
[2.155.0] - 2026-08-13 - Old-school DOS-style installer UI
Added
- Restyled
install.sh/update.sh's terminal chrome to an old-school DOS-installer look — entirely contained toscripts/lib/ui.sh(confirmed nothing outside it references the color internals directly, so neither script needed a single line changed):- Flat 16-color EGA/VGA palette in place of the previous 256-color cyan-blue gradient.
- Banner and completion card are now fully-closed double-line CP437 boxes (
╔═╗║╚═╝) on a blue background — the two moments the script fully owns the screen (right after a clear, and at the very end); everywhere else keeps the terminal's natural background since real subprocess output (apt-get, pip, git, alembic) is interleaved there and can emit its own color resets. - Classic 4-frame ASCII spinner (
| / - \) in place of the Unicode braille spinner. - Single-glyph status gutter (
iinfo,*ok,!warn,Xerror) in place of[INFO]/[ OK ]/[WARN]/[ERR!]bracket tags. █/░block-fill progress bars were already period-correct CP437 and are unchanged, just recolored.
- Two real bugs turned up by actually testing this instead of just reading the diff:
- The progress bar's filled and empty portions were built as one concatenated string, so despite two different color codes in the
printf, the entire bar would have rendered a single color. Split into separatebar_filled/bar_emptyvariables. - The first pass at the fully-closed boxes used
printf '%*s' N '' | tr ' ' '═'to build the horizontal rule —trsilently mangles multi-byte UTF-8 fill characters even in a UTF-8 locale (a known GNUtrlimitation, not a locale misconfiguration; reproduced in isolation). Replaced with the same bash-loop character-concatenation the block/shade progress bar already used safely. Verified by stripping ANSI codes from a real captured run and measuring every box line at exactly 72 characters.
- The progress bar's filled and empty portions were built as one concatenated string, so despite two different color codes in the
- Verified via a real
script-captured pty run (not just visual inspection of the source):bash -non all three files,shellcheckshows zero new warnings beyond what the original file already had, the spinner was tested against a real backgrounded job with correct exit-code passthrough, and theNO_COLOR=1plain-text fallback path (unchanged) still renders correctly.
[2.154.3] - 2026-08-13 - Poller stopped silently dropping VTEC-bearing alerts
Fixed
- A Numba disk-cache race could make the poller silently drop every NWS
product carrying a VTEC string.
poller/cap_poller.pytriesfrom app import ..., CAPAlert, ...first and only falls back to its own standalone (non-Flask)CAPAlertmodel, defined inline in anexceptblock, when that import fails for any reason. That fallback model was last updated beforevtec_office,vtec_phenomenon,vtec_significance,vtec_etn,vtec_year,vtec_action,superseded_by_idandcancelled_atwere added to the canonical model — 8 of 36 columns missing. Two call sites do unconditional attribute access on those columns:existing.vtec_officein_update_existing_alert()andnew_alert.vtec_actionin_insert_new_alert(). On the fallback model neither is a real instrumented column, so the access raised a bareAttributeErrorand"Error saving CAP alert"— for every affected alert, on every poll cycle, for as long as that poller process stayed in fallback mode. Confirmed in production logs: 4 distinct NWS warnings failed to save on 430+ separate poll cycles across two independent poller restarts (vtec_officeAttributeError ×430,vtec_action×1). - Root cause of the import failure:
_dll_drain_bits_numba()inapp_utils/eas_demod.pywas JIT-compiled withcache=True, persisting compiled machine code to a shared.nbi/.nbcfile pair next to the source. Up to half a dozen independent OS processes (2 web workers, poller, EAS/SDR/audio services) import this module and can start within the same second on a service restart or reboot; Numba's on-disk cache isn't safe against that many processes racing to compile and write it on first import. One such race corrupted the cache withcannot cache function '_dll_drain_bits_numba': no locator available for file '.../eas_demod.py', which brokefrom app import ...for the poller process that hit it. Removedcache=Truehere and from the 6 equivalent@jitkernels inapp_core/radio/demod/kernels.py(same documented pattern, same exposure) — each process still JIT-compiles independently at import time (a few tens of ms, once), it's just never persisted to a file multiple processes can race on. Verified bit-exact output unchanged (134 existing decode/demod tests pass) and both modules still report Numba JIT active. - Hardened both
CAPAlertVTEC attribute-access call sites inpoller/cap_poller.pywithgetattr(..., None)as defense in depth, matching the style already used by_apply_cancellation_status()in the same file — a future schema drift degrades gracefully instead of crashing the whole alert save. - Added
tests/test_cap_alert_fallback_model_parity.py: statically parses bothCAPAlertclass definitions and fails the build the moment a column exists on one but not the other, so the next schema change can't reintroduce this gap silently. Removed the now-obsoleteif not hasattr(cp.CAPAlert, "vtec_action")workaround fromtests/test_forwarding_pipeline_guard.py, added when this exact gap was first noticed but never fixed at the source.
[2.154.2] - 2026-08-13 - Alert narration no longer sits ~15 dB below the tones
Fixed
- TTS/embedded/relay narration was mixed into broadcasts at whatever raw
level the source happened to produce, with no leveling against the SAME
header, attention tone and EOM tones — which are always generated at a
fixed, controlled amplitude.
EASAudioGenerator.build_files()(the automatic path used for real NOAA/IPAWS alerts) appendedvoice_samplesstraight into the composite. Measured against production audio: the attention tone sits at -9.1 dB mean/RMS; Azure OpenAI TTS narration measured -24.7 to -24.8 dB across four separate alerts, and IPAWS-embedded audio -21.9 dB — a consistent ~13-16 dB gap, easily described as "wildly different" volume between the tone and the voice message. Every source that can populatevoice_samples(TTS synthesis, IPAWS-embedded audio, OTA relay capture) funnels through the same code path, so a single fix point covers all three. build_manual_components()(the manual/uploaded-narration broadcast path) already normalized narration to the tone's RMS via_normalize_audio_amplitude();build_files()never called it. Added the same call tobuild_files()'s narration handling.- Also hardened
_normalize_audio_amplitude()itself: pure RMS matching against real TTS audio caused ~2.5% of samples to hard-clip, because speech has a much higher peak-to-RMS ratio (crest factor) than the sine-wave tones the function was modeled on. Added a peak-safety cap so the resulting gain never pushes the loudest sample in a clip past full scale; loudness falls a little short of the RMS target only for unusually peaky source audio, in exchange for zero clipping. This also improves the existing manual-narration path, which had the same latent risk. - Added
tests/test_narration_loudness_normalization.py: unit coverage for the peak-safety cap (verifies no clipping on high-crest-factor input, and that low-crest-factor input still reaches the target RMS), plus an integration test that stubs a quiet TTS engine and asserts the tone/ narration RMS gap in the generated composite stays under 6 dB instead of regressing to the ~15-16 dB gap this fix addresses.
[2.154.1] - 2026-08-13 - Static asset URLs stop double-appending the cache-bust param
Fixed
- Every
url_for('static', ...)link double-appended?v=. The@app.url_defaultshook inapp_core/flask/url_defaults.pyalready adds?v={{ static_asset_version }}to every static URL automatically, but 35 template references across 11 files (favicons, the new PWA manifest link,map.css, admin JS bundles, etc.) also manually appended the same suffix as a literal string, producing URLs like/static/manifest.json?v=2.154.0?v=2.154.0. Harmless in practice — Flask's static route ignores the malformed trailing query string and serves the file regardless — but wasteful and confusing in the rendered markup. Lefttemplates/repo_stats/_content.htmlalone: it deliberately stripsurl_for's auto-appended?v=(.split('?')[0]) and re-adds it manually because it builds a dynamic per-language logo pathurl_forcan't express directly, so its manual suffix is the only one and is correct as-is.
[2.154.0] - 2026-08-13 - Installable as a home-screen app
Added
- PWA manifest for "Add to Home Screen."
static/manifest.json(name, standalone display mode, theme/background colors, 192/512/maskable icons rendered from the existingeas-app-icon.svg) plus the matching<link rel="manifest">andapple-mobile-web-app-*meta tags inbase.html. Lets phones running EAS Station launch it full-screen from a home-screen icon instead of a browser tab — relevant for a system meant to be checked in the field during severe weather. - Along the way, fixed
apple-touch-iconpointing at the 2080×325 wordmark banner (meant for the favicon/header, not a home-screen tile) — iOS was squashing it into a square. Replaced with a proper 180×180 render of the square app icon.
[2.153.5] - 2026-08-12 - Alert Verification stops loading audio it never shows
Fixed
- The Alert Verification page loaded every audio blob in the display window just to show delivery status.
collect_alert_delivery_records()queried wholeEASMessageORM rows, which drags along all sixLargeBinaryaudio columns even though the page only readssame_header,created_atandmetadata_payload. Measured on production: 235 messages averaging ~6.4 MB of audio each meant the default 30-day window pulled roughly 180 MB from Postgres on every page load — for bytes that were never rendered. Switched toEASMessage.without_audio(), which defers all six blob columns in SQL. Addedtests/test_alert_verification_queries.py, which inspects the emitted SQL to pin this query shape and catch a regression back toEASMessage.query.
[2.153.4] - 2026-08-12 - Idle-in-transaction sessions get a timeout
Fixed
idle_in_transaction_session_timeoutwas unlimited (0) in production. A live audit found severaleas-stationapp sessions sitting idle in an open transaction for 20+ minutes on dashboard-style count queries. A long-held open transaction pins Postgres's vacuum cleanup horizon, which lines up with autovacuum never having completed a single run on the largest tables despite heavy dead-tuple accumulation. Addedscripts/database/apply_postgres_tuning.sh, an idempotent script that applies a 5-minute timeout viaALTER SYSTEM+ reload (no restart required), wired into bothinstall.sh(fresh installs) andupdate.sh(existing deployments) so the fix persists instead of only living in a one-off manualALTER SYSTEMcall.
[2.153.3] - 2026-08-11 - Page-chrome clocks tell the truth
Fixed
The navbar and footer clocks were hardcoded to Eastern. Every page renders a live clock from
static/js/core/utils.js, and all three readouts passedtimeZone: 'America/New_York'literally. The station timezone is configurable — it is stored inLocationSettings.timezone, edited from the Location settings form, and the setup wizard offers 22 US zones including Anchorage, Honolulu and Guam — so any station outside Eastern read a clock that was hours off.The clocks also pass
timeZoneName: 'short', so the wrong zone was stated outright: a Honolulu station saw a time six hours ahead labelled "EDT" rather than an ambiguous number it might have questioned. Server-rendered timestamps had just been moved onto the configured zone in 2.153.1, which left the clock in the chrome disagreeing with the tables beneath it.base.htmlnow stamps the configured zone onto<body data-timezone>via a newstation_timezone()template global, andutils.jsreads it. An unusable or missing value falls back to the browser's zone rather than a guess —toLocaleStringthrows aRangeErroron a bad IANA name, which would have killed the one-second interval and frozen every clock on the page.System Health showed the wrong "Last Updated" value and a dead footer clock.
system_health.htmlgave its "Last Updated" metricid="current-time", whichbase.htmlalready uses for the footer clock. Both elements land in the same document and{% block content %}renders before the footer, sogetElementByIdreturned the metric.Two one-second timers then wrote to that one node: the global wall clock in
utils.jsand the page's own"10:06:43 (5s ago)"readout. The field alternated between the two meanings depending on which timer fired last, and the footer clock stayed on "Loading..." for as long as the page was open. Nothing errored — duplicate IDs are legal HTML andgetElementByIdjust picks the first match.The metric is now
health-last-updated. This was the only ID collision againstbase.htmlin the template tree.
Added
tests/test_client_clock_rendering.pyfails the build if a template redefines an ID thatbase.htmlowns, or if the chrome clocks hardcode an IANA timezone again.
[2.153.2] - 2026-08-11 - Filter dropdowns no longer clipped by their card
Fixed
Filter pull-downs were sliced off at the card's edge. On the Received Alerts page, opening the Audio Source or Event Type multi-select showed the Include/Exclude toggle and roughly one option — the rest of the menu was invisible and un-clickable, making the filters unusable for any event type past the first.
The menus were not mis-positioned; they were clipped.
.cardinstatic/css/styles.csssetsoverflow: hiddenso the::beforeaccent line, the::afterglow and card media stay inside the rounded corners, and that declaration clips every absolutely-positioned descendant that leaves the card box. A Bootstrap dropdown is exactly that: measured in a browser, the Event Type menu overhung the filter card by 188px, and a hit-test at the bottom of the menu landed on the results card underneath rather than on the menu. Nothing about the markup looked wrong, which is why the fault survived review — only a rendered page shows it.A card now stops clipping while a dropdown inside it is open, and is lifted above the cards that follow it. The lift is load-bearing rather than cosmetic:
backdrop-filtermakes each.cardits own stacking context, and equal-z-indexstacking contexts paint in document order, so the next card down the page would paint straight over the un-clipped menu. The chosenz-index: 3clears sibling cards while staying far below the sticky navbar (1030), modals and toasts.The card's two decorations fall out of this: both exist only because the card crops them. The
::afterglow is sized 200% and offset -50%, so unclipped it bleeds across neighbouring content; the::beforeaccent line is a 3px strip whose ends the card's 24px corner arc trims, and it cannot round itself to match —border-radiuson a 3px-tall box is scaled down to a 3px radius, leaving coloured nubs sticking out past the corner. Both are hover-only flourishes sitting atopacity: 0the rest of the time, so they are simply held at 0 while the card is unclipped.The rule is expressed as
.card:has(.dropdown-menu.show), withstatic/js/core/utils.jsmirroring it via ahas-open-dropdownclass toggled from Bootstrap'sshow.bs.dropdown/hidden.bs.dropdownevents so the fix also holds where:has()is unsupported. Being a global rule, it covers every card-hosted dropdown in the app, not just the Received Alerts filters. Verified in a headless browser at 320, 360, 768 and 1024px wide in both the light and dark themes: the full menu is reachable and no element exceeds the viewport width. Regression coverage intests/test_card_dropdown_clipping.py.
[2.153.1] - 2026-08-11 - Timestamps read in the station's local time
Fixed
Alert times displayed UTC while claiming to be local. On the Received Alerts page an alert that arrived at 6:06 AM Eastern was listed as
2026-08-11 10:06:43— four hours ahead of the operator's wall clock, with nothing on the row to say the number was UTC. The same defect ran through the log viewer, alert detail, search results, GPIO activation history, the display/VFD message tables, security settings and three admin settings pages.Every timestamp is persisted in UTC, and these templates called
.strftime()straight on the column. That is a silent bug:strftimeformats whatever zone the value is already in, so the page renders correct digits for the wrong zone and no error is raised anywhere. The FCC report builders inapp_core/eas_storage.pywere unaffected — they already went throughformat_local_datetime()— which is why the exported logs and the on-screen tables disagreed.app_utils/time.pygainsto_location_time()andformat_local(), wired up as thelocaltimeandto_localJinja filters, and all 24 affected template sites now use them. Naive columns (the settings tables default todatetime.utcnow) are treated as UTC so they convert identically to the timezone-aware ones. Rendered timestamps now carry a%Zsuffix —EDT,EST— so the zone is stated rather than assumed, and the offset follows DST via the configured location timezone instead of a fixed value.Client-rendered tables were already correct: those endpoints serialize timezone-aware columns, so
new Date(...).toLocaleString()converts to the browser's zone. The mismatch was confined to server-rendered markup.
Added
tests/test_local_time_rendering.py— covers the DST and naive-column conversions, and fails the build if.strftime()is called on a stored timestamp in any template again.
[2.153.0] - 2026-08-10 - The in-app maps get the share card's treatment
Added
A shared, theme-aware Leaflet skin —
static/css/map.cssplusstatic/js/core/map_theme.js(window.EASMap). Release 2.152.0 gave the exported share card a toned basemap and a hazard that reads as the subject; the maps inside the app kept rendering raw OpenStreetMap: pastel landcover, orange motorways and dozens of town labels at full saturation, dropped into a themed card and completely indifferent to which of the 20 themes was active. The alert polygon had to compete with the road network to be seen.The skin is the browser-side twin of
app_utils/image_export/map_style.pyand reuses its numbers. The basemap runs the same tone chain in the same load-bearing order —saturate(0.38) contrast(1.18) brightness(0.58)on dark themes, gentler on light ones — then blends toward the theme's own background, which is what makes the map belong to the current theme instead of merely sitting on top of it. A radial vignette fades the frame edges so the map stops looking like a screenshot pasted into the card, and zoom, scale, attribution and popups all read as chrome in the active palette.Alert geometry is drawn the way the share card draws it: a blurred glow behind a white casing stroke behind a severity-coloured core, over a semi-transparent fill, in a dedicated pane that always sits above the context boundaries no matter which finishes loading first. Boundaries move to their own pane underneath as quiet reference lines — still in their category colour, no longer competing.
Overlay colours are resolved from the theme's
--severity-*variables at draw time and re-resolved on thetheme-changedevent, so switching themes restyles a live map without a reload.
Changed
- Every Leaflet map in the app now goes through the skin: the alert
detail coverage map, the dashboard's interactive map, the alert trail map,
the county-boundary browser and the Security Center traffic map. The two
browsing maps opt out of the vignette (
EASMap.create(..., {vignette: false})), where edge darkening would only hide data. base.htmlstampsdata-theme-modein its anti-flash script. It was only set bytheme.jsonDOMContentLoaded, so anything keyed on it — the new basemap tone, the chart palettes — painted its light variant first and snapped to dark a moment later.tests/test_map_theme.pykeeps the hardcoded dark-theme list in sync withtheme.js.- The Leaflet chrome moved out of
styles.css(loaded on every page) intomap.css(loaded only by pages with a map), and the duplicated.popup-title/.popup-detailrules thatindex.htmlandalert_detail.htmleach carried their own copy of were promoted with it — clearing two entries fromtests/css_collisions_allowlist.txt.
Fixed
- Vector layers in a custom Leaflet pane rendered nothing. Leaflet ships
a
max-width: nonereset for exactly this, but scopes it to.leaflet-overlay-pane; the mobile-overflow rulesvg, canvas { max-width: 100% }instyles.cssresolves against the pane, and a Leaflet pane is a 0x0 positioned box, so the overlay's width computed to zero. The same trap applies to CSSfilter, whose region derives from the element's own box — a filter on a pane is silently discarded while devtools still reports it, which is why the tone chain lives on.leaflet-tileand the hazard glow on the pane's<svg>.
[2.152.0] - 2026-08-10 - The share-card map reads like a warning graphic
Changed
The alert now fills the map instead of sitting in it. The renderer picked the integer OSM zoom at which the alert's bounding box fit inside 60% of the map slot, then cropped a fixed
map_w x map_hwindow at that zoom. Two lossy steps stacked: the 60% fit factor, and flooring to an integer zoom, which can nearly halve the apparent size again. Together with 30%-per-side bbox padding, a county-scale flood advisory rendered with its polygon covering barely a tenth of the frame, adrift in unrelated geography.Zoom now decides tile detail only (
_detail_zoom), and the crop frames the padded bbox itself, widened to the slot's aspect ratio and resampled (_crop_window). The subject fills the frame at any zoom. Padding dropped to 16% per side now that it is the entire margin rather than compounding with a fit factor.Because overlays are drawn on the tile canvas before that resample, every overlay dimension is now pre-divided by the resample factor — polygon strokes, the storm cone, arrow and its callout (which also switches to a proportionally larger font) all arrive at their intended size instead of shrinking.
The basemap is toned down so the hazard is what reads. A raw OSM tile is the loudest thing on the card — pastel landcover, orange motorways, dozens of town labels, all at full saturation — and dropped into a dark card it reads as a bright rectangle pasted in from elsewhere. New
map_style.tone_basemap()desaturates, lifts contrast, darkens and tints toward the card's slate before any overlay is drawn, so the alert polygon is the only saturated thing in frame (mean luminance on a typical mosaic: 223 → 93).apply_vignette()fades the edges so the inset blends into the card rather than ending in four hard borders.County names are back on the map, with collision avoidance. Labels were removed once because drawing one per boundary produced overlapping, unreadable text — but a map with no place names cannot answer "where is this?".
map_style.place_labels()places them greedily in priority order (counties inside the alert first), skipping any label that would overlap an already-placed one, land on the scale bar or attribution, or clip the frame, and caps the total so a 40-county watch does not become a wall of text.Counties inside the alert are drawn brighter than the surrounding reference lines, the way NWS warning graphics separate "in the warning" from "here for context".
_fetch_county_outlines()now asks PostGIS which counties intersect the alert geometry and returns anST_PointOnSurfaceanchor for each label (point-on-surface, not centroid, so the anchor cannot fall outside a crescent-shaped or multi-part county).
Fixed
- The storm-motion callout no longer covers the arrow it labels. The pill was centred just past the arrow tip, so its own width laid it back over the arrow; it is now offset far enough that its near edge clears the tip.
Internal
maps.py(904 lines after the changes above) was split: the storm-motion overlay moved tostorm_overlay.py, the PostGIS lookups tomap_data.py, and the new basemap treatment and label placement live inmap_style.py.mapsre-exports every moved name, so no caller changed.- Removed
_best_zoom(), dead once the map started framing its own crop. It is the one name the package__init__no longer re-exports.
[2.151.0] - 2026-08-10 - Share cards keep the outline NWS wrote
Changed
Share-card descriptions render as the outline NWS actually wrote, not a wall of text. Modern NWS products are written to a tagged bullet structure — WHAT is happening, WHERE, WHEN, what the IMPACTS are — but CAP delivers the whole thing as one free-text
description. The renderer flattened it (collapse newlines, strip the leading asterisk), so a flood advisory landed on the card as nine unbroken lines with stray*and-glyphs sitting mid-sentence, and the reader had to parse the structure back out by eye.app_utils/image_export/nws_text.pyrecovers the segments and the DESCRIPTION panel lays them out as a label gutter plus wrapped prose. Both NWS conventions are recognised: the* WHAT...bullet form and the asterisk-freeHAZARD.../SOURCE.../IMPACT...form used by severe thunderstorm and tornado warnings. The untagged lede that opens those warnings — "At 900 PM EDT, a severe thunderstorm was located near Lima, moving east at 40 mph", the most informative sentence in the product — is kept and rendered full width. Text carrying no recognisable outline, including shouted legacy products, still falls back to the paragraph path.Segments the card already shows elsewhere are dropped.
WHEREis the AFFECTED AREAS county list written out as a sentence,WHENrestates the footer's expiry stamp, and thePRECAUTIONARY/PREPAREDNESS ACTIONSblock is the CAPinstructionfield the card renders under its own ACTION banner. Each is suppressed only when the card is genuinely carrying that information, which is what buys the room for the hazard copy.Bare URLs no longer consume a line of copy. NWS descriptions end with links like
http://www.weather.gov/safety/flood; printed on an image they cannot be clicked, wrap badly, and displace hazard text. Stripped from both the description and the instruction.areaDescno longer repeats the state on every entry. NWS sends "Allen, OH; Defiance, OH; Henry, OH; Paulding, OH; Putnam, OH", which spends a wrapped row restating "OH" four more times than needed. Entries are grouped by state in first-appearance order and the code is factored out once per group: "Allen, Defiance, Henry, Paulding, Putnam (OH)". Multi-state lists group separately; zone and marine products that carry no state codes are passed through untouched.
Internal
- The prose section drawers (headline, description, action) moved from
app_utils/image_export/panels.py— already past the module-size guidance — into a new siblingpanels_text.py, next to the parser they depend on.panelsre-exports every name, so existing imports are unchanged. - Added
_lighten()to the image-export palette so accent-coloured text stays legible on the dark card whatever hue a hazard family's theme resolves to.
[2.150.2] - 2026-08-10 - Stop a dead audio service from looking like stopped sources
Fixed
A dead audio service rendered as three deliberately-stopped sources. Live Audio showed grey "Stopped" badges and "Start the source to listen" for every source while the EAS Continuous Monitor panel — reading the same Redis key — correctly reported "Audio service metrics are unavailable". Only one of those two statements was true, and it was not the one attached to the buttons the operator would reach for.
In the separated deployment the web process builds an adapter for every
audio_source_configsrow but deliberately never starts one:_start_audio_sources_background()returns immediately because the audio service owns capture. Those adapters are placeholders that permanently reportSTOPPEDwith no error message.build_source_listing()preferred them over theservice_deadsignal, so_serialize_db_only's "Audio service is not running – source failed to start" branch was unreachable in production — the tests only reached it because they stub the controller empty.GET /api/audio/sourcesnow ignores a non-running local adapter when the audio service is publishing no metrics at all, so anauto_startsource reportserrorwith an actionable message. Integrated deployments are unaffected: there the web process is itself the metrics publisher, soservice_deadis never True and a running adapter stays authoritative.Audio commands reported success when nothing received them. Redis
PUBLISHsucceeds with zero subscribers, so with the audio service down every fire-and-forget command (eas_monitor_start,streaming_start,source_add/source_update) returnedsuccess: Trueand went nowhere. Pressing Start Monitor produced no error, no state change and no explanation.AudioCommandPublisher._publish_command()now checks the receiver count and fails with "The audio service is not running, so the command was not delivered." This also makes the wait-for-response commands (source_start/source_stop) fail immediately instead of burning their full 5-second timeout.POST /api/eas-monitor/controlreturned 500 for a down dependency. An undelivered command is now a 503 carrying a hint pointing at Settings → Services.The Start/Stop Monitor buttons gave no feedback on success.
controlEASMonitor()showed a toast only on failure, which was indistinguishable from the command silently going nowhere. It now confirms acceptance and surfaces the server'shinton failure.
Added
tests/test_audio_command_delivery.py— the receiver-count contract, both directions, for fire-and-forget and wait-for-response commands.- Two cases in
tests/test_audio_source_listing.pycovering the production controller shape (placeholder adapters present) rather than the stubbed-empty controller the existing dead-service test relies on.
[2.150.1] - 2026-08-10 - Fix one-click backup and upgrade
Fixed
One-click backup and one-click upgrade never ran. The worker that
_start_background_operationspawns is a bare daemon thread, and Flask contexts are per-thread, socurrent_appis unavailable there. The very first statement in the worker wascurrent_app.logger.info(...), which raisedRuntimeError: Working outside of application context. Theexcepthandler then calledcurrent_app.logger.exception(...)and raised again, somessagewas never assigned. The net effect: thesubprocess.runwas never reached, and the UI showed a failed operation with an empty message.The caller already resolves
current_app.loggerinside the request context and passes it in as theloggerargument — the worker simply was not using it. All four call sites now use the injected logger, andcurrent_appis no longer imported by that module.This is pre-existing, not a regression from the Phase 3f split: the same four
current_app.loggercalls are in the single-filewebapp/admin/maintenance.py, which is why the split was still 31/31 AST-identical. Found by CodeRabbit's review of #2355 and verified against the pre-split file.
Added
tests/test_maintenance_package.pygains two guards. One runs a real background operation with no application context and asserts the subprocess actually executed — it fails against the pre-fix source, so it pins the behaviour rather than the spelling. The other assertscurrent_appdoes not appear inoperations.pyat all, since that is the one module here doing work off the request thread.
[2.150.0] - 2026-08-08 - Phase 3h: split the alert verification routes
Changed
webapp/routes/alert_verification.py(1,668 lines) is now thewebapp/routes/alert_verification/package — 14 modules plus a 123-line__init__, all within the 400-line guidance. Phase 3 is complete for every file exceptapp.py, which is assessed separately (3g).This was the harder shape: 687 of those lines were a single
register(app, logger)with eight helpers and seven handlers nested inside it, so the split had to reproduce a closure, not just move text.| Module | Lines | Contents | | --- | ---: | --- | |
errors.py| 26 | The self-test error type | |samples.py| 31 | The bundled sample recordings | |routes_export.py| 77 | The CSV export | |temp_audio.py| 77 | Decode an upload, then persist | |helpers.py| 89 | The capture-free helpers fromregister| |decode_serialization.py| 100 | A decode result across the thread boundary | |composite_audio.py| 124 | Stitching segments into one file | |routes_api.py| 141 | Progress, header decode, decode audio | |routes_self_test.py| 170 | The end-to-end self test | |audio_buffer.py| 174 | PCM extraction and caching | |routes_operations.py| 203 | Starting an async run | |routes_page.py| 269 | The verification dashboard | |progress.py| 289 | On-disk progress and result stores | |eas_detection.py| 316 | Locating every EAS burst in a file | |__init__.py| 123 |register, fanning out to the route modules |The closure was reproduced from
symtable, not guessed. Four helpers capture nothing fromregister, so they were dedented to module scope inhelpers.py— semantically identical, and theirast.dumpis unchanged. The other four captureroute_logger,repo_rootorapp, so each topic module keeps its ownregister(app, logger)that rebuilds exactly the locals its handlers need and nests them inside. Three modules needed onlyroute_logger; emittingrepo_rootunconditionally left an unused local in each (F841).
Fixed
Four mutable globals are deliberately not re-exported from the package.
_progress_dir,_progress_lock,_result_dirand_result_lockare replaced withtmp_pathbytests/test_alert_verification_async.py. This was verified rather than assumed: with the names re-exported and the patch aimed at the package, all six tests still pass — while writing to the real temp directory instead oftmp_path. The patch rebinds a copy; the classes keep reading the original. Absent from the package, the same call raisesAttributeErrornaming the module, which is the whole point. The six patch sites across two test files now targetalert_verification.progress,.helpers(get_location_settings) and.routes_self_test(AlertSelfTestHarness).One dead import dropped out:
structwas imported and never used.
Added
tests/test_alert_verification_package.py— 14 tests. The closure-specific ones inspect the handler through__wrapped__, sinceapp.view_functions[...]returns the outermostrequire_authdecorator whose only free variable is the function it wraps.A flaky test that made every "suite green" claim unreliable.
tests/test_8khz_stress_test.pygenerated its noise with the global unseededrandom, then asserted a confidence threshold — so a decoder regression and an unlucky draw were indistinguishable. Measured at 2 failures in 12 runs oftest_8khz_with_increasing_noise[0.2]. It now uses a seededrandom.Random, and 15 consecutive runs pass.Seeding it surfaced something worth a look. Sweeping ten seeds across five noise levels, 6 of 50 combinations fail at 20-25% noise — and they fail with
len(result.headers) == 0whilebit_confidencestays around 0.92. That is not gradual degradation: bit recovery still looks healthy while header framing drops out entirely. Whether the decoder should tolerate that noise is a signal-processing decision, so it is documented in the helper's docstring with a one-line reproducer rather than hidden by the seed.
Notes
- An import cycle was created and caught during the split. The call chain
is
_process_temp_audio_file→_detect_comprehensive_eas_segments→_build_composite_audio_segment. Grouping the two ends into onecomposite_audiomodule — which is what their names suggest — puteas_detectionin the middle of a cycle. Split intocomposite_audioandtemp_audio, and the generator now walks the emitted imports and fails on a cycle by name rather than leaving Python to raise a partially-initialised ImportError at startup. Group by the call graph, not by topic name. - No
__file__hazard here, unlike 3e and 3f:repo_rootis derived fromapp.root_path, so it does not shift with the module's depth. eas_detection.py(316) holds one 276-line function. Within the guidance as a module, but it is the same shape as the other single-function modules this plan has flagged; noted rather than tracked.- Verification. 27 of 28 definitions (top-level and nested) are
ast.dump()-identical. The one difference isregisteritself, which is deliberately restructured to fan out to the route modules. URL map unchanged at 549 rules, 0 differences, all 7 endpoints intact.
[2.149.0] - 2026-08-08 - Phase 3f: split the maintenance routes
Changed
webapp/admin/maintenance.py(1,802 lines) is now thewebapp/admin/maintenance/package — 15 modules plus a 118-line__init__, every one within the 400-line guidance. All 31 top-level definitions moved verbatim: 31 of 31 areast.dump()-identical.| Module | Lines | Contents | | --- | ---: | --- | |
blueprint.py| 28 |maintenance_bp| |paths.py| 41 |repo_root— the tools scripts and.envlive under it | |routes_poll.py| 53 | Out-of-band feed poll | |serialization.py| 65 | A CAP alert row for the admin views | |eas_settings.py| 85 | The singleton EAS settings row | |routes_env.py| 122 | The.enveditor | |routes_operations.py| 140 | Operation status, backup, upgrade | |routes_database.py| 153 | DB health and optimize | |routes_expiry.py| 162 | Mark and clear expired alerts | |operations.py| 165 | Backup/upgrade progress state and its lock | |routes_location.py| 178 | Location settings, filtering, FIPS lookup | |routes_import.py| 249 | Manual single-alert import | |routes_eas_settings.py| 259 | The EAS settings page | |routes_alerts.py| 264 | Admin alert list and detail | |noaa.py| 271 | Theapi.weather.govclient | |__init__.py| 118 |register_maintenance_routes,__all__|
Fixed
repo_rootwould have silently moved, taking backup, upgrade and the.enveditor with it. It isPath(__file__).resolve().parent.parent.parent— three hops to the repository root fromwebapp/admin/maintenance.py, one short fromwebapp/admin/maintenance/paths.py. It resolvestools/create_backup.pyandtools/inplace_upgrade.py, is passed as theircwd, and locates the.envthat Settings → Environment reads and writes. Unfixed, all three would point intowebapp/: the backup would invoke a script that does not exist and the environment editor would edit a file nothing reads. This is the second consecutive phase to hit this hazard.get_operation_statusis re-exported even though__all__does not list it.app_core/websocket_push.pyimports it by name to feed the admin operation-status push. The import sits inside a function whose caller logs and continues, so losing it would have been silent — precisely the shape of theAudioSourceConfigDBregression the audio_ingest split caused. The test derives the required names by AST-walking the tree rather than listing them, so it cannot drift.
Added
tests/test_maintenance_package.py— 20 tests, the first this module has had. Beyond the structural guards it pins two things worth stating:- The blueprint takes no
url_prefix—/adminis written into each route decorator, the opposite of the certbot blueprint next door, which is registered withurl_prefix='/admin'and whose decorators must not repeat it. The two conventions sit in adjacent packages; the test records which is which so "harmonising" them is a deliberate act. _OPERATION_STATEis mutated in place, never rebound. That is what makes a by-value import of it safe. A module that rebound it would give the status endpoint a private copy that never updates, and the test fails if one ever does.
Both guards are mutation-checked: dropping a
.parentfails 2 tests, and removing theget_operation_statusre-export fails 1.- The blueprint takes no
Notes
limitis allow-listed but never sent upstream.build_noaa_alert_requestaccepts it and drops it;retrieve_noaa_alertsapplies it client-side by slicing the response. So a manual import fetches the full NOAA result set and trims it locally. Left as-is — this is a motion commit — but now asserted by a test, so a future change that starts forwarding it is a visible decision rather than an accident.
[2.148.0] - 2026-08-08 - Phase 3e: split the Certbot routes
Changed
webapp/admin/certbot.py(1,946 lines) is now thewebapp/admin/certbot/package — 14 modules plus a 105-line__init__. Like 3d this was ordinary top-level definitions, so all 22 moved verbatim.| Module | Lines | Contents | | --- | ---: | --- | |
blueprint.py| 40 |certbot_bpand the domain/email patterns | |log.py| 43 | The one logger, namedwebapp.admin.certbot| |failures.py| 50 | certbot exit → operator-readable explanation | |routes_pages.py| 50 | The rendered settings page | |nginx.py| 93 | Is nginx up, and bring it up | |routes_status.py| 119 | Certificate status and the log tail | |routes_settings.py| 134 | Reading and writing stored settings | |staging.py| 149 | Detect and clear staging certificates | |paths.py| 157 | The writablecertbot_datatree | |routes_actions.py| 228 | Auto-renewal, download, install | |routes_obtain.py| 273 | Obtain dry-run and the domain test | |routes_renew.py| 286 | Renew dry-run and the real run | |install.py| 293 | Install a certificate into nginx | |routes_obtain_execute.py| 449 | The real certificate run | |__init__.py| 105 |register_certbot_routes, side-effect imports |register_certbot_routes(app, logger)is unchanged, and__all__still exports it alongsidecertbot_bp.
Fixed
The
certbot_datadirectory would have silently moved.CERTBOT_BASE_DIRisPath(__file__).parent.parent.parent / 'certbot_data'— three hops fromwebapp/admin/certbot.pyto the repository root. Fromwebapp/admin/certbot/paths.pythat is one hop short, so it resolves to<repo>/webapp/certbot_data. Nothing raises: certbot would have built a fresh empty tree in the wrong place and every existing certificate would have looked like it had vanished. Fixed to four hops and pinned by a test that asserts the resolved value, plus a second test that rejects any newPath(__file__).parent.parent.parentadded at the wrong depth.Log records keep the name
webapp.admin.certbot. The single-file module had onelogging.getLogger(__name__)behind all 92 call sites. Per-module loggers would have renamed those records towebapp.admin.certbot.routes_obtainand friends, breaking any log filter or journald grep keyed on the old name. There is onelog.pyholdinggetLogger(__package__)and every module imports it. A by-value import is safe here specifically becauseregister_certbot_routesdoes not rebind the module logger — it only writes one line through the logger it is handed. (Phase 3a's audio_ingest package needed a fan-out instead precisely because itsregisterdid rebind.)Two dead imports dropped out:
osanddatetime.datetimewere imported by the single-file module and used by none of it.
Added
tests/test_certbot_package.py— the first tests this module has ever had. It had zero coverage before the split, which is worth stating plainly given it drives certificate issuance. Eleven tests covering the three failure modes that produce no error: the resolvedcertbot_datapaths, the logger name, and routes lost from the URL map. Also the/adminprefix invariant the module docstring warns about, and that the domain and email patterns still discriminate — they gate every certificate request.All three guards are mutation-checked: dropping a
.parentfails 2 tests, reverting the logger to__name__fails 1, and removing a route module from the__init__imports fails 2.
Notes
routes_obtain_execute.pyis 449 lines and knowingly over the guidance.obtain_certificate_executeis a single 387-linetryblock, so module-level splitting cannot shrink it — that needs collaborators extracted from the body, which is a behavioural refactor and needs the behaviour pinned first. On a module that had no coverage at all, that is its own piece of work; tracked as Phase 3e-ii. The size test names it as a known exception and fails if it is ever silently joined by another.- Verification. 22 of 23 top-level definitions are
ast.dump()-identical. The one difference isregister_certbot_routes, retyped into the package__init__: its docstring lost the trailing whitespace on one blank line. That is the whole diff —"...app.\n \n Routes"became"...app.\n\n Routes". The URL map is unchanged at 549 rules, 0 differences, all 14 certbot endpoints intact, and the fourCERTBOT_*paths resolve to exactly their pre-split values. - Four pre-existing F541 warnings (f-strings with no placeholders) moved across verbatim rather than being cleaned up, to keep the diff pure motion.
[2.147.0] - 2026-08-08 - Phase 3d: split the admin API routes
Changed
webapp/admin/api.py(2,105 lines) is now thewebapp/admin/api/package — 13 modules plus a 95-line__init__. Unlike the last two phases this file was 21 ordinary top-level definitions rather than handlers nested inside one giantregister(), so every definition moved verbatim.| Module | Lines | Contents | | --- | ---: | --- | |
blueprint.py| 45 | The sharedapi_bp| |hostinfo.py| 81 | Host CPU sample cache and primary-IP detection | |motion.py| 98 | The NWS storm-motion parameter parser | |county.py| 164 | The county-wide heuristic and location terms | |display_data.py| 322 | One alert flattened for the detail views | |routes_geometry.py| 176 |/api/alerts/<id>/geometry| |routes_alert_detail.py| 337 |/alerts/<id>| |routes_alert_export.py| 265 | PDF, social-share image, IPAWS audio | |routes_alerts_list.py| 321 |/api/alertsand/api/alerts/historical| |routes_boundaries.py| 141 |/api/boundaries| |routes_system.py| 281 |/api/system_status,/api/system_health| |routes_system_history.py| 136 |/api/system_health/history| |routes_smart.py| 165 |/api/smart_diag| |__init__.py| 95 |register_api_routesand the side-effect imports |All 14 modules are within the 400-line guidance. The entry point is unchanged —
webapp/admin/__init__.pystill doesfrom .api import register_api_routes, and it is the only name the package re-exports.Import blocks are derived, not hand-written. Guessing them produced 127 ruff errors on the first attempt. The generator now narrows each of the original file's import statements to the names the module actually uses, with the free-variable set computed by
symtablerather than by countingast.Namenodes. Name counting is scope-blind and had already claimed a local variabledescinside_extract_alert_display_dataas a use offrom sqlalchemy import desc— the same bug Phase 4a hit with a parameter namedtext.Three dead imports dropped out of the split:
flask.current_app,app_utils.vtec.extract_vtec_identityandoptimized_parsing.json_dumpswere imported by the single-file module and used by none of it.
Fixed
tests/test_api_field_fixes.pyandtests/test_detect_county_wide_false_positive.pyread the API source as text and would have broken on the move. They now scan the package directory, which also means a future split cannot make them pass vacuously: an assertion against a missing file fails loudly, but one against a shim that no longer holds the code would quietly succeed.
Added
tests/test_api_package.py— the structural guards for the split, all covering things that fail silently: a route module dropped from the__init__imports vanishes from the URL map without raising; the blueprint'simport_nameshifts ifblueprint.pypasses its own__name__; and the CPU sample cache stops updating if any module imports it by value.It also exercises the real
_detect_county_wideagainst a stub alert. The existing regression tests reimplemented the two heuristics locally and then grepped the source to check the original still matched — a copy of the logic cannot catch a change to the original. Both heuristics are mutation-checked: removing the_multi_county_listguard fails 2 cases, and droppingcounty_shortfrom thecounty_and_statetest fails 2 more.Writing those cases surfaced that
state_codeholds the two-letter postal code (OH), not the spelled-out state. The_multi_county_listguard counts", <state_code>"occurrences against area descriptions NWS writes as"Allen, OH; Putnam, OH; Van Wert, OH", so a fixture using"ohio"counts zero and reinstates the false positive the guard exists to prevent.
Notes
- The blueprint's
root_pathmoved fromwebapp/admintowebapp/admin/api.import_nameis unchanged, but it now names a package rather than a module, so Flask derives a different directory. Nothing reads it — the blueprint sets neithertemplate_foldernorstatic_folderand never callsopen_resource— and a test pins that, so adding one later is a deliberate decision rather than a surprise. - Verification. 21 of 21 top-level definitions are
ast.dump()-identical before and after, every non-blank line of the original lands in exactly one module (the 11 that do not are the re-rendered import statements, the module docstring and theBlueprint(...)line), and the app's URL map is unchanged: 549 rules, 0 differences, verified against a worktree at the pre-split commit.
[2.146.1] - 2026-08-08 - Restore the audio-source WebSocket push
Fixed
The live audio-source list stopped updating over WebSocket. The Phase 3a split turned
webapp/admin/audio_ingest.pyinto a package, and the package__init__re-exports only the names it means to. The single-file module had importedAudioSourceConfigDBat its top, so the model was incidentally importable from it — andapp_core/websocket_push.pyimported it from there in two places. Both broke:_emit_audio_sources_update()imports at the top of the function, outside itstry, so every call raisedImportErrorbefore doing any work. The Audio Monitoring page's source list never received a push._refresh_config_cache()imports inside atry/except Exceptionthat logs at debug level, so the shared audio-source config cache silently stayed empty for the process's whole life.
Neither surfaced an error a human would see — one was swallowed at debug, the other logged once per emit into a stream nobody reads. Both now import the model from
app_core.models, which is where every other consumer (app_core/audio/eas_monitor.py,eas_monitoring_service.py, the diagnostics scripts) already gets it. A routes package was never the right home for a model import.
Changed
tests/test_audio_ingest_package.pynow derives the shim's required exports from the tree instead of a hand-written list. The existingPUBLIC_IMPORTStuple is kept as an explicit floor, but it is the exact mechanism that let this through:AudioSourceConfigDBwas never added to it, so the guard was green while the import dangled. The newtest_every_absolute_import_of_the_package_resolvesAST-walks every.pyin the repository forfrom webapp.admin.audio_ingest import …and asserts each name still resolves. Confirmed discriminating — it fails with the pre-fix source and passes after.
[2.146.0] - 2026-08-08 - Phase 3c: split the radio settings routes
Changed
webapp/routes_settings_radio.py(2,781 lines) is now thewebapp/radio_settings/package — 17 modules behind a 52-line shim. It was eight module-level helpers plus a 2,114-lineregister()with 26 route handlers nested inside it.Every handler closed over exactly
appandroute_logger, so each one moved verbatim into a topic module that keeps its own smallregister(app, route_logger)— no reindentation, no signature changes.| Module | Lines | Contents | | --- | ---: | --- | |
deps.py| 129 | The seams a test injects fakes for, plus the capture constants | |sdr_client.py| 64 |_send_sdr_command| |serialization.py| 162 | ARadioReceiverrow rendered to the API payload | |payload.py| 291 |_parse_receiver_payload— the whole write-side contract | |sync.py| 174 | Reconciling the database against the live RadioManager | |routes_pages.py| 66 | The two rendered pages | |routes_receivers.py| 259 | Receiver CRUD | |routes_receiver_control.py| 241 | Restart, audio-monitor wiring | |routes_devices.py| 265 | Discovery, capabilities, frequency validation | |routes_presets.py| 49 | Built-in tuning presets | |routes_signal.py| 355 | Waveform and spectrum | |routes_monitoring.py| 64 | Dashboard status and the diagnostics summary | |routes_diagnostics_status.py| 306 | Diagnostics status, SoapySDR error decoding | |routes_diagnostics_capture.py| 275 | IQ capture request and download | |routes_diagnostics_waterfall.py| 315 | The waterfall view | |routes_diagnostics_analyze.py| 386 | Capture analysis and the auto-gain sweep | |__init__.py| 105 |register, fanning out to the topic modules |All 17 modules are within the 400-line guidance.
Test seams are now reachable in one place. The names the radio tests inject fakes for —
get_redis_client,get_radio_manager,_log_radio_event,RADIO_CAPTURE_DIRand the other capture constants — live indeps.pyand are called through the module. A by-value import would have snapshotted the real object, so a stub set in one place would have been silently ignored everywhere else.
Fixed
- Nothing user-visible. 34 of 34 moved definitions are
ast.dump()-identical, every non-blank line of the original lands in exactly one module, and the app's URL map is unchanged: 549 rules, 0 differences, verified against a worktree at the pre-split commit.
Notes
- Three functions import
get_redis_clientfromapp_core.redis_clientlocally, shadowing the module-level import fromapp_core.extensions— a genuinely different object. Those call sites were left exactly as they were; rewriting them would have changed behaviour, not just location. tests/test_8khz_stress_test.py::test_8khz_with_increasing_noise[0.2]is flaky: it generates noise with an unseededrandom.randint, so the 20 % case sits close to the confidence threshold and fails occasionally. Unrelated to this change; noted here because it can turn a full-suite run red.
[2.145.0] - 2026-08-08 - Phase 3b-ii: split the /alerts browse surface
Changed
The 385-line
alerts()handler and its ~175-line PDF export are now thewebapp/public/alerts_page/package. This completes Phase 3b-ii — every module inwebapp/public/is now within the size guidance.Unlike the previous two, this one is a pipeline, so the modules are stages:
| Module | Lines | Contents | | --- | ---: | --- | |
filters.py| 210 |AlertFilters, the sortable-column allow-list, request parsing and clamping | |query.py| 133 | Search, exact filters, date range, VTEC, visibility rules, sorting | |pagination.py| 101 |MockPaginationand the paginate-with-fallback | |options.py| 88 | Filter dropdown values and the headline counts | |enrichment.py| 160 | Audio map, manual activations, lazy IPAWS backfill | |pdf_export.py| 150 | The export's query, blocks and filter summary | |__init__.py| 94 |build_alerts_page|webapp/public/alerts.pydrops from 648 to 112 lines.Added
tests/test_public_alerts_page.py(73 tests). Neither handler had coverage. The suite pins the input clamping (page,per_page,sort,directionare all attacker-controlled), the VTEC override, every filter, the pagination fallback includingiter_pages()elision, and the PDF export's formatting and truncation.
Fixed
- Removed a dead
per_pagecapture in the PDF export that had been flagged byruff(F841) for some time.ruff check --select F,E9is now clean across the whole ofwebapp/public/.
Notes
- The PDF export applies a strict subset of the page's filters. It honours
search, status, severity, event, source and
show_expired, but not the date range, the VTEC event chain or the superseded rule — so a PDF exported from a filtered page can contain rows the page was hiding. This is pre-existing behaviour, now documented inpdf_export.pyand pinned bytest_pdf_export_ignores_filters_the_page_supports. Unifying the two query builders is a behaviour change and needs its own commit.
[2.144.0] - 2026-08-08 - Phase 3b-ii: split the /stats dashboard
Changed
The 645-line
stats()handler is now thewebapp/public/stats_sections/package. The handler was seventeentry/exceptblocks in a row, each running a few queries, writing into a sharedstats_datadict and declaring its own fallback so one failing query could not lose the whole dashboard. That pattern is now expressed once, as aStatsSectioncontract:| Module | Lines | Contents | | --- | ---: | --- | |
common.py| 81 | TheStatsSectioncontract and the runner | |alerts_overview.py| 215 | Headline counts, boundary/status/severity/event breakdowns, urgency, certainty, message types | |timeline.py| 237 | Hour/weekday/month/year buckets and the recent-alert feed | |coverage.py| 143 | Most-affected boundaries, alert durations, coverage overlap | |broadcast.py| 173 | Forwarding rate, manual activations, received alerts, broadcast latency, relay stats | |polling.py| 183 | Poller success rate, timings and trend | |__init__.py| 100 | The ordered pipeline andbuild_stats_data|webapp/public/stats.pydrops from 693 to 50 lines. The pipeline order is declared explicitly in one place because it is load-bearing — three sections divide bytotal_alertsand must run after the counts.Added
tests/test_public_stats_sections.py(32 tests). The handler had no coverage; the suite pins every derived rate, every time bucket, the per-section fallbacks, and the full set of keysstats.htmlindexes.
Fixed
- Nothing user-visible. The rendered payload was compared key-by-key against the pre-refactor handler across an empty and a populated database — 70 keys, zero differences — and the test suite was mutation-checked before (17/17 caught) and after (17/17 caught) the split.
Notes
- 29 of the handler's 31 trailing
setdefaultcalls were dead. Every section already set its keys on both its success and its failure path, so those defaults could never fire. Onlyavg_durationsandlifecycle_timelinehad no producer; the new package keeps just those two and the section contract now guarantees the rest structurally.
[2.143.0] - 2026-08-08 - Phase 3b-ii: split the /logs query layer
Changed
_load_logs_data(1,057 lines in a single function) is now thewebapp/public/logs_sources/package, nine modules behind a shared contract. This is the Phase 3b-ii follow-up recorded indocs/development/LARGE_FILE_REFACTOR_PLAN.md— the first of the threewebapp/public/modules that Phase 3b left over the size guidance.The function was a seventeen-way
if/elifonlog_type, each branch querying its own source and shaping rows into the generic log dict the template renders. Every branch is now a loader taking oneLogQueryand returning oneLogPage, so the dispatcher is a table lookup:| Module | Lines | Contents | | --- | ---: | --- | |
common.py| 83 | TheLogQuery/LogPagecontract and the shared timestamp sort key | |database.py| 329 | System, polling, polling-debug, audio, audio-metrics, audio-health, GPIO | |eas.py| 304 | EAS messages, decoded audio, manual activations, received alerts | |audit.py| 130 | The audit trail and the compliance ledger | |reports.py| 146 | The six FCC report kinds and the report metadata envelope | |services.py| 90 | The systemd journal category | |aggregate.py| 112 | The "All Logs" merge, fault tolerance and truncation | |aggregate_collectors.py| 346 | The eleven per-category collectors the merge runs | |__init__.py| 81 |LOADERSandresolve_loader|webapp/public/logs_data.pydrops from 1,116 to 80 lines and now only dispatches;MIN_LOGS_PER_CATEGORYis re-exported so the old import path still resolves. Adding a log category no longer means editing a 1,000-line function — it means writing a loader and registering it.Added
tests/test_public_logs_data.py(79 tests). The loader previously had no test coverage at all. The suite asserts the complete returned triple for every log type — display name, every key of every row, and the report metadata — plus the per-branch level-derivation rules, the fallback strings for missing fields, the audit action filter running in SQL, the session rollback after a failing report builder, and the limit arithmetic.
Fixed
- Nothing user-visible. The split is behaviour-preserving: the loader's full output was compared across 115 scenarios (23 log types × 5 parameter combinations) against the pre-refactor code with zero differences, and the test suite was mutation-checked both before the refactor (15/15 caught) and after it (16/16 caught) to prove it would notice if that changed.
[2.142.0] - 2026-08-07 - Phase 4a: split the system-monitoring helpers
Changed
app_utils/system.py(2,580 lines) is now theapp_utils/system/package, 16 focused modules behind a re-exporting__init__. This is the Phase 4app_utils/system.pyentry indocs/development/LARGE_FILE_REFACTOR_PLAN.md— the one the plan flagged as "mostly independent helpers — easier than it looks", and it was: 47 top-level definitions sharing one file, with a strictly acyclic internal dependency graph and no__file__-relative paths to shift.| Module | Lines | Contents | | --- | ---: | --- | |
common.py| 102 |SystemHealth,_safe_read_text,_safe_int,_coerce_int,_to_bool,_is_valid_temperature| |dependencies.py| 96 |_collect_dependency_versions| |services.py| 146 |_collect_systemd_services| |badges.py| 189 | Shields.io badges and the distro logo URL | |osinfo.py| 131 | OS release details and virtualization detection | |network.py| 111 | Interface traffic counters, primary-interface selection | |device_tree.py| 110 |DEVICE_TREE_CANDIDATESand the device-tree readers | |block_devices.py| 163 |lsblkinventory and its simplifier | |hardware.py| 276 | CPU, USB and platform inventory | |disks.py| 99 | Disk enumeration and device-type detection | |smart.py| 429 |_collect_smart_health| |smart_fields.py| 202 | smartctl/NVMe field extraction | |temperature.py| 177 | Thermal sensor readings | |rtc.py| 127 | Real-time clock status | |subsystems.py| 147 | Hardware subsystem and GPS probes | |snapshot.py| 478 |build_system_health_snapshot|Pure motion, verified as such: 48 top-level definitions, 48
ast.dump()matches, zero differences, plus the standing assertion that every non-blank line of the original lands in exactly one module.DEVICE_TREE_CANDIDATESis deliberately not re-exported from the package. It is a mutable list that two tests replace withmonkeypatch.setattr; re-exporting it would have turned both patches into silent no-ops, sincedevice_treeresolves the name from its own globals. Leaving it out made both fail loudly withAttributeError, which is what pointed at the right patch target. Same forPathandpsutilin the CPU-details test — all three now patchapp_utils.system.<module>.
Fixed
docs/guides/SMART_SETUP.mdpointed at line ranges in the old monolith (app_utils/system.pylines 1276-1557) and atwebapp/routes_public.pyfor the health-page route, which moved towebapp/public/pages.pyin 2.141.0. Both now name modules and functions rather than line numbers.
Notes
smart.py(429) andsnapshot.py(478) are still over the 400-line guidance. Each is one function —_collect_smart_healthis 396 lines andbuild_system_health_snapshotis 406 — so module-level splitting cannot shrink them; they need collaborator extraction with a characterization harness built first, as in Phase 2e / 3a-ii. Tracked as Phase 4a-ii.- One pre-existing lint finding was left alone deliberately:
smart.pyhas an unuseddevice_namelocal (F841), present in the monolith. Fixing it is a behaviour-adjacent change and does not belong in a pure-motion commit.
[2.141.0] - 2026-08-07 - Phase 3b: split the public route surface
Changed
webapp/routes_public.py(2,849 lines) is now thewebapp/public/package, one module per surface. This is Phase 3b ofdocs/development/LARGE_FILE_REFACTOR_PLAN.md.Unlike the
audio_ingestsplit in 2.139.0, this module had exactly one top-level definition: its entire body was a single 2,779-lineregister(app, logger)with all 21 route handlers nested inside it. Nothing could be moved at module level.That made the split simpler rather than harder. Every handler closed over only
app(for the@app.routedecorator) androute_logger— verified by walking the AST for names resolving toregister's scope, not assumed — so each surface keeps its ownregister(app, route_logger)and the handler bodies move verbatim, still nested inside aregister, just a much smaller one. No reindentation, no rebinding, no signature changes.| New module | Lines | Contents | | --- | ---: | --- | |
pages.py| 189 |/,/about,/help,/style-guide,/attribution,/support,/navigation,/terms,/privacy,/sms-compliance,/system_health,/audio-monitor| |sitemap.py| 115 |/sitemap.xml| |stats.py| 693 |/stats| |alerts.py| 648 |/alerts,/alerts/export.pdf| |logs_data.py| 1116 |_load_logs_data, via abuild_logs_loader(route_logger)factory | |logs.py| 265 |/logs,/logs/export.csv,/logs/export.pdf|_load_logs_datais a helper the three/logshandlers share, not a route. Wrapping it in a factory rather than re-signaturing it to takeroute_loggerexplicitly keeps its 1,057-line body byte-identical — only the enclosing scope changed.logs.pyreceives it as a parameter named_load_logs_data, so its three call sites resolve unchanged.webapp/routes_public.pyremains as a 31-line shim re-exportingregister, so the route-module registry inwebapp/__init__.pyis untouched.Verified three independent ways, each confirmed discriminating before its result was trusted: 21/21 handlers
ast.dump()-identical with every non-blank line placed exactly once (2,753 lines, 0 unplaced); the Flask URL map diffed against a worktree at the pre-split commit (549 rules, 0 differences, mutation-checked by deleting aregister()call); and all 28 public surfaces fetched through the test client on both sides and hashed (28/28 identical, 28 distinct digests). Full suite: 2,045 passed.logs_data.py,stats.pyandalerts.pyremain over the 400-line guidance — each is dominated by one enormous function, which module-level splitting cannot shrink. Tracked as Phase 3b-ii.
[2.140.2] - 2026-08-07 - Stalled audio sources no longer cycle forever
Fixed
A dead audio source restarted forever instead of backing off, filling the Audio Alerts log for hours. Production showed two sources (
WNCI,ERN-LUC) repeatingstall, stall, stall, error, erroron a ~3-minute cycle for over four hours, with no terminal state and no recovery.The restart circuit breaker existed but was unreachable for this failure mode.
AudioSourceAdapter.restart()cleared_consecutive_failed_restartsand_quarantined_untilwheneverstart()returnedTrue— butstart()only proves the capture launched. A stream URL that is off the air, or a dead SDR, relaunches cleanly every single time while never delivering a sample, so the breaker's counters reset on every cycle. The health monitor compensated with its own consecutive-stall escalation, which set a flat 60s quarantine; the restart that followed quarantine expiry then wiped that quarantine too. Net effect: escalate → wait 60s → restart → stall ×3 → escalate, indefinitely.Three changes break the loop:
restart()no longer clears the breaker. A successful launch marks the restart provisional (_restart_unconfirmed); onlynote_healthy()— called by the health monitor when it observes a metrics update produced by a real audio chunk — clears it. This mirrors the guard the monitor already applied to its own stall counter.- Quarantine now backs off exponentially (60s → 120s → … → 900s cap) instead of staying at 60s, so a source that is simply off the air settles at one retry every 15 minutes. The backoff resets on confirmed recovery.
- Repeat alerts are deduplicated by severity (
stall<disconnected<error) with a 15-minute re-notify. A failing source oscillates between those states as it is restarted, so keying on state equality would have deduplicated nothing; keying on message would have been worse still, as the escalation message embeds_describe_stalldiagnostics that change every cycle.
Measured on a reproduction of the production shape: 21 alerts and 14
start()calls in 30 seconds before, 2 alerts after — with the outage still fully reported and recovery still working. Regression tests intests/test_audio_stall_recovery_backoff.py; the three end-to-end assertions were mutation-checked by restoring the originalrestart()behaviour, which fails exactly them.A flapping SDR receiver wrote an
ERRORand a matchingINFOrow per failed read.radio.wbkslogged 20 ERROR/INFO pairs within three seconds._update_statusis called from the capture loop on every read and emitted an event unconditionally;_last_logged_errorwas maintained for exactly this purpose but never consulted. The paired "recovered" INFO made it worse by clearing_last_logged_error, so the next failed read looked like a brand new error. Identical errors are now rate-limited to one row per 5 minutes, and a recovery must hold for 10 seconds before it is announced.The Logs page "Copy" button omitted the message from every row. It read
cells[3]as the message, but the log tables have six columns andcells[3]is the Alert column — which renders a muted em dash when a row has no linked alert. Every copied line therefore ended in—with the actual message dropped. Copy now reads the correct column via a namedLOG_COLmap, includes the alert identifier, drops em-dash placeholders, and appends the collapsed Details JSON so copied logs carry the diagnostics that make them actionable.
[2.140.1] - 2026-08-07 - Stop serving stored stream credentials to the browser
Fixed
GET /api/audio/sourcesreturned the stored stream password in cleartext._redact_device_paramswas applied at individual call sites rather than at the payload boundary, and the two branches of the sources listing — the Redis-backed one and the database-only one — never called it. Any client that could reach the endpoint receiveddevice_params.auth_passwordverbatim for every configured source. The detail endpoint had always redacted, which is why the gap went unnoticed. Predates the 2.139.0 package split; reproduced on 2.138.1.Every endpoint emitting
device_paramsreturned the storedAuthorizationheader, including the paths that already redacted._redact_device_paramsonly ever strippedauth_password, soauth_header— sent verbatim as theAuthorizationheader byapp_core/audio/sources.py, and therefore a bearer token or Basic credential — passed straight through the redaction that was supposed to be protecting it. This affectedGET /api/audio/sources/<name>too, not just the listing.Redaction now happens in one place.
SECRET_DEVICE_PARAM_KEYSnames every credential key,_redact_device_paramsreplaces each with a<key>_setboolean, and_config_blockroutes the listing through it.auth_usernameis deliberately not redacted: a username is not a credential on its own and the edit form repopulates from it._restore_audio_source_from_db_configcopied credentials intoadapter.metrics.metadata. It folds everydevice_paramskey into the adapter's metadata, which the detail endpoint serializes — so the secrets reached the API even on the adapter path, whereconfig.device_paramsitself was redacted. It now redacts first, and copies the metadata dict instead of mutating the adapter's own.GET /api/audio/icecast/configreturned the Icecast source and admin passwords in cleartext, with no permission check. The sibling POST that writes the same settings requiredsystem.configure; the read did not. The route is now gated to match, and both the read and write responses carrypassword_set/admin_password_setbooleans instead of the secrets.
Changed
- The stream edit form treats the Authorization header as write-only, the way
it already treated the password.
static/js/audio_monitoring.jsrenders it as an empty password field with a "leave blank to keep current" hint and only submits it when the operator types a new value — without this the redaction would have wiped the stored header on every save.
Added
tests/test_audio_credential_redaction.py(15 tests). The central one is deliberately blunt: plant a known secret in a source'sdevice_params, hit every audio endpoint, and assert the value appears nowhere in the response bytes — the failure mode a call-site-by-call-site approach keeps reproducing. All four protections were mutation-checked; revertingSECRET_DEVICE_PARAM_KEYSto its pre-fix value fails seven tests, including the detail endpoint.
[2.140.0] - 2026-08-06 - Large-file refactor, phase 3a-ii: the audio source listing
The follow-up 2.139.0 opened. routes_sources.py was the one module the package
split could not bring under the 400-line guidance, because
api_get_audio_sources was 327 lines of a single handler. Every module in
webapp/admin/audio_ingest/ is now under the cap. Behaviour is unchanged.
Changed
api_get_audio_sources(327 lines) became a 15-line handler plus two modules. The handler reconciled three sources of truth inline — the database rows, the local controller's adapters, and the audio service's Redis snapshot — while also decoding Redis' several payload shapes and building two different JSON bodies. Those are now separated:| Module | Lines | Contents | | --- | ---: | --- | |
listing.py| 226 |RedisControllerStateand its decoding, the latest-metric query, Icecast status collection, andbuild_source_listing| |source_payload.py| 268 |_serialize_from_redisand_serialize_db_only— one audio source rendered to JSON |routes_sources.pykeeps only the two read endpoints (88 lines) and delegates tobuild_source_listing().The write endpoints moved to
routes_sources_write.py(389 lines). The split follows the permission boundary exactly: every handler there carries@require_permission('receivers.configure')and neither handler left inroutes_sources.pycarries one.Three shapes the endpoint has always tolerated are now named rather than implied.
RedisControllerState.service_deaddistinguishes "the audio service published nothing" from "it published an empty source list" — the former is what turns an auto-start source's badge from grey Stopped into red Error._redis_data_fornames the rule that a non-dict Redis entry falls back to the database row rather than to the local controller._collect_icecast_statusdocuments that Redis is the fallback for stream stats, never an override of a locally hosted streaming service.
Added
tests/test_audio_source_listing.py(12 tests) characterizesGET /api/audio/sourcesacross all three of its live-state paths — local adapter, Redis snapshot, database-only — plus the envelope counters, the dead-service escalation rule, malformed Redis payloads, JSON-encoded controller blobs, and a failing streaming service. Written against the pre-refactor handler and confirmed discriminating first: five deliberate mutations (disabling the dead-service escalation, reversing Redis/database metric precedence, invertingdb_only_count, letting Redis override the local streaming service, and dropping the reconstructed Icecast URL) each failed exactly the test covering them.
[2.139.0] - 2026-08-06 - Large-file refactor, phase 3: the audio-ingest admin API
The first web-layer split. webapp/admin/audio_ingest.py (3,180 lines — the
largest Flask module in the tree) became webapp/admin/audio_ingest/: 15
modules, 14 of them under the 400-line guidance. Behaviour is unchanged.
Changed
webapp/admin/audio_ingest.py→webapp/admin/audio_ingest/. Helpers and handlers had been interleaved down the length of the file; they are now separated the waywebapp/audio_archive/already does it.| Module | Lines | Contents | | --- | ---: | --- | |
blueprint.py| 36 | the sharedaudio_ingest_bp| |controller.py| 231 | controller singleton, startup, Redis metrics bridge | |streaming.py| 262 | auto-streaming (Icecast) service lifecycle | |sanitize.py| 173 | JSON-safety helpers for values reaching the API | |probe.py| 155 | stream-URL probing | |radio_sources.py| 385 | SDR-backed audio source provisioning | |serialization.py| 359 | audio source → API payload | |routes_sources.py| 749 | source collection and item endpoints | |routes_source_control.py| 164 | start/stop and stream-test endpoints | |routes_rbds.py| 151 | RBDS history endpoint | |routes_metrics.py| 233 | metrics endpoints | |routes_health.py| 255 | health endpoints and the dashboard page | |routes_alerts.py| 204 | audio alert endpoints | |routes_devices.py| 159 | device discovery, waveform, spectrogram, stream | |routes_icecast.py| 205 | Icecast configuration and stream control |The package
__init__.pyis the compatibility shim: every name the single-file module exposed is re-exported, so the imports inwebapp/routes_settings_radio.py,app_core/websocket_push.pyandwebapp/admin/__init__.pyare untouched.register_audio_ingest_routeskeeps its(app, logger)signature and stays the package's only__all__entry.register_audio_ingest_routesnow fans the caller's logger out to every submodule. Pre-split there was oneloggerglobal and registration rebound it; the package has one per module, and rebinding only the package's would have left every line that actually logs on its own logger.
Fixed
api_get_rbds_historyand the source start/stop endpoints keep working in tests that reset module state. Four test fixtures reset globals such as_audio_controllerand_auto_streaming_servicethroughmonkeypatch.setattron the module. Those globals now live in the submodule that owns them, and the patches were retargeted accordingly — patching the re-exporting package would not change what a function sees in its own module globals, so the resets would have silently stopped resetting anything.
Added
tests/test_audio_ingest_package.py(16 tests) pins the split: the Blueprint'simport_name, every URL rule surviving registration, the logger fan-out covering every module that logs, the re-exports other modules import, and — the bug class this split could most easily have introduced — that no module imports a mutable global such as_audio_controllerby value. Each guard was mutation-checked: reintroducing the bad import, dropping a module from the fan-out list, and un-importing a route module each fail exactly the test that covers them.
[2.138.1] - 2026-08-06 - Fix cross-constellation PRN collision in GSV parsing
Fixed
A satellite could silently disappear from
satellites_in_view.apply_gsvbuckets each constellation's GSV group by talker, but merged those buckets keyed on PRN alone. PRN numbering restarts per constellation — Galileo and BeiDou both number from 1 — so a$GAGSVreporting PRN 5 overwrote the$GPGSVPRN 5 and the GPS satellite vanished from the view. The surviving row was corrupted as well as deduplicated: it kept whichever talker parsed last, so it showed Galileo's elevation, azimuth and SNR under a single undifferentiated entry. Nothing raised, and the satellite count quietly ran low on exactly the multi-GNSS receivers most likely to hit it.The merge is now keyed by
(talker, PRN), matching the two sibling paths that already treat that pair as a satellite's identity: the GSA path viaGPSManager._sat_key, andservices/gps/trends.py, whose per-PRN SNR map is keyed"<talker><PRN02>"— that map was losing the same satellite one step downstream, and is now correct for free.satellites_in_viewcan consequently carry two entries sharing aprnwith differentconstellationvalues. This is not a new shape: the gpsd ingest path (GPSManager._handle_gpsd_sky) has always published it, so every dashboard consumer already handles it — all of them iterate the array, and none dedupe onprnor use it as a DOM id. Publish order is unchanged in the common case, since PRN remains the primary sort key and talker only breaks ties.
[2.138.0] - 2026-08-06 - Large-file refactor, phase 2e: NMEA sentence parsing
The first collaborator extraction in this effort rather than pure motion. Behaviour is unchanged, but unlike 2a–2d the code is deliberately restructured, so it was verified by characterization rather than by AST comparison.
Changed
GPSManager._handle_sentence(246 lines, 50selfreferences) becameapp_core/gps/nmea.py(326 lines) plus a 30-line orchestrator. The seam is what the sentence says vs. what the manager does about it:apply_gga/apply_rmc/apply_gsv/apply_gsaare standalone handlers that take(fix, msg, state)plus config such asmin_satellites, mutate the fix dictionary and the parse state in place, and return aSentenceEffects— free of the manager, but not pure. Cross-sentence accumulators live inNMEAParseStateand everything else a sentence implies — satellite-history updates, the 3D-fix holdover anchor, a usable UTC datetime — returned in aSentenceEffectsfor the manager to apply. The system-clock sync policy moved to its own_queue_time_sync.gps_manager.py: 2893 → 2313 lines._FIX_QUALITYand_safe_intmoved toapp_core/gps/nmea.py, the NMEA path having been their only consumer. Both are re-exported fromgps_managerso existing imports keep resolving.
Added
tests/test_gps_nmea_sentences.py(19 tests). These rules were previously reachable only by constructing a wholeGPSManager; each is now asserted directly. Coverage includes the two multi-constellation bugs the code exists to prevent — a GLGSV group wiping the GPGSV bucket, and an empty GLGSA clearing the GSA per-cycle union — plus fix-quality mapping, the minimum-satellite status threshold, blank-field handling and per-type sentence counters. Both multi-constellation guards were mutation-checked: reverting the bucketing and the union each failed exactly one test.
Verification
- A characterization harness was built before the refactor: a 28-sentence multi-GNSS stream (full cycles, multi-constellation GSV groups, several GSAs per cycle, no-fix → 2D → 3D transitions, empty GLGSA, malformed fields, out-of-order GSV group numbers) snapshotting every piece of mutated state after each sentence — fix dict, GSV buckets, GSA accumulator and cycle flag, pending time sync, per-PRN satellite history, 3D-fix anchor. The baseline was confirmed discriminating (28 frames, 28 distinct states), and after the refactor the diff was 0 differing frames of 28 with wall-clock timestamps scrubbed. Full suite: 1992 passed, 67 xfailed, 0 failures.
- Deferring the effects until after the parse was checked, not assumed: none of
_record_sat_seen,_record_sat_usedor_mark_3d_fixreads the fix dictionary, so there is no read-after-write ordering hazard.
[2.137.0] - 2026-08-06 - Large-file refactor, phase 2d: GPS timing statistics
Continues the effort tracked in
docs/development/LARGE_FILE_REFACTOR_PLAN.md. Pure motion — no behaviour was
altered.
Changed
- The stateless half of
GPSManagermoved out ofapp_core/gps/gps_manager.py(2893 lines). Profiling the class byselfusage separates 8 methods (386 lines) that never touch instance state from the 46 (2235 lines) that do. Six of those eight (359 lines) moved;_sat_key(2 lines, used only by the manager's satellite-history helpers) and_scan_capture(25 lines, UBX frame scanning that belongs withapp_core/gps/ubx.py) stayed. The six were@staticmethodin all but name — pure functions trapped inside a class — and now live inapp_core/gps/timing_stats.py(342 lines:compute_jitter_summary,compute_allan_deviation,holdover_seconds,derive_leap_state) andapp_core/gps/sysprobe.py(49 lines:read_cpu_temp_c,safe_read).gps_manager.pydrops to 2528 lines and imports them. Verified as pure motion: all 6 functions areast.dump()-identical to their originals once the@staticmethoddecorator and docstring indentation are normalised, every non-blank removed line was asserted present in the new modules, and both implementations were run side by side over 5 interval datasets — including empty, single-sample and constant edge cases — with zero output differences. - The GPS stability tests now import the functions directly. 20 call sites
across
tests/test_gps_stability_metrics.py,test_gps_holdover_anchor.pyandtest_gps_trends_archive.pywere reaching through the class (GPSManager._compute_allan_deviation(...)) to get at a pure function; they now import fromapp_core.gps.timing_stats/sysprobe. No assertion changed.
Documentation
LARGE_FILE_REFACTOR_PLAN.mdcorrected: Phase 2 is not complete. The phase 2c pull request claimed it was and that everything remaining was Flask-coupled or frontend. Both are wrong —gps_manager.pyandapp_core/radio/drivers.pyare still open, and both are pure library code. They differ in kind from 2a–2c: each is a single god-class (GPSManageris 2741 of 2893 lines;_SoapySDRReceiveris 1822 of 2187), and module-level splitting cannot shrink one class. Only their stateless parts move as verifiable motion; the rest needs extracted collaborators and its own design pass. The plan now records this distinction so the remaining work is not mistaken for another mechanical split.- The CI fixes released in 2.136.0 (repository-relative test paths, CI schema creation) shipped without a changelog entry of their own — the entry was lost when that release's merge conflict was resolved in favour of the phase 2c metadata. Recorded here so the history is not silent about them.
[2.136.0] - 2026-08-06 - Large-file refactor, phase 2c: GPIO
Continues the effort tracked in
docs/development/LARGE_FILE_REFACTOR_PLAN.md. Pure motion — no behaviour was
altered. This completes the library-code splits; everything remaining is either
Flask-coupled or on the alert path.
Changed
app_utils/gpio.py(3149 lines) became theapp_utils/gpio/package. Four independent subsystems shared one file: the GPIO backend abstraction (lgpio / sysfs / null behind one Protocol, plus gpiozero pin-factory setup), theGPIOControllerand its behaviour manager, the NeoPixel strip controller, and the USB tower-light controller. New layout:pin_types.py(166),backends.py(426),tower_light.py(410),neopixel.py(336),controller.py(1003),behavior.py(546) andconfig_loaders.py(440), with an acyclic dependency graph. Each optional-dependency probe now sits with its only consumer —get_gpio_settings/_GPIO_SETTINGS_AVAILABLEwith the database loaders,PixelStrip/NeopixelColor/_NEOPIXEL_LIB_AVAILABLEwith the NeoPixel controller. The package__init__.pyre-exports all 72 names the single-file module exposed, sofrom app_utils.gpio import …is unchanged forapp_core/gpio_commands.py,app_core/oled.py,app_core/websocket_push.py,services/gpio/alert_indicators.py,services/gpio/init.pyandwebapp/routes/system_controls.py. Verified as pure motion by comparingast.dump()of every top-level definition: 28 definitions, 28 matches, zero differences, plus an assertion that every non-blank line of the original landed in exactly one module.- The GPIO tests now patch the module that uses each name. 31
monkeypatch.setattrsites acrosstest_gpio_controller.py,test_gpio_behavior_matrix_save.pyandtest_gpio_activation_logging.pytargeted the module object; rebinding a name on the re-exporting package does not change whatconfig_loadersorneopixelresolve from their own globals. They now targetgpio.config_loaders(_GPIO_SETTINGS_AVAILABLE,get_gpio_settings),gpio.neopixel(_NEOPIXEL_LIB_AVAILABLE,NeopixelColor),gpio.controller(_create_gpio_backend) andgpio.behavior.time(sleep). No assertion changed. The retarget was confirmed load-bearing rather than assumed: pointing the patches back at the package fails 9 tests.
Fixed
- Naming the new module
types.pyshadowed the stdlibtypesmodule. The enums-and-dataclasses module was initially calledtypes.py; any process whose working directory is the package directory then resolvesimport typesto it, and the stdlib import chain (dataclasses→re→enum→types) fails with a confusing partially-initialised-module error. It is namedpin_types.py, which is also more descriptive of its contents.
Known limitations
controller.py(1003),behavior.py(546) andconfig_loaders.py(440) remain over the ~400-line guidance.GPIOControllerandGPIOBehaviorManagerare each a single class, so module-level splitting cannot shrink them; that needs extracted collaborators and its own reviewed commit.app_core/radio/demod/types.py(added in 2.134.0) carries the same stdlib-shadowing footgun described above. It is harmless in normal operation — Python 3 defaults to absolute imports and the package directory is not onsys.path— so it was left alone rather than churning already-merged code, but it is noted in the refactor plan as a cleanup candidate.
[2.135.0] - 2026-08-06 - Large-file refactor, phase 2b: the share-image renderer
Continues the effort started in 2.134.0 and tracked in
docs/development/LARGE_FILE_REFACTOR_PLAN.md. Pure motion again — no
behaviour was altered.
Changed
app_utils/image_export.py(3391 lines) became theapp_utils/image_export/package. The alert share-image renderer mixed thirteen concerns in one file: brand logo, canvas layouts, colour palette, font loading, the ALL-CAPS humanizer, threat icons, event theming, drawing primitives, weather particle effects, OSM tile fetching, map rendering, info-panel drawers and the top-level composer. New layout:logo.py(71),layout.py(145),palette.py(62),fonts.py(116),text.py(252),icons.py(81),theme.py(435),drawing.py(143),weather_fx.py(429),tiles.py(257),maps.py(731),panels.py(577) andrender.py(492), with an acyclic dependency graph generated from the actual imports. The package__init__.pyre-exports all 125 names the single-file module exposed (includinglogger), sofrom app_utils.image_export import …is unchanged forapp_core/notifications/alert_image.pyandwebapp/admin/api.py. Verified as pure motion by comparingast.dump()of every top-level definition: 68 definitions, 68 matches, zero differences. The slicing step additionally asserted that every non-blank line of the original landed in exactly one module, so nothing was silently dropped.tests/test_image_export_themes.pywas updated for the package split — the existing 120 assertions are unchanged - only how the module is loaded and patched - plus three new tests pinning asset paths (see Fixed). The test deliberately loads the renderer by file path to avoid importing all ofapp_utils; loading a package that way needssubmodule_search_locationson the spec, or its relative imports resolve back throughapp_utilsand undo the isolation. Ninemonkeypatch.setattrcalls also had to move to the module that calls the patched name (tilesfor_http,mapsfor_fetch_tile/_fetch_county_outlines,renderfor_render_map) — rebinding a name on the re-exporting package does not change whatmaps._render_mapresolves from its own globals. The retarget was confirmed load-bearing rather than assumed: pointing the patches back at the package makes 5 tests fail.app_core/radio/demod/now uses relative intra-package imports, matchingwebapp/audio_archive/,app_core/flask/,app_core/config/,app_core/database/and the newimage_export/. Beyond consistency this is what allows a package to be loaded standalone by file path in tests.
Fixed
- The brand logo would have disappeared from every share image, silently.
_LOGO_PATHand_TILE_DISK_CACHE_DIR_DEFAULTare built by walking two directories up from__file__. That reached the repository root while the renderer was a singleapp_utils/image_export.py; inside the package every module sits one level deeper, so both resolved one short — the logo path becameapp_utils/static/img/…(nonexistent, and_load_logo()swallows the error and renders the card without a logo) and OSM tiles cached intoapp_utils/data/tile-cache, colliding with the directory 2.134.0 had just created for the FIPS table. This is the one way a verbatim move can still change behaviour: the code is genuinely identical, so the AST comparison cannot see it. Both constants now derive from a named_REPO_ROOTwith a comment explaining the depth, andtests/test_image_export_themes.pygained three tests pinning the resolved paths and asserting the logo actually loads. The refactor plan's ground rules gained a rule to audit__file__-relative paths before any future split.
Known limitations
maps.py(731),panels.py(577) andrender.py(492) remain over the ~400-line guidance. They are coherent single concerns rather than mixed ones, and splitting them further would mean carving up_render_mapandgenerate_alert_imagethemselves — a behaviour-adjacent change that belongs in its own commit.- Two pre-existing issues were found and deliberately left alone, since fixing
either changes behaviour:
maps.pycarries an unusedshadowlocal (F841, present identically in the monolith), andtest_render_map_draws_counties_and_scale_barasserts only the output image's size and mode — despite its name and docstring it never checks that county outlines or the scale bar were actually drawn, so it passes whether or not its stubs take effect.
[2.134.0] - 2026-08-06 - Large-file refactor, phase 1: FIPS data and the demodulator
docs/development/AGENTS.md asks for Python modules under ~400 lines. The tree
currently breaks that in 131 modules, 75 templates and 16 JavaScript files.
This release starts working that number down, beginning with the two biggest
Python files. Both changes are pure motion — no behaviour was altered.
Added
docs/development/LARGE_FILE_REFACTOR_PLAN.md— the running plan for the whole effort. It records the full inventory of oversized files, the ground rules that make each split reviewable (move code verbatim, keep the old import path working as a shim, split on a real seam rather than a line number, name the tests that cover the file), a per-file extraction strategy across five phases, and a progress log.webapp/audio_archive/from 2.133.1 is cited as the reference example.
Changed
app_utils/fips_codes.py: 3887 → 673 lines. 3,236 lines of it were a singleUS_FIPS_COUNTY_TABLEstring literal — FCC/Census reference data, not code. Every reader of the lookup helpers scrolled past it and every diff touching the module rendered it. The table moved toapp_utils/data/us_fips_counties.txtin the identicalFIPS|ST|Namepipe format, read at import by_load_county_table()and resolved relative to__file__the same way the module already resolves the NWS partial-county.dbffromassets/. A read failure logs an actionable error and degrades to an empty table rather than failing the import.US_FIPS_COUNTY_TABLEitself is still exported, so nothing downstream changes. Verified by hashingUS_FIPS_COUNTIES,ALL_US_FIPS_CODES,US_FIPS_LOOKUP,US_STATE_COUNTY_TREE,STATEWIDE_SAME_CODES,get_extended_same_lookup(),get_extended_state_county_tree()andget_marine_state_tree()before and after: all identical.app_core/radio/demodulation.py(5355 lines, the largest module in the tree) became theapp_core/radio/demod/package. Six unrelated concerns shared one file: Numba JIT kernels, generic DSP helpers, configuration dataclasses, the RBDS decoder, the FM demodulator and the AM demodulator. The RBDS decoder alone is ~2,300 lines and has its own test file, yet could not be imported without pulling in the whole FM chain. The new layout iskernels.py(426),rbds_constants.py(144),types.py(279),dsp.py(332),rbds_decoder.py(1238),rbds_worker.py(2293),fm.py(795),am.py(96) andfactory.py(48), with a strictly acyclic dependency graph.app_core/radio/demodulation.pystays as a 95-line re-export shim, so every existingfrom app_core.radio.demodulation import …keeps resolving — including the private_NUMBA_AVAILABLEthatwebapp/routes_monitoring.pyreads for the diagnostics page. The split was checked to be pure motion by comparingast.dump()of every top-level definition before and after: 23 definitions, 23 matches, zero differences.tests/test_rbds_demodulation.py,tests/test_fm_stereo_decoder.py,tests/test_early_decimation.pyandtests/test_eas_resampler.pypass unchanged.
Fixed
scripts/rbds_diagnose.pywould have silently reported stale pipeline values after the split. It regex-scans the demodulator source so its report stays in sync with the code rather than hard-coding "current" values, and it pointed atapp_core/radio/demodulation.py— which is now a 95-line shim. Every lookup would have missed and fallen back to the known-good defaults, which is exactly the staleness the introspection exists to prevent. It now reads every module underapp_core/radio/demod/concatenated, so the patterns keep matching wherever a symbol lands if the package is split again. Verified to return identical results against the old monolith and the new package.
Known limitations
scripts/rbds_diagnose.pycannot locate the_costas_pysdr/_mm_timing_pysdrcalls insideRBDSWorker._process_rbdsand falls back to assuming Costas runs first. This predates the refactor — it reproduces identically against the pre-split monolith — and is left alone here because correcting it changes what the diagnostic reports, which does not belong in a pure-motion commit.demod/rbds_worker.py(2293) anddemod/rbds_decoder.py(1238) are each a single class, so module-level splitting cannot shrink them further.RBDSWorker's 27 methods cover pilot estimation, interference notching, timing recovery, Costas carrier recovery and group decoding — five collaborators wearing one class. Extracting them means mixins or helper objects, which is behaviour-adjacent and belongs in its own reviewed commit. Tracked as Phase 2a-ii in the refactor plan.
[2.133.1] - 2026-08-06 - Audio Archives settings survive upgrades, and the page matches the rest of the UI
Fixed
- Archiving settings were wiped on every service start — i.e. every upgrade.
Per-source archiving settings live in
AudioSourceConfigDB.config_params["archive"], but that JSON column also holds the keys the radio→audio sync derives from eachRadioReceiverrow. Both sync paths —webapp/admin/audio_ingest.ensure_sdr_audio_monitor_sourceandeas_monitoring_service.sync_radio_receiver_audio_sources— built a fresh dict and assigned it wholesale, deleting thearchiveblock along with it. Because the startup sync runs on every restart, archiving silently reverted to off with default retention after each upgrade, andinitialize_archivers()then found nothing to start. Newapp_core/audio/source_config.pydefines which keys the sync owns (MANAGED_CONFIG_KEYS) and amerge_managed_config_params()helper that replaces those and preserves everything else; both call sites now merge instead of overwrite. Regression coverage intests/test_audio_archive_settings.pyandtests/test_radio_audio_monitoring.py. - The silence threshold was saved but never applied. The Audio Archives page
exposes a
silence_thresholdfield and persisted it, but neither runtime consumer passed it toAudioArchiverConfig— so dead-air segments were written to disk regardless of the setting. Both thearchiver_startRedis command handler andinitialize_archivers()now forward it. - Malformed stored settings could stop archiving from coming up. The save
endpoint wrote the request body through unchanged, while the runtime reads the
values back with
int()/float()at service start. A single unparseable value made archiver startup fail with only a log line. Settings are now normalised on write and on read: values are coerced and clamped to the same bounds the form enforces, unknown keys are dropped, and garbage falls back to the default rather than raising.
Changed
- The Audio Archives page now looks like the other admin pages. It moved
from a bespoke
container-fluidlayout to the standard.admin-container, the Refresh and Audio Streams buttons moved into the page header's action slot, per-source panels became Bootstrap tabs inside a collapsible card, and the summary bar became the same stat cards used elsewhere. Archiving state now renders with the shared.status-badgeinstead of a page-local badge, the page-local toast stack and its@keyframes spincopy were deleted in favour of the globalshowToast(), "Enable archiving" became a toggle switch as required for binary choices, and "Purge Old…" became a proper modal instead of awindow.prompt(). - Page JavaScript moved to
static/js/pages/audio_archives.js, bringing the template from 846 lines to 234. Cards are addressed by array index rather than by interpolating source names intoonclickattributes andCSS.escape()d element IDs, all user-supplied values run through the sharedescapeHtml(), and handlers are attached by delegation — so a source name containing a quote can no longer break the markup or the buttons. webapp/routes_audio_archive.py(928 lines) became thewebapp/audio_archive/package:fsutil.py(archive directory scanning and formatting),config.py(settings persistence and normalisation),metadata.py(ICY junk detection and VAST ad-URL resolution) androutes.py.
[2.133.0] - 2026-08-06 - Repository Statistics reads like the other dashboards
The page worked but looked like nothing else in the app. It now uses the same shared components as System Health and the GNSS dashboard, and languages carry their brand marks.
Added
- Language logos.
static/img/logos/gainedyaml.svg,shell.svg,sql.svg,xml.svg,text.svgandsystemd.svg, authored in the same 48×48 style as the existing marks, joining the Python/HTML/JS/CSS/Markdown/ JSON/SVG logos already there.LANGUAGE_LOGOSandLANGUAGE_GLYPHSinapp_utils/repo_stats/scanner.pymap each display language to its mark and a Font Awesome fallback; every language row carries both, so a missing file degrades to a glyph instead of a broken image. .ti-logois now a shared utility instyles.css. It was scoped to.traffic-dash-root; two pages render logo marks fromstatic/img/logos/now, so the page-scoped copy was removed.
Changed
- The page uses the shared dashboard idiom. Live
.status-pillchips ride in the page header (file source, route source, analysis time), the headline figures moved from.stats-grid/.stat-cardto the.status-strip/.status-tilereadouts that System Health and GNSS use, and the component inventory became a second strip. The page defines no tile CSS of its own.
Fixed
- Chart axis and tick labels were invisible on every dark theme. Charts were
built once, then theme changes only updated
Chart.defaults. Chart.js resolves per-scale options at construction and caches them, so tick and grid colours kept the theme that happened to be active on first paint — dark text on a dark surface after switching to Dark or Coffee. The page now rebuilds its charts on a theme change, which is the only approach that is correct in both directions: mutatingchart.optionsinstead builds a self-referential proxy that blows the stack. Regression tests pin down both failure modes.
[2.132.1] - 2026-08-06 - Stop the standard page header scrolling the page sideways
Fixed
- Every page using the standard
.page-headerscrolled horizontally on phones. Inside@media (max-width: 575.98px)the header carriedmargin-left/right: -0.125rem. The header is normally a direct child of the unpadded.page-shell, so that bleed made it ~3px wider than the viewport and pusheddocument.body.scrollWidthpastclientWidth— a violation of the project's own "no horizontal scrolling at ≥320px" rule, and easy to miss because the overflow is only a couple of pixels. The.page-header-gradientvariant never had the bleed, which is why whether a page overflowed depended on which variant it happened to use. Removed the two declarations; the two variants now agree.
Added
tests/test_mobile_overflow_css.py— a static guard (no browser needed) that fails if a full-width layout container grows a negative horizontal margin at a mobile breakpoint, or if the two page-header variants drift apart on mobile margins again.
Verified in Chromium across 7 pages × 6 viewports (320×568 through 1440×900): no page scrolls horizontally.
[2.132.0] - 2026-08-06 - Repository statistics that are actually about this repository
The Repo Stats page was a committed HTML blob regenerated by a script. It was seven weeks stale, painted with CSS variables that do not exist, and counted Bootstrap as our source code. It is now a normal page in the app, computed from the working tree it is describing.
Added
app_utils/repo_stats/— live repository analysis.scanner.pyfinds files (viagit ls-files, so.gitignoreis respected for free, falling back to a filtered walk) and sorts every one into exactly one bucket — first-partyproject,docs, or third-partyvendored.inventory.pycounts routes and structural components.__init__.pyassembles the snapshot behind a 10-minute TTL cache. Because it is derived on demand, it cannot drift./api/repo-statsreturns the same snapshot as JSON. It sits behind the deny-by-default API gate, so the page only offers the button to a signed-in user.- Repository Statistics is now in the navigation registry (Reports →
Analytics). It was previously reachable only from the
/docslisting.
Changed
- The page is a real template.
templates/repo_stats.htmlextendsbase.html, usescomponents/page_header.html, and reuses the existing.stats-grid/.stat-card/.eas-sectionutilities instead of shipping a parallel design. It gets the navbar, the footer and all 20 themes for free. - Route counting is now authoritative. Counts come from the running app's
url_map(548 routes) rather than a regex that matched only@app.routeand@bp.routeand therefore missed every named blueprint (it reported 239). - Vendored code no longer inflates the headline numbers.
static/vendor/,node_modules/and minified bundles are ~68% of tracked files and ~49% of tracked lines; they are now reported in their own clearly labelled row rather than folded into "lines of code". First-party code is 998 files / 263,928 lines, not 3,156 files / 550,959 lines. - Python line counting no longer miscounts one-line docstrings. The old
counter toggled block state on any line starting with
""", so"""Summary."""flipped the rest of the file into "comment" mode.
Fixed
- The page rendered invisible stat tiles in every theme. It painted with
--gradient-start,--gradient-endand--hover-color, none of which are defined anywhere instyles.css. The gradient collapsed to an invalid value and was dropped, leavingcolor: whitetext on the page background. A regression test now fails if the page references an undefined CSS variable.
Removed
scripts/generate_repo_stats.py,static/repo_stats.htmland.github/workflows/update-repo-stats.yml. The workflow only printed an Actions annotation when the committed file drifted, which is why it stayed stale for seven weeks. There is nothing left to regenerate.tests/test_repo_statistics.py::test_workflow_filefromtests/known_failures.txt. It asserted on a workflow shape that had been deliberately removed; the suite is rewritten against the analyser instead.
[2.131.0] - 2026-08-06 - One navigation registry, and a menu tree that makes sense
The navbar, the /settings hub and the /navigation site map were three
independently hand-maintained copies of the same menu, and they had drifted
apart. They now all render from a single declarative registry.
Added
webapp/navigation/— the navigation registry.registry.pyholds the whole menu tree as data (NavSection→NavGroup→NavItem);types.pyholds the node types and the permission-filtering logic. A context processor injects the already-filtered tree into every template asnav_sections,nav_section_mapandnav_user_menu. Adding a page is now oneNavItem— it appears in the navbar, the settings hub and the site map at once.docs/frontend/NAVIGATION.mddocumenting the registry, the node types, the permission model and the rendered-DOM contract that the Ctrl+K command palette depends on.tests/test_navigation_registry.py(20 tests): every link resolves to a real route, every permission name is a realPermissionDefinition, empty groups and sections are pruned, and no hardcodedhrefhas crept back into the three navigation templates.
Changed
- All tests and diagnostics are in one place. Test pages were spread across three dropdowns — Weekly Tests under Broadcast, Audio Tests and Alert Verification under Tools, SDR Diagnostics under Monitor — so checking whether the station was healthy meant opening three menus. They are now one Diagnostics section with Tests & Verification and System Health groups. A test asserts no other section carries a test page.
- The Tools junk drawer is gone. Its four unrelated groups (Observability, Analytics, Testing, Data Continuity) split into Diagnostics (tests + health) and Reports (logs, analytics, security, exports). The former top-level Logs menu folded into Reports as its Logs group.
- Settings is a direct link, not a two-item dropdown. It previously held
exactly one link (to
/settings) plus a button opening the Display Units modal. It now goes straight to the hub, which renders the registry's settings groups as cards. - Display Units moved to the user menu. Unit preferences are per-browser
personalization stored in
localStorage, not station configuration, so they belong next to Security Settings under your username rather than in Settings. - Top-level sections are now Dashboard · Monitor (what comes in) · Broadcast (what goes out) · Diagnostics (is it working) · Reports (what happened) · Settings · Help.
templates/components/navbar.htmlwent from 1268 lines to ~200. The scoped CSS and behaviour moved intonavbar_styles.htmlandnavbar_scripts.html, included at the end of it, bringing all three within the size guidance inAGENTS.md.templates/settings_hub.htmlandtemplates/site_navigation.htmlrender from the registry instead of hardcoded markup;webapp/routes_settings_hub.pyno longer computes permission flags, because the registry already has.AGENTS.mdnow directs agents to the registry — the "Changing the Navbar" and "Creating New Pages" sections previously told them to hand-edit the navbar template.
Fixed
- Dead permission gate. Part of the Tools menu was gated on
analytics_manage, which is not a member ofPermissionDefinitionand so always evaluated toFalse. The registry only accepts real permission names, and a test enforces it. - Site map / settings hub drift. The site map linked
/admin/userswhile the hub linked/admin/rbac— two different pages, each surface aware of only one. Both are now present and distinctly labelled (User Accounts and Roles & Permissions). - Pages reachable only from the site map. LED Sign, VFD Display and OLED
Screens existed on
/navigationbut appeared in no menu; they are now in Broadcast → Display Outputs. - Stale menu paths in the docs.
templates/help.htmlanddocs/guides/HARDWARE_QUICKSTART.mdpointed at menu locations that no longer existed (Tools → Analytics → Security Center,Tools → GPIO Control,Monitor → Radio Monitoring → Received Alerts).
[2.130.0] - 2026-08-05 - Uniform chrome on the monitoring pages
System Health and the GNSS & Time dashboard show the same kind of thing — a
header with live status pills, a heartbeat dot, and a strip of severity-tinted
metric tiles — but each had grown a private copy of that chrome under
health-* and gps-* prefixes, and the copies had drifted. The two pages now
share one implementation.
Changed
- The GNSS dashboard uses the standard page header. It was the last page
still hand-rolling its own (
.gps-dash-header), which is why it opened looking unlike the rest of the UI — AGENTS.md requires the sharedcomponents/page_header.htmland ~59 other templates already use it. Every control it carried is preserved, moved into the header's actions slot: the lock/fix/stratum pills, the heartbeat dot, the Simple/Engineering toggle, the refresh-interval selector, Units and Settings. The header is also hoisted out of the page'scontainer-fluid, matching System Health — nested inside it, the container's gutters inset the header so the two pages' headers did not line up. - Status strip, pills and heartbeat dot are now one shared component
(
.status-strip/.status-tile/.status-pill/.live-dot) defined instatic/css/styles.cssand consumed by both pages, replacing roughly 190 lines of duplicated per-page CSS. Element IDs are unchanged, so the page JavaScript — which resolves tiles and pills by ID — is unaffected. - The GNSS page's view toggle and refresh selector were restyled for the
header gradient. They previously used page-surface tokens (
--text-muted,--border-color) that are not legible as white-on-gradient, and the active toggle chip's blue/purple gradient muddied the theme gradient underneath it.
Fixed
- Drift between the two copies. The strips disagreed on their background
token — System Health used
--surface-color, the GNSS page used--bg-secondary— so one strip could paint differently from the other under the same theme. The pills also used two different greens for "OK" (#6ee07avs#3fb950) and two different idle colours for the heartbeat dot. The shared component still keeps two palettes deliberately, because tiles sit on the page surface and pills sit on the header gradient, but each is now defined exactly once.
Added
tests/test_monitoring_pages_uniform_chrome.py— 39 tests pinning the shared chrome: both pages must include the header component and must not hand-roll one, both must use the shared strip/pill/dot classes, none of the ten retired page-private class names may return, and neither page may redefine a shared class locally. Verified to fail (23 of 39) against the pre-change templates.
Verification
Rendered both pages in Chromium at 1440px, 360px and 320px. The two headers
are now geometrically identical (x=0, width=1440, height=213 at 1440px) and the
status strips align exactly (x=14, width=1412); the GNSS strip stays taller by
design, because its Sync and PPS Lock tiles keep their emphasis via the shared
.status-tile.is-primary modifier. No horizontal document scrolling at any of
the three widths on either page. All 20 themes still pass the contrast audit.
[2.129.1] - 2026-08-05 - System Health and GNSS dashboard fixes
A bug-fix pass over the two monitoring pages. Several of these are cases where one page already solved a problem correctly and the other hadn't; in each case the better-behaved page set the pattern.
Fixed
formatUptimewas declared twice insystem_health.html. Both declarations sat at brace depth 0 in the same<script>, so the later one silently won and the earlier 24 lines were dead code. The surviving version dropped minutes for any uptime over a day, while the server's first paint uses Python'sformat_uptime()— so a machine up for1d 10h 17mrendered that on load and then reformatted itself to1d 10hon the first poll. There is now one definition, and it matches the Python formatter.- System Health never refreshed after a hidden tab regained focus.
Polling correctly pauses on
document.hidden, but nothing resumed it on return, so a backgrounded tab showed arbitrarily stale readings with no cue that they were stale. Added thevisibilitychangehandler the GNSS dashboard already had. - The GNSS dashboard's trends timer ignored
document.hidden. Its siblingfetchOnce()has guarded on it since the guard was added; the trends re-fetch kept hitting the Redis-backed trends endpoint in background tabs regardless. Guarded, and paired with a refocus re-seed so the charts don't sit on a stale right edge. - A failing GNSS telemetry feed was reported only to the console.
Every card would freeze on its last good values with nothing but a
no-longer-pulsing heartbeat dot to indicate the readings were no longer
live — too subtle for a page used to confirm timing integrity. Failures
now raise a banner stating the values are last-known, not live.
Non-2xx responses are treated as failures rather than being parsed as a
valid snapshot. The hardware-service outage path needed handling of its
own:
gps_dashboard_data()answers HTTP 200 carryinggps._errorwhen the hardware service is unreachable — deliberately, so the chrony half of the dashboard keeps rendering — so treating every 200 as success would have hidden exactly the outage the banner exists for. That envelope now keeps the banner up, with a message noting that timing data is still live while satellite and fix values are not.
Accessibility
- All 15
<canvas>elements on System Health now carry an accessible name (role="img"plusaria-label); previously none did, against 22 of 23 on the GNSS dashboard.role="img"is included because canvas has no implicit ARIA role, and anaria-labelon a roleless element can be ignored outright.
Added
- A "Full GNSS Dashboard" link on the System Health GPS card. The only route to the detailed dashboard was the navigation menu. Added to both the server-rendered and JS-rendered copies of the card so it survives a refresh.
tests/test_system_health_gps_ui_fixes.py— 13 regression tests, each verified to fail against the pre-fix templates. Includes a check that executes the template'sformatUptimeunder Node and compares its output againstformat_uptime()across a range of durations, so the two formatters cannot drift apart again.
[2.129.0] - 2026-08-05 - Faster VU meters for less CPU
The VU meters were throttled to ~15 fps specifically to keep CPU down. Rather than trade responsiveness against load, this reworks what each update costs so both improve: the refresh rate doubles to ~30 fps while the per-frame work drops.
Changed
- One shared
AudioContextinstead of one per source. EveryAudioContextcarries its own audio render thread, so a context per player multiplied that fixed cost by the number of players on the page. - Time-domain sampling instead of an FFT.
getByteFrequencyData()runs a transform on every call and returns spectral magnitudes — which are not signal amplitude, so the previous peak/RMS figures were measuring the wrong quantity and paying for a transform to do it.getByteTimeDomainData()copies the sample buffer: cheaper, and the correct input for a level meter. - Frame-rate-independent ballistics. Attack and release are now time constants applied against the real elapsed interval. The previous fixed per-frame coefficients meant the decay rate changed with the frame rate: measured over the same half-second of silence, the old release landed anywhere between roughly −80 dB and the −120 dB floor across 10–144 fps. That coupling is what made raising the rate risky; the new curve holds to within 3 dB across the same range. The elapsed interval is clamped so the first frame after a hidden tab resumes cannot apply a multi-second decay in one step.
- DOM writes only on change. Bar widths are quantised to 0.1% and written only when they differ; the dB text labels refresh at ~8 fps rather than with every frame. Style and text writes trigger layout and cost far more than the sampling.
- Paused, fully decayed sources are skipped entirely rather than being re-analysed and re-rendered with identical values every frame.
- The loop remains
requestAnimationFrame— display-capped, and suspended outright while the tab is hidden. No timer-driven spin was introduced.
Fixed
- Meters for replaced audio elements ran forever. The source list
re-renders on a timer and rebuilds its
<audio>elements, but the old meters stayed in the map — analysed on every frame, against elements no longer in the document, each holding its ownAudioContextopen. Detached meters are now pruned and their nodes released. - Duplicate listeners accumulated on every re-render.
enableRealtimeVUMeters()is called after each render and re-registered itsplaying/endedhandlers each time; they are now bound once per element.
Tooling
- New
tests/test_realtime_vu_meters.pypins the static guarantees (no FFT call, single context, rAF-driven, pruning and bind guards present, write-elision caches intact) and executes the module under node to verify decay is frame-rate independent, the fill/label helpers hold at their bounds, and the public API the page calls is preserved. The node-backed cases skip cleanly if node is unavailable.
[2.128.7] - 2026-08-05 - Audio monitor: honest labels, visible stream failures
Removed
- The per-source player's MP3 claims, which were wrong in both directions.
/api/audio/stream/<source>respondsmimetype='audio/wav'and writes a RIFF header — uncompressed PCM at the source's native rate — while the UI advertised "MP3 Streaming @ 128 kbps" and "Audio compressed to MP3 format (10x more efficient than WAV)". Replaced with an accurate one-line note.
Fixed
- A decoder feed that died mid-listen reported nothing.
onPlaying()removed both theplayinganderrorlisteners, so once playback started nothing was watching. If the audio service restarted or the source stopped, the player went silent while the button still read "Stop" and no message appeared — the feed just seemed to stop working. Playback failures are now watched for the life of the stream, reset the button, and report why.stalledis deliberately not treated as terminal: browsers fire it after a few seconds without data, which a live feed hits on ordinary jitter, so reacting to it would tear down healthy streams. Onlyerrorandendedare terminal — a live stream has no natural end, soendedmeans the server closed the connection. - Stopping deliberately no longer raises a "stream ended" alert.
removeAttribute('src')+load()fireerror/endedduring teardown, which would trip the new watcher; the handler is detached first. - A stream that never produced a frame hung the button on "Loading…" forever. Added a 20s startup watchdog that diagnoses and resets. Every path that concludes the start attempt cancels it.
- A missing ffmpeg was reported as an audio-source problem. The decoder
stream encodes 16 kHz PCM to MP3 via ffmpeg inside the response generator.
By the time the generator runs,
Response()has already sent the headers, so a missing ffmpeg simply ended the generator and the browser received "200 OK" with an empty body — a generic media error that the UI attributed to the audio source, the one thing that was not wrong. ffmpeg is now pre-flighted in the request phase, where a status code can still be chosen, and returns a 503 naming the real cause.
Tooling
- New
tests/test_audio_monitor_decoder_feed.pypins the stream labelling against the endpoint's actual mimetype, the client-side failure handling (post-start watcher, nostalledteardown, watchdog cancellation on all three paths, detach-before-teardown ordering), and the ffmpeg pre-flight's placement ahead of the streaming generator.
[2.128.6] - 2026-08-05 - Test surfaces that report a real verdict
Audit of the test and diagnostic suites exposed in the web UI, checking that each one's verdict actually reflects what it measured. Two did not — in opposite directions.
Fixed
- The Services diagnostic failed every unit it could not query.
check_services_running()runssystemctl is-activeper unit. When systemd is unreachable, each probe returns an empty state, which the per-unit branch reported as a hard failure — so the Diagnostics page showed all ten services red regardless of whether they were running, burying any real failure among false ones. It now probes reachability once withsystemctl is-system-runningand reports a single informational line instead. Checking only that thesystemctlbinary exists is not sufficient: the binary is present in images where the bus is not, and there it exits non-zero on every query — which is exactly the situation this bug appeared in. - The alert self-test could not fail.
run_alert_self_testcomputeddecode_error_count, displayed it in its own tile, and then ignored it when settingsuccess. Unless the caller opted intorequire_match, the verdict wasTrueon every run — a test where every sample failed to decode still rendered a green PASS. A decode error means the SAME header could not be recovered from the audio, which is the precise failure this self-test exists to surface. Decode errors and an empty result set now fail the run;FILTEREDandSUPPRESSED_DUPLICATEcontinue to pass, since those are correct outcomes rather than faults.
Changed
- Raised the audio pipeline suite's run budget from 180s to 240s and made the
timeout message actionable. The route runs pytest synchronously in the
gunicorn worker, which systemd starts with
--timeout 300, so the ceiling has to stay below that — overrunning gunicorn kills the worker and returns a 502 with no results, which is worse than a reported timeout. The suite takes ~35s on a fast x86 host, but on the Raspberry Pi deployment target 180s was close enough to the real runtime for a full run to time out and report FAILED regardless of how the tests did.
Verified (no change needed)
- The audio pipeline suite genuinely runs pytest and parses its JUnit XML: 189 tests across 21 files, correct per-module attribution, working single-module runs, and path-traversal input rejection.
- The remaining diagnostic checks are honest — Audio Service, Audio Devices and Icecast all skip with an explicit informational note when their feature is disabled, rather than reporting a failure.
Tooling
- New
tests/test_diagnostics_service_check.pycovers the unreachable-bus, missing-binary, healthy-host, uninstalled-unit anddegradedcases. Thedegradedcase matters specifically:is-system-runningexits non-zero for it, so keying the probe off the exit code would skip per-unit checks on exactly the hosts that have a failed unit worth reporting. - Extended
tests/test_alert_self_test_routes.pywith the decode-error, all-forwarded, filtered/duplicate,require_matchand empty-result verdicts.
[2.128.5] - 2026-08-05 - Restore two panels that rendered into nothing
Both panels shipped as scripts that were written defensively and therefore
failed silently: each wrote into a container guarded by if (element), and no
element carried the id. A guarded write to a missing element logs nothing and
shows nothing — the feature is simply absent.
Fixed
- System Health "Service Details" never appeared. The refresh script built
the full per-service card with
renderSystemdDetails()and assigned it togetElementById('systemd-details').innerHTML, but no element declared that id, so the card was rendered and discarded on every refresh cycle. Added the container as a full-width row under Resource Usage, with a server-rendered first paint (matching the siblingsmart-card/dependencies-cardpattern) that the refresh then replaces. Covers all three states the script handles: populated, systemd unavailable (shows the reported reason), and available-but-empty. - The radio receiver's "Service Configuration" summary never appeared.
showConfigSummary()runs whenever a service type is picked and populates six fields behind anif (summary)guard; none of the seven ids (configSummary,summaryModulation,summarySampleRate,summaryBandwidth,summaryAudio,summaryStereo,summaryRBDS) existed. Added the panel to the Add/Edit Receiver modal, hidden until a service type is selected since the script is what reveals it. Sample rate is read from the hardware-populated dropdown rather than the service config, which deliberately omits it.
Tooling
- New
tests/test_health_and_radio_panels.py: asserts every id the two scripts address exists, that the radio panel starts hidden, that nogetElementById()target inadmin/radio.htmlis unbacked, and renders the health page across all three systemd states to check row content, status badge mapping, category labels and thetable-responsivewrapper.
[2.128.4] - 2026-08-05 - Broken URL targets and a shadowed module
A sweep for URL targets that do not resolve — every literal url_for() and
every literal fetch() in the UI, checked against the real URL map.
Fixed
- Role denials returned 500 instead of a redirect.
_role_denied_responseinapp_core/auth/decorators.pycalledurl_for("admin"), but the admin dashboard is registered on thedashboardblueprint. Every HTML (non-JSON) request denied for insufficient role raisedBuildError— the "You do not have permission" flash was never shown. - The per-boundary Delete button had no endpoint. The boundary-management
UI shipped a Delete action with a confirmation modal calling
DELETE /admin/delete_boundary/<id>, which no route answered. Only bulk "clear by type" and "clear all" existed. Addedboundaries.delete_boundary, permission-gated like its siblings and audit-logged toSystemLog. - The audio detail page's Delete button 404'd. It called
/admin/eas_messages/<id>; that route moved to/eas/messages/<id>. The 404 body is HTML, soresponse.json()threw and the page reported a generic "Failed to delete audio message". - The storm-track map legend never rendered.
alert_detail.htmlcalledgetElementById('map-legend')against a div carryingclass="map-legend"and noid, so the "Storm Line (N cells)" legend entry was silently dropped.
Removed
webapp/admin/audio.py(1276 lines) was dead code. It sat next to thewebapp/admin/audio/package, and Python resolves the package — so the module was never imported. Its blueprint was never registered, and its threeurl_for()calls referenced endpoints that no longer exist. A prior security commit ("require authorization on 80 unprotected mutating routes") had applied decorators to this file, giving a false impression that those routes were hardened. The live equivalents inwebapp/eas/workflow.pyandwebapp/eas/messages.pywere verified to carry@require_permission.- Dead
url_for("public_index") if False else "/"branch inglobal_search.
Tooling
- New
tests/test_url_targets_resolve.pyreplaces brittle hardcoded-string guards with three generic checks that would have caught all of the above: everyurl_for()literal resolves to a registered endpoint (suggesting the blueprint-qualified name when one exists); every literalfetch()path in templates andstatic/jsmatches a route that accepts its method; and no module is shadowed by a same-named package.
[2.128.3] - 2026-08-05 - Readable status text and links in every theme
The theme contrast audit added in 2.126.0 probed two strict surfaces (table headers) and reported a clean run — while the most common text utilities in the codebase were failing WCAG AA in most themes. The audit simply never looked at them.
Root cause: --success-color / --danger-color / --warning-color /
--info-color / --primary-color are tuned as fills — they sit behind
white text in badges, buttons and .bg-* utilities, so they are deliberately
vivid. .text-* and a reused those same vivid values as text on a card,
where they are far too light.
Fixed
.text-warningwas illegible on cards in all 11 light themes —#ffa726on white measures 1.94:1 against a 4.5:1 floor. Alongside it.text-infomeasured 2.30:1,.text-success2.78:1 and.text-danger3.49:1. These utilities appear ~1700 times acrosstemplates/.- Every hyperlink failed AA in 11 of 20 themes.
a { color: var(--primary-color) }put the brand colour into body copy: Yellow measured 1.97:1, Slate 1.98:1, Orange 2.70:1, Charcoal 2.77:1, Sunset 2.79:1, Midnight 2.89:1, plus Green, Spring, Tide, Nebula and Obsidian. - Muted text in the default Cosmo theme measured 2.56:1 (
#94a3b8on white), affecting every out-of-the-box install. Spring (4.10:1), Slate (4.18:1) and Nebula (4.47:1) were also short. - Dark theme's
--text-secondarymeasured 4.23:1 on its own surface. - Coffee's page header title measured 1.91:1 — white ink on a pale tan
#e2b37f → #c7a06egradient. This is the third instance of the bug already fixed for Lightning and Yellow, and it gets the same dark-ink treatment. - Aurora, Orange, Sunset and Dark page header titles sat under the 3.0 large-text floor (2.36–2.81:1). Their gradients are deepened ~26% so the hue and the white ink both survive.
Changed
- Added a semantic ink layer: each theme now carries
--success-ink,--danger-ink,--warning-ink,--info-inkand--primary-ink— the same hue as the fill, lightness shifted until it clears 4.5:1 against that theme's own--surface-color..text-*andause the ink;.bg-*, badges and buttons keep using the vivid fill. Light themes share one ink set (Bootstrap's text-emphasis palette, which the existing.bg-lightoverrides had already standardised on); dark themes map ink back to the fill except where measurement said otherwise. .eas-hero-leadis now full-opacity white with a stronger shadow.
Tooling
scripts/diagnostics/check_theme_contrast.pygained 9 strict probes for the flat card surfaces it never covered:.text-primary,.text-success,.text-danger,.text-warning,.text-info,.text-muted,.text-secondary, a link in card copy, and body text on the page. Strict coverage goes from 2 surfaces to 11, all 20 themes passing; gradient advisories drop from 34 to 27.
[2.128.2] - 2026-08-04 - Stop list views from loading audio blobs
Follow-up sweep after the FCC reports 502 (2.128.1). The same defect — loading whole ORM rows when only small columns are rendered — was present across the rest of the logs and audio pages. Several were worse than the bug originally reported.
Fixed
/logsdetail tabs loaded every audio blob to compute a checkbox. The EAS Messages, Decoded Audio, Manual Activations and Received Alerts tabs each loaded whole rows, in most cases only to evaluatex_audio_data is not None. Measured on 100 seeded rows carrying 1 MB captures (limitaccepts up to 500):| Tab | Before | After | |---|---|---| | Decoded Audio (7 blobs/row) | 12.5 s, 2119 MiB | 0.02 s, ~0 MiB | | Manual Activations (10 blobs/row, none read) | 12.8 s, 2118 MiB | 0.02 s, ~0 MiB | | EAS Messages (6 blobs/row) | 10.5 s, 1818 MiB | 0.02 s, ~0 MiB | | Received Alerts | 0.7 s, 305 MiB | 0.02 s, ~0 MiB | | All (default tab) | 1.8 s, 231 MiB | 0.09 s, ~0 MiB |
The blob-presence booleans are now SQL
IS NOT NULLtests./audio(audio history) was the worst case: 19.7 s and 2715 MiB. Three separate causes, all fixed: wholeEASMessagerows (six blobs) joined to wholeCAPAlertrows (raw_jsonplus PostGISgeom); wholeManualEASActivationrows (ten blobs); and a lazymessage.source_alertsrelationship that fired one query per row, each loading aReceivedEASAlertwith its captured WAV. Now 0.23 s and 2 MiB. The page's fetch window isoffset + per_page, so the old cost grew with every page turned./audio/receivedlisting deferredraw_audio_dataandfull_alert_data; only the detail view renders them./api/logs/recent— polled every 10 s by the live log viewer — no longer loads the sixEASMessageaudio columns on every poll.Bulk exports loaded geometry they never wrote.
/export/alerts,/export/alerts/cap.xml,/export/alerts/csv(limits of 50,000, 10,000 and 50,000 rows) and/export/boundaries(20,000) render only small scalar columns, but each loaded every alert's PostGISgeom,raw_json,descriptionandinstruction— or, for boundaries, the geometry itself. All four now defer those columns; output is unchanged.The active-alert WebSocket push (
alerts_update, every 5 s) loaded 50 alerts' geometry and raw CAP payloads to send eight small fields — a standing background load. Now deferred.Expired-alert sweeps (
cleanup_expired()plus the two admin maintenance endpoints) loaded every expiring alert's geometry and raw CAP payload only to setstatus/updated_at, or to hand the row todb.session.delete(). Now deferred, which keeps the per-object ORM delete and its cascade behaviour exactly as they were.Smaller listings — the admin dashboard's recent-messages panel, the EAS workflow page, and global search — deferred their
EASMessageaudio columns too.EAS message purge loaded every blob it was about to delete. Purging by "older than N days" read all six audio columns of every doomed row. Now consolidated in
purge_eas_messages(), which selects only the on-disk filenames, clears the dependentreceived_eas_alerts.generated_message_idreference the previous per-object delete relied on SQLAlchemy's cascade for, and deletes in batches of 1000 to stay under Postgres' bound-parameter limit. Measured on 150 messages: 0 MiB, down from ~900 MB.Manual activation purge had the same shape — it reads only
idandstorage_pathbut loaded all ten audio columns of every doomed row. The blobs are now deferred, keeping the per-objectdb.session.delete()and its fail-closed audit ordering exactly as they were. Measured on 120 activations: 0 MiB, down from ~720 MB.
Added
EASMessage.summary_query()/summary_to_dict(), plusEASMessage.without_audio()andManualEASActivation.without_audio()— shared helpers so list views have an obvious correct path.summary_to_dict()returns exactly the same payload asto_dict();without_audio()is for views that need whole ORM instances but no audio.tests/test_blob_free_list_views.py— pins the query shapes and asserts the blob-column lists stay complete, so a newly added audio column cannot silently stop being deferred.
Notes
- Every changed endpoint was diffed before and after against seeded data:
all 13 responses are byte-identical apart from the CSRF token and
/api/logs/recent's own generation timestamp. webapp/admin/audio.pyis dead code — thewebapp/admin/audio/package shadows it, so the module is never imported. It is a stale copy of the livehistory.py/received.pyroutes and was left untouched; it should be removed or renamed per the_oldconvention in a separate change.
[2.128.1] - 2026-08-04 - Fix gateway error on the FCC reports page
Fixed
FCC Reports returned a 502 gateway error. Reports → FCC Reports (
/logs?type=report_received) and its sibling report tabs failed on any station with real traffic. The report builders loaded whole ORM rows, so printing a table of timestamps and event codes also pulled every heavy column attached to those rows:| Report | Columns loaded but never printed | |---|---| | Received / Forwarded / Ignored |
received_eas_alerts.raw_audio_data(the WAV capture, ~1 MB per alert) andfull_alert_data(JSONB) | | Initiated | sixeas_messagesaudio blobs, pluscap_alerts.raw_jsonand the PostGISgeomvia an eager join |Measured on 200 seeded alerts carrying 1 MB captures, the received report used 614 MiB of resident memory and 4.7 s; the initiated report used 736 MiB and 6.8 s. A month of real traffic pushed the gunicorn worker past its memory ceiling, and the OOM kill surfaced to the browser as a bad gateway rather than an application error.
Both builders now select only the columns they print. The same 200 alerts now cost 1 MiB and 0.02 s — a 600× reduction in memory. The weekly and monthly summaries were already column-scoped and are unchanged.
Report queries are now bounded. The export routes accept arbitrary
start/endbounds, so a multi-year window could build an unbounded row list. Reports cap at 25,000 rows (REPORT_MAX_ROWS) and note the truncation in their summary block.
Added
tests/test_fcc_report_queries.py— asserts the report queries never select a blob, large JSON, or geometry column, that theCAPAlertjoin and theforwarding_decisionfilter survive the rewrite, and that the row cap is applied.
[2.128.0] - 2026-08-04 - Authentication audit: public docs, private diagnostics
Audited every registered route against the deny-by-default gate in
app.by:before_request. Two problems in opposite directions; see
Public vs. Authenticated Routes for the full
inventory.
Changed
Documentation and licence pages no longer require a login.
/attribution(the AGPL-3.0 and third-party licence disclosures) and/style-guide(the UI component reference, linked from the developer docs) were behind the gate. Neither carries station data, and putting licence notices behind a password defeats their purpose. The/docstree,/about,/help,/terms,/privacy,/supportand/versionwere already public and stay that way.Machine-describing diagnostics are no longer readable from the internet. These GET endpoints answered any anonymous caller:
| Endpoint | Exposed | |---|---| |
/api/smart_diag| Rawsmartctloutput — drive models, serial numbers, firmware, temperatures, power-on hours, error logs, pluslsblktopology | |/api/system_status| Hostname, primary IP address, CPU/memory/disk utilisation, uptime | |/api/system_health| Service and dependency health detail | |/api/monitoring/radio,/api/eas-monitor/status| Receiver and decoder state | |/api/audio/metrics,/api/audio/metrics/latest,/api/audio/health,/api/audio/sources| Audio hardware and source configuration |They now sit in a new
LOCAL_API_GET_PATHStier: still reachable without a session, but only from loopback or a private network. The reason they cannot simply be gated isscripts/screen_renderer.ScreenRenderer, which the displays subsystem runs againsthttp://localhost:5000with no credentials to populate OLED/LED/VFD screens — that path is unaffected. A signed-in operator still reaches all of them from anywhere.request.remote_addris the real client IP (ProxyFix, one trusted hop), so a remote caller cannot claim to be local by sending its ownX-Forwarded-For./api/alerts,/api/alerts/historical,/api/boundaries,/api/broadcast/state,/api/health,/api/release-manifestand/api/traffic/clientremain fully public — alert content is the point of the station, and the rest are small non-sensitive signals the public pages poll.
Removed
- The public landing page fetched
/api/system_statuson first load and on every refresh and did nothing with the result butconsole.logit. Each call sampled CPU, measured disk usage and ran database queries. Removed, along with the endpoint's entry in thewindow.mapDebug.testAPIsconsole helper.
Added
docs/security/PUBLIC_ROUTES.md— the route inventory, the rule for deciding which tier a new route belongs in, and how to re-run the audit.tests/test_public_route_audit.py— asserts documentation stays public (including every route under/docs), that machine-describing endpoints stay out of the internet-public set while remaining reachable for the screen renderer, and that the local-network check fails closed on a missing or malformed client address.
[2.127.0] - 2026-08-04 - Weekly RWT scheduling, Security Center speed-up, OTP autofill
Changed
The RWT now fires once per week, on one of the selected days — not on every selected day. The scheduler treated the day list as "broadcast on each of these days", so a Sunday + Tuesday schedule sent two tests a week. The days are now the days the test is allowed to land on:
weekly_fire_slot()picks exactly one of them per ISO week, at a random minute inside the configured window. Varying the day and time is also what 47 CFR §11.61(a)(2) asks of a Required Weekly Test — ticking more days makes it less predictable rather than more frequent.The choice is seeded from (schedule id, ISO year, ISO week), so it is stable for the whole week, identical in every Gunicorn worker without coordination, and different each week. The "already sent" check and the cross-worker Redis lock are now keyed on the ISO week rather than the calendar date. A slot missed because the station was down is caught up on a later allowed day in the same week, inside its window, rather than losing the week. The RWT Schedule page explains the behaviour and the day picker is relabelled "Allowed Days".
The traffic dashboard's 60-second auto-refresh only runs while it is actually on screen. One refresh runs ~35 aggregations over
web_request_log, and the old unconditional interval kept paying that on a backgrounded browser tab or while the operator was reading a different Security Center tab — the largest recurring CPU cost the web process carried. Becoming visible again reloads immediately if the data went stale. The dashboard cache TTL moved from 30s to 55s so it sits under the refresh interval and concurrent viewers (or the second Gunicorn worker) share one computed payload instead of each recomputing.
Fixed
/security/centertook a very long time to open. Two causes:GET /admin/fail2ban/statusranimport_ssh_bans()andheal_firewall_bans()inside the request. The latter re-pushes the whole ban list into the firewall with onesudo fail2ban-client set … banipsubprocess per banned IP, so after any fail2ban restart (which flushes the jails) the first page load paid for hundreds of privileged subprocess round trips before it could render. Both functions have run on a 60-second background schedule inapp_core.fail2ban_syncsince it was added; the route is now read-only and its result is cached for 10s, with the cache invalidated by every mutating action.- The page fetched all four tabs' data on load. Each tab now loads the first time it is shown, so opening the default Traffic tab no longer pays for the fail2ban status call, the malicious-attempts query and the ban list.
Automated RWTs could leave the transmitter unkeyed even with the GPIO service healthy.
GPIOBehaviorManager.start_alert()counted aFIVE_SECONDSpulse as having "handled" the broadcast, which suppresses the subprocess's fallback of keying every configured pin. On a station whose relay is assigned Forwarding Alert (held only for forwarded alerts) alongside a beacon pulse, an automated RWT therefore held nothing, blinked the beacon, reported handled, and never keyed the air chain. A five-second pulse cannot carry a broadcast that runs for minutes, so only holds and flashes count as handled now; a broadcast the matrix holds nothing for also logs a warning naming the fix.GPIO behaviour-matrix warnings (e.g. "no pin is assigned a transmit-capable behavior — the transmitter will NOT be keyed") were only written to the GPIO subprocess's journal at startup, where no operator sees them. They now appear on the GPIO Control page alongside the existing environment issues.
Added
- The MFA code field is now autofillable. It carried
autocomplete="off", which explicitly opts out of the one-time-code suggestion iOS/macOS Passwords, 1Password, Bitwarden and Chrome offer; it is nowautocomplete="one-time-code"withinputmode="numeric". Auto-submit moved fromkeyuptoinput/change, because a password manager sets the value programmatically and fires neither key event — an autofilled code used to sit in the box waiting for a manual tap on Verify. (Storing the TOTP secret in the password manager is still a one-time manual step: copy the setup key shown during MFA enrolment into the saved entry.)
[2.126.2] - 2026-08-04 - MFA login delays and GPIO relay keying
Fixed
MFA login rejected valid codes for 90 seconds after every sign-in.
verify_user_mfa()guarded against code reuse by comparing wall-clock time since the last successful verification: any code accepted within 90 s of the previous login was logged as a "TOTP code reuse attempt" and rejected. That check cannot distinguish a replayed code from the brand-new one the authenticator has already rotated to, so it rejected both — operators had to wait out several 30-second rotations before a second login would go through.Replay prevention now keys off the RFC 6238 time step the submitted code belongs to.
MFAManager.verify_totp_with_counter()reports which step matched (searching the same ±1 skew window as before, with a constant-time comparison), and a code is rejected only when its step is not newer than the last one that user spent. A replayed code still fails; the next rotated code is accepted immediately. Codes pasted with the space authenticators display ("123 456") now verify instead of failing.New nullable
admin_users.mfa_last_totp_countercolumn (migration20260804_mfa_totp_counter) records the spent step. Rows predating it fall back to deriving the step frommfa_last_totp_at, so upgraded installs get the fix without re-enrolling.GPIO relays never fired for automated RWTs or forwarded alerts. Two independent causes, both of which had to be fixed:
The pin-owning service could not start.
eas-station-gpiois the only process permitted to claim the relay lines (lgpio claims are exclusive per process), and it was capped atMemoryMax=128M— a hard cgroup limit — while needing roughly 240 MB just to import. Importingapp_core.modelstransitively pulls in the DSP stack (app_utils.eas_decode→eas_demod→ numba → scipy and numpy) before the service does any work. The kernel OOM-killed it during startup andRestart=alwaysturned that into a silent crash loop, so no automated relay action was possible at all. Raised toMemoryMax=384Mon all six affected units (gpio,displays,gps,network,zigbee,endec-feeds) — every one of them imports the model layer and every one was capped below its own startup footprint.tests/test_systemd_memory_limits.pyfails if a ceiling drops back below the floor.Forwarded alerts released the air-chain marker instantly.
EASBroadcaster.handle_alert()— the path every auto-forwarded CAP alert and OTA relay takes — set theeas:broadcast_activemarker, called the audio player, and cleared it. On any station without a localEAS_AUDIO_PLAYER(Icecast-only or external-ENDEC installs) that call returns in microseconds while the encoder is still working through the queued SAME burst, so the marker existed for under a millisecond. The GPIO subprocess keys the relay off that marker's rising edge and samples it at 1 Hz, so the edge was never observed.handle_alert()now holds the marker for the full composite duration (bounded bymax_activation_seconds), matching what the manual send path and the RWT scheduler already did.
Changed
- The GPIO subsystem's active-alert count is cached for 5 seconds. It was
issuing a database
COUNTon every indicator refresh — once a second plus once per pub/sub notification — all serialised behind the same lock that keys the relay, so a slow query delayed relay keying.
Removed
tests/known_failures.txtno longer liststest_mfa_totp_reuse_prevention. Those seven tests were not failing for the recorded reason ("needs a real database"); every one errored during fixture setup because patching thecurrent_appLocalProxy resolves it outside an application context. The fixture now passes an explicit replacement and the suite runs clean.
[2.126.1] - 2026-08-04 - SessionStart hook for Claude Code on the web
Added
.claude/hooks/session-start.sh— provisions a remote session end to end in roughly 45 seconds: installs PostGIS, Redis, ffmpeg and libsndfile; installsrequirements.txtplus pytest/ruff/playwright; creates and starts a PostgreSQL cluster with the PostGIS extension; builds and stamps the schema; starts Redis; and exportsDATABASE_URL,SECRET_KEY,REDIS_HOST,REDIS_PORT,LOG_LEVELandCHROMIUM_BINfor the session.Schema bootstrap deliberately mirrors
install.sh:db.create_all()thenalembic stamp head. A barealembic upgrade headcannot build an empty database, because no migration inapp_core/migrations/versions/creates the base tables — the first migration referencingcap_alertsfails with UndefinedTable. Stamping records the head revision so a migration written during a session applies cleanly withalembic upgrade head; on later runs the hook takes that upgrade path automatically.The hook is idempotent, non-interactive, and gated on
CLAUDE_CODE_REMOTEso it never modifies a developer's own machine. Optional packages are installed under a boundedtimeout, and if PostgreSQL cannot be provisioned the hook declines to exportDATABASE_URLsoconftest.pyfalls back to in-memory SQLite rather than the session failing outright.
Fixed
app_core/migrations/env.pysetSKIP_DB_INITinside_get_configured_url(), which runs after the module-levelfrom app import create_app. Importingappis what reads the flag to decide whether to start the background workers (RWT scheduler, retention, auto-purge, metrics sampler), so the guard never took effect: everyalembicinvocation started those workers, and they queried tables mid-migration and buried the real output in UndefinedTable tracebacks. The assignment now happens before the import.
[2.126.0] - 2026-08-04 - Legible page titles, navbar layout, alert visibility
Found by rendering the running application in a headless browser rather than by reading the code — every item below was verified on screen.
Fixed
- Every page title in the application was close to illegible.
static/js/visual-effects.jsapplied its.gradient-textclass to everyh1and.page-title. That class fills the glyphs withlinear-gradient(--primary-color → --secondary-color)and sets-webkit-text-fill-color: transparent— and the standard page header's background is built from those same two variables, so each title was painted in the colours of the surface directly behind it. Headings on coloured header/hero/navbar surfaces are now excluded; headings on plain backgrounds keep the effect. - The "Map Layers" and map-legend panel titles on the dashboard were white
text on a white surface.
styles.csssets a global.card-header { color: white }for the gradient-filled card headers used elsewhere; these two headers are deliberately transparent over the card's light body, so they inherited white and vanished. - The Dashboard navbar item wrapped onto two lines below ~1440px,
rendering 61px tall against its siblings' 37px and colliding with the
active-state pill. Bootstrap ships
.nav-link { display: block }and nothing overrode it, so the icon and label were laid out as inline content. Only the Dashboard link was affected because every other top-level item is a.dropdown-toggle, which picked up flex layout elsewhere. - Alerts could disappear from the UI entirely.
get_active_alerts_query()andget_expired_alerts_query()were written independently and were not complements: active excludedstatus='Expired',status='Cancelled'and superseded alerts, while expired only checkedexpires < now. An alert in any of those three states with a future expiry matched neither query, so it vanished from the dashboard, the active count and the archive at the same time — a cancelled-but-not-yet-expired alert left no trace anywhere in the interface.get_expired_alerts_query()is now the exact complement of active, and<=closes the boundary case whereexpiresequals now. All callers are read-only counts, listings and exports; nothing purges from this query. Regression coverage intests/test_alert_active_expired_partition.py. - The VFD Display Control page was entirely non-functional. It injected
jQuery with
document.createElement('script')— async by default — and the next inline block called$('#textForm')at top level, throwing "$ is not defined" before jQuery arrived. Neither form submitted and no AJAX request fired. jQuery is now loaded with an ordinary blocking<script src>. README.mdreferenceddocs/screenshots/system-health.jpg, which did not exist — a broken image in the Screenshot Tour.
Changed
- Removed the "🔍 Ctrl K" search pill from the navbar by request. It occupied
a third row at common desktop widths; with it and the layout fix above the
navbar is a consistent 142px at every width tested (was 212px). The command
palette is unchanged and still opens on Ctrl/Cmd+K or via
window.EASCommandPalette.open(). - Refreshed the README screenshot tour from the running application and added a Broadcast Builder panel.
Added
- The CI test job now provisions a PostGIS service container and exports
DATABASE_URL, so tests needing a real database (the alert-query partition tests) execute in CI instead of skipping. Tests that do not need one still use the in-memory SQLite default fromconftest.py.
Verified, not changed
- The horizontal-overflow warnings on
.page-headerare a false positive: the element setsoverflow-x: hiddento clip its decorative orbs, and the page itself does not scroll horizontally at 1440px, 390px or 320px.
[2.125.0] - 2026-08-04 - Front-end consistency: one escapeHtml, one page header
Added
tests/test_frontend_consistency.py— static guards so the duplication cleaned up below cannot silently return. Covers: only oneescapeHtmland oneshowToastdefinition; the shared implementation must escape quotes and be published as a global; no page may redefine astyles.css-owned class or a Bootstrap class unscoped; no page may hand-roll.page-headermarkup.tests/css_collisions_allowlist.txt— the 33 CSS classes currently defined unscoped by two or more pages, recorded as a shrinking backlog. New collisions fail CI; existing ones are listed with the files that clash. The largest cluster is.gps-*, copy-pasted across the GPS dashboard, Hardware Settings and System Health and since diverged..spinner-lg,.empty-state-plain,.status-badge-plain,.status-badge.connected/.disconnectedinstatic/css/styles.css— the page-local variants that previously overrode the shared rules, expressed as modifiers so both treatments can coexist.
Fixed
escapeHtmlhad 16 behaviourally different implementations across 22 templates, plus 6 more instatic/js. Several used the detached-<div>textContentround-trip, which escapes&,<and>but leaves"and'intact — unsafe wherever the result is interpolated into a quoted HTML attribute, which happened in 13 places (onclick="editProfile('${...}')",placeholder="${...}", and similar on the Stream Profiles and Environment admin pages). There is now a single implementation instatic/js/core/utils.jsthat escapes all five characters. It is published aswindow.escapeHtml; it was previously reachable only asEASUtils.escapeHtml, which is precisely why every page grew its own copy. The twoescapeHtmlAdminaliases now forward to it instead of carrying their own bodies.showToastwas redefined in 8 templates and one module, despite being documented in AGENTS.md as a global to reuse; the local copies shadowed it. The VFD Display Control page had also drifted onto a different calling convention —showToast(message, isSuccess)taking a boolean rather than a type string — so its 13 call sites were converted to'success'/'error'.- The August 2026 CSS consolidation was additive but never subtractive.
.spinner,.status-badgeand.empty-statewere promoted intostyles.css, but the inline copies were left in place, so the page-local rules kept winning the cascade and the shared versions did nothing. The duplicates are gone; genuine variants became modifiers. Two overrides on System Health (.table,.alert) were byte-for-byte whatstyles.cssalready applied and were simply deleted. - Bootstrap primitives were being restyled per page. The Environment
settings page redefined
.form-controlunscoped, so form fields rendered in a monospace face there and nowhere else; it is now scoped to that page's own.env-pagewrapper. .stat-labelwas defined unscoped by three pages with three different treatments. Each is now scoped under the wrapper it already lives in (.stat-box,.stat-item,.stat-card) — a specificity change only.
Changed
- Every page header now comes from
components/page_header.html. Four systems previously coexisted: the shared component (59 pages), the shared hero (8), a hand-rolled.page-headeron System Health, a bespoke.workflow-heroon the Broadcast Builder, and ~13 pages with no standard header at all that opened with a bare<h1>— themselves inconsistent (mb-4,h3 mb-1,editor-title, or unclassed), so heading size and spacing visibly changed as you moved around the UI. Converted: LED Sign Control, VFD Display Control, Alert Statistics (which had a fifth system,.stats-page-header), Alert Trail, Audio System Health, Audio Pipeline Test Suite, Documentation Search, RBAC Permission Tree, search results, System Health and the Broadcast Builder Console. Adoption is now 70 pages on the standard header and 7 on the hero, with zero hand-rolled headers. System Health's live platform pills and the Broadcast Builder's station-facts strip ride in the component's actions slot, preserving their element IDs and behaviour. - The empty-state icon on the Screens page was harmonised from 4rem/0.35 opacity to the 3rem/0.5 used by the other two pages sharing that treatment.
- Deleted
templates/privacy.html. The/privacyroute rendersPRIVACY_POLICY.mdthrough_render_policy_page(), so the template had been unreachable.
[2.124.0] - 2026-08-04 - CI runs the test suite; latent NameError crashes fixed
Added
- CI now runs the full test suite.
.github/workflows/tests.ymlrunspytest tests/on every pull request and on pushes tomain/develop, across Python 3.11 and 3.13, with a Redis service container. Previously CI executed exactly one test file (tests/test_release_metadata.py) plus the template-block check, so roughly 1,700 tests across 147 files never gated a merge. - A lint gate. New
pyproject.tomlcarries pytest and ruff configuration. The enforced ruff rule set is deliberately narrow (E9,F821,F811,F601,F632) — every rule the codebase is now clean against, so a failure is always a new regression rather than pre-existing debt. The remaining categories are recorded as a documented backlog with counts. AudioSourceManager.remove_source()— the manager could add sources but had no way to remove one. Stops the source, drops it from every per-source map, and fails over first if the source being removed is the active one.AudioSourceManager(monitor_interval=...)— the health-check interval was hardcoded to 1.0s, which made failover assertions in tests depend on sleeping longer than the production poll period.tests/known_failures.txt— tests that need real hardware or a live SDR service are marked xfail (not skipped) so they still run and report XPASS when they start passing. The list is meant to shrink to nothing.- A shared
authenticated_userpytest fixture covering all three auth decorators (require_auth,require_role,require_permission).
Fixed
pip install -r requirements.txtfailed outright on Python 3.11 and 3.12, and on every non-Raspberry-Pi machine.audioop-ltspublishes wheels only for Python >= 3.13, andrpi-ws281xbuilds a C extension that cannot compile on x86 — neither carried an environment marker, so installation aborted before any other dependency resolved. This blocked contributors on x86 laptops and any CI runner, andinstall.shexplicitly suggests downgrading to Python 3.12 when SoapySDR bindings mismatch, which walked users straight into it.- Twenty undefined names that raise
NameErrorat runtime, found by the new lint gate:webapp/routes_security.pyusedloggerin threeexcepthandlers without importing it, so the ban-list and overview endpoints raisedNameErrorfrom inside the error handler whenever geo enrichment or fail2ban status was unavailable — the default state on a fresh install.webapp/routes_backups.pyusedoswithout importing it, in the backup download temp-file cleanup and its error path.app_core/audio/eas_monitor.pyreadfull_alert_jsonroughly 25 lines before it was assigned, in the duplicate-alert suppression branch. SAME headers are transmitted three times, so the duplicate path is the normal case, not an edge case.webapp/routes_monitoring.pycalledget_redis_client()without importing it. The surroundingexcept Exceptionturned the resultingNameErrorinto a permanent "Redis check failed", so the health dashboard could never report Redis as healthy regardless of the server's real state.- Missing
Any/Optionaltyping imports and several unresolvable forward references (np.ndarray,ReceiverConfig,ReceiverStatus,OLEDLine,Path), now declared underTYPE_CHECKING.
AudioSourceManager.start()could never succeed. The guard readif not self._stop_event.is_set(), butthreading.Event()starts unset, so a freshly constructed manager always took the "already running" branch and returned False. The check now keys on whether the monitor thread is alive, which is the only state that distinguishes "never started" from "running". (The class has no production callers yet, so this was latent rather than a live outage.)- The app could not be instantiated against SQLite at all.
app.pypassedpool_size/max_overflow/pool_timeoutand libpq-specificconnect_argstocreate_engine()unconditionally; SQLite's pool classes accept none of them, so construction raisedTypeError. These are now applied only for non-SQLite URLs, which also makes the in-memory database the test fixtures ask for actually work. scripts/database/check_schema.pysilently skipped three column checks. A duplicate"location_settings"key inREQUIRED_COLUMNSoverwrote the earlier, longer entry, droppingmap_center_lat,map_center_lngandmap_default_zoomfrom validation.app_core/radio/schema.pyused PostgreSQL-onlyALTER TABLE ... ADD COLUMN IF NOT EXISTS. Every call site already guards on the inspector's column list, so the clause was redundant on PostgreSQL and a syntax error on SQLite. Production behaviour is unchanged.app_utils/eas_tts.pyfell back toimport audioop_lts, which does not exist — theaudioop-ltspackage installs a module namedaudioop. The fallback could never succeed; it now raises an actionable error naming the package to install.- Test doubles in
tests/test_audio_source_manager.pyhad drifted from the real interfaces: mock factories took a positionalconfigthe manager no longer passes, and the source double implementedread_audio(the manager's method name) instead ofread_samples(the source's). Both failures surfaced as confusing assertion errors rather than the real cause, becauseadd_source()swallowed theTypeError.
Changed
tests/conftest.pyseedsDATABASE_URL,SECRET_KEY,SKIP_DB_INITandTESTINGat import time viasetdefault, so a barepytestworks with no external services — astests/README.mdalready documented. A real exported value still wins, which is how CI points the suite at live services.- The
audio,gpioandradiopytest marks are registered inpyproject.toml; they were applied by a conftest hook but never declared, emittingPytestUnknownMarkWarningon every run. Marker checking is now strict, so a typo is an error rather than a silently dropped mark. - Roughly 28 MB of scratch bug artifacts (two PDFs and an unreferenced IQ
capture) are no longer tracked.
bugs/was already in.gitignore; these predated that rule. The one IQ capture consumed by the RTL-SDR saturation regression test is explicitly re-included so the test keeps running instead of degrading to a permanent skip.
[2.123.2] - 2026-08-01 - GPIO activation logging and relay release pairing
Fixed
- The GPIO activation log stopped recording anything once relay keying
moved into the
eas-station-gpiosubprocess. That subprocess keys relays entirely from background threads — the alert-indicator poll loop, its Redis pub/sub listener, the per-pin watchdog timers, and the behavior manager's hold / pulse / flash threads — none of which run inside a Flask application context. Flask-SQLAlchemy 3.x scopesdb.sessionto the active application context, so everysession.add()from those threads raisedRuntimeError: Working outside of application context._save_activation_event()swallowed the exception, so the failure was silent and Logs → GPIO showed no new entries at all while alerts continued to broadcast normally. Only the manual-command path (which already wrapped itself in a context) still wrote rows.GPIOControllernow takes the owning Flask app and pushes a context around its audit writes when the calling thread has none. - An activation was only written to the audit trail when the relay
released. A relay still on air — or one whose process died mid-broadcast —
left no record at all. The row is now written when the pin fires and updated
in place on release (the Logs view already rendered
Activefor a row without a duration), so an in-flight or never-released activation is visible. The write happens after the watchdog and flash threads start: it commits synchronously under the controller-wide lock, so a slow or hung database must not be able to delay a pin's safety timer or block another pin's forced release. - A relay keyed through the
activate_allfallback was never released, and stayed energised until its 300 s watchdog fired. When a behavior matrix exists but holds nothing for a given broadcast, the rising edge falls back to keying every configured pin — but the falling edge called onlyend_alert(), which releases just the pins the behavior manager is tracking. The keying path is now recorded across the edge and released through the matching one; a falling edge with no known rising edge (the subprocess started mid-broadcast) releases through both, since leaving a transmitter keyed is the worse failure. - A hold on an already-energised pin was dropped on the floor, leaving
that relay keyed until the watchdog.
activate()refuses a pin that is alreadyACTIVE, which happens routinely — a second hold behavior assigned to the same pin, or a still-runningINCOMING_ALERTpulse the broadcast overlapped — and the behavior manager treated that refusal as failure and never recorded the hold, soend_alert()had nothing to release. Holds now adopt an already-active pin. - A finishing
INCOMING_ALERTpulse could un-key a transmitter mid-alert. The 3 s pulse thread deactivated its pin unconditionally, even when a broadcast hold had since adopted it. The pulse now defers to the owning hold. - Failed GPIO activations were indistinguishable from successful ones in
the Logs hub. All three render sites hard-coded
level: 'INFO', so the row written when the hardware is unavailable and the relay never moved looked like a normal activation. Level now follows the row'ssuccessflag, the message carries the reason and the error, and durations are rounded to a tenth of a second instead of showing raw microsecond floats. A failed row readsDuration: N/Arather thanActive— it has no duration because the relay never moved, not because it is still on air.
Added
app_utils/gpio_logs.py— shared level/message/duration formatting forGPIOActivationLogrows, used by the unified All Logs view, the GPIO log category, and the/api/logs/*JSON feed. Free of hardware imports so the web layer does not pull in gpiozero / lgpio.tests/test_gpio_activation_logging.py— 11 tests covering audit writes from a thread with no application context, activate/release row pairing, failed-activation auditing, hold adoption, the pulse/hold handover, the rising/falling edge release pairing, and the Logs-hub formatting.
[2.123.1] - 2026-08-01 - Table header contrast, logs coverage, and a theme-contrast audit
Fixed
- Table headers rendered dark text on a dark gradient in every theme.
.table thead thset abackground:shorthand carrying a vibrant gradient; a later "TABLE READABILITY FIXES" rule setcolorto dark ink andbackground-colorto a light tint — butbackground-colorcannot override a gradient, because the gradient is a background-image and keeps painting on top. The result was near-black text on dark navy across all 39 plain-Bootstrap tables (18 of them on System Health alone). The gradient is removed so the readable tint actually shows; the rule now carries a comment explaining why it must not come back. A stale[data-theme="lightning"]dark-ink override, which existed only to compensate for that gradient, was removed with it. .eas-tableheaders fell below WCAG AA in the dark theme (4.04:1, where 4.5:1 is required at that font size). The header colour is now mixed toward--text-colorinstead of using--text-secondaryalone.- ENDEC Device Feeds had an unwrapped
<table>, violating the repository's own mobile rule; it is now inside.table-responsive. No unwrapped tables remain outside email templates. - "All Logs" silently omitted half the log categories. The unified view aggregated 8 of the 20 individual categories, so Polling Debug, Audio Metrics, Audio Health, and Decoded EAS Audio were invisible to anyone who did not know to click their specific tab. All four are now included (Audit and Compliance remain deliberately separate as distinct legal record categories). Every one of the 21 log tabs was verified to map to a real handler branch, with no orphan branches in the other direction.
- Documentation pointed at a
/system-logspage that does not exist. The journal is reachable through the Logs hub's Service Logs (systemd) tab (/logs?type=services); the CLI-free-operations guidance now says so.
Added
scripts/diagnostics/check_theme_contrast.py— audits text/background contrast for key surfaces across all 20 themes and exits non-zero on a strict-surface regression. It measures the backdrop from real screenshots with the glyphs masked out, so gradients andcolor-mix()/color(srgb …)values are handled correctly, and it applies WCAG's large-text threshold where it applies. Gradient surfaces that rely ontext-shadoware reported as advisory rather than failing, since the WCAG formula models neither the shadow nor a gradient. This check reproduces the table-header bug above and caught a regression introduced while fixing it.
[2.123.0] - 2026-08-01 - Standardized page headers across the entire UI
Changed
Every page now renders its header through one shared component (
templates/components/page_header.html). Twenty-seven templates that hand-rolled near-identical.page-headermarkup (Dashboard, Alerts, Audio Archive, GPIO pages, Logs, Settings, Docs, Diagnostics, and more) were converted to setheader_icon/header_title/header_subtitle/header_actionsvariables and include the component, so future header changes happen in exactly one file. The unused duplicatetemplates/partials/page_header.htmlwas removed.Modernized header visuals for all pages and themes. The component renders the page icon in a frosted-glass tile beside the title, and header action buttons are now unified pill-shaped controls with a glassy outline-light treatment and hover lift — promoted from page-specific CSS on the dashboard into
static/css/styles.cssso all 20 themes and every page get the same look.Live Display Preview no longer overrides the global
.page-headerstyling with its own local gradient; it now uses the standard header like every other page.Style Guide (
/style-guide) now documents the component-include pattern as the required way to build page headers instead of showing hand-rolled markup.Hero pages standardized too. About, Attribution, and Support hand-rolled their
.about-herobanner markup; they now render through the sharedpartials/hero.htmlcomponent (which gained ahero_extraslot for content like the Support page's Ko-fi call-to-action). The Admin panel — an operational page — moved from the marketing-style hero to the standard gradient page header so it matches the rest of the operator UI. The now-unused.about-hero*/.about-chip*CSS alias selectors were removed fromstyles.css.Admin section (32 pages) standardized. Every
templates/admin/*page plus the Security Center used a third header system (admin-page-headerwith per-page color variants, styled inadmin.css); all now render through the shared page-header component, and ~95 lines of dead header CSS were removed fromadmin.css.Remaining stragglers standardized. Help, Privacy, SMS Policy, and Version now use the shared hero component (Version's build-info card moved into the hero's
hero_extraslot); the old per-pageversion-heroCSS was removed.Orphaned templates deleted.
alerts_new.html,audio_sources.html(superseded byadmin/audio_sources.html),system_logs.html(superseded by the unified Logs hub),theme_test.html,components/form-example.html, andpartials/common_head.htmlwere referenced by no route or include.Style Guide is now reachable.
templates/style_guide.htmlhad no route; it is now served at/style-guideand linked from the Site Navigation page under Help & Documentation.Per-page CSS consolidation. Byte-identical rule blocks that were copy-pasted across admin pages moved into
styles.cssas shared utilities: the.status-badgepill system (was duplicated in Certbot, Tailscale, and Icecast), the inline.spinner(Network, Zigbee), and the.snr-s0–.snr-s5signal-quality color scale (Hardware Settings, System Health). Pages with intentionally different badge designs (Network, System Health) neutralize the shared defaults explicitly so their appearance is unchanged. Also deleted three unused component templates (status_badge.html— which emitted classes no stylesheet defines —metric_card.html, andstat_card.html) and stale.page-headeroverrides left in Live Display Preview.
Fixed
- Hero banner titles were unreadable in light themes. The global
h1 { color: var(--text-color) }typography rule overrode the hero's white text, rendering titles near-black on the purple gradient on About, Attribution, and Support in every light theme..eas-hero-titlenow sets white explicitly. - Page headers were unreadable in the Yellow theme. The standard
header's white text sat on the theme's amber → yellow gradient with
failing contrast (admin pages previously carried their own dark-ink
override in
admin.css, but main pages never did).styles.cssnow applies the same dark ink to.page-headertext, the icon tile, and outline-light action buttons for both the Yellow and Lightning themes.
[2.122.2] - 2026-08-01 - Installer resilience on Debian 13/Ubuntu and alembic_version repair
Fixed
- install.sh no longer aborts when a distro-specific package is missing
(GitHub issue #2330). The package list was installed as one apt transaction
that included the hardcoded
postgresql-17-postgis-3, so on releases whose default PostgreSQL differs (e.g. Ubuntu 24.04 ships 16) or that lack an SDR/ audio package, apt failed wholesale and the install died with "unfindable" dependency errors. Packages are now split into a required core set (hard failure) and best-effort feature packages (SDR, Airspy, espeak, Icecast, fail2ban, certbot, audio libraries) that are availability-checked first and skipped with a warning when this OS release doesn't carry them. The versioned PostGIS package is resolved from the installed PostgreSQL major version instead of being hardcoded.update.shapplies the same availability filtering and per-package retry. - Databases initialized by the
db.create_all()fallback are no longer permanently stuck without analembic_versiontable. When Alembic failed during a fresh install, the fallback built the full schema but never recorded the migration head, so every lateralembic upgrade headreplayed all migrations from scratch and failed on the firstCREATE TABLE, and the schema health check reported "Could not query alembic_version". The install fallback now runsalembic stamp headafter a successfulcreate_all(), and bothinstall.sh(existing-database path) andupdate.shdetect the tables-present/no-revision fingerprint on already-affected systems, stamp the head, and (inupdate.sh) run an additivecreate_all()pass so tables added since the original install exist. scripts/database/check_schema.pyno longer cascades intoInFailedSqlTransactionerrors. A failedalembic_versionquery poisoned the shared connection so every subsequent check reported "current transaction is aborted" instead of real results. The connection is now rolled back after the failure, and the report includes thealembic stamp headrepair hint.- ALSA and PulseAudio source adapters are no longer silently unavailable on
bare-metal installs.
pyalsaaudioandpyaudiowere never installed (nor were their system build dependencies), so every startup logged "ALSA not available" / "PyAudio not available" and sound-card capture sources could not be used.install.shandupdate.shnow installlibasound2-dev,alsa-utils, andportaudio19-dev(best-effort) and then installpyalsaaudioandpyaudiointo the main venv best-effort, keeping graceful degradation on systems without sound hardware.
[2.122.1] - 2026-08-01 - Stalled captures can no longer stall the entire audio service
Fixed
- A stalled capture could freeze the whole audio service (all sources
showing STOPPED, EAS Continuous Monitor "Unavailable", "Audio service
metrics are unavailable" banner). Four compounding causes, all fixed:
AudioIngestController.start_source()/stop_source()/remove_source()/ start_all()/stop_all()held the controller registry lock while running blocking adapter start/stop work (capture-thread joins, FFmpeg process termination, stream URL resolution — tens of seconds on a dead source). Every consumer of the controller — metrics publishing, status queries, EAS-monitor source discovery, Redis command handling — froze behind that lock. The lock now guards only the source registry; blocking work runs outside it.- The health monitor restarted stalled sources serially in its single
shared thread, so one stalled capture's slow restart delayed stall
detection and recovery for every other source. Restarts and stall
escalation now run in per-source background recovery threads
(
spawn_recovery), with at most one recovery in flight per source. - The 30-second source watchdog in
eas_monitoring_service.pyran inline in the metrics-publishing loop: a blocked restart stopped metrics publishing, the Rediseas:metricskey (120 s TTL) expired, and the entire UI reported the audio service as down. The watchdog now runs in its own thread and dispatches restarts through the per-source recovery threads. - The watchdog's auto-start lookup queried
AudioSourceConfigDBwithout a Flask app context, so it raised on every cycle, the exception was silently swallowed, and STOPPED sources withauto_start=Truewere never restarted. The query now runs insideapp.app_context().
- Startup auto-start is now parallel: sources start in concurrent threads with a bounded 10 s wait, so a single dead stream URL no longer delays service startup (and therefore decoding and metrics for every healthy source) by 10–30 s per broken source. Late starters are picked up by the auto-streaming, EAS-monitor, and Redis-publisher discovery loops.
UnifiedEASMonitorService._discover_sources()now uses the controller's lock-guardedget_all_sources()snapshot instead of reading the internal source dict while other threads mutate it.- Regression coverage in
tests/test_stalled_capture_isolation.py(8 tests): lock-free status queries during blocking start/stop, parallel per-source recovery, single-recovery-in-flight guarantee, and service-level guards keeping the watchdog out of the metrics loop.
[2.122.0] - 2026-07-03 - Multi-select and exclusion filtering for received alerts
Added
- Multi-select Audio Source and Event Type filters on the Received Alerts
page (
/audio/received). Both filters are now checkbox dropdowns that accept several values at once (?source=A&source=B), and each carries an Include / Exclude mode toggle so a noisy source can be filtered out of the list, or weekly test events (RWT/RMT) hidden entirely (?event=RWT&event=RMT&event_mode=exclude). Rows with no event code survive an event exclusion filter. Each dropdown option shows its alert count so operators can see which sources/events dominate the list. - Clickable statistics cards: the Total / Forwarded / Ignored / Errors cards now link to the correspondingly filtered list.
- Filter parsing, query building, and chip/pagination query-string helpers
extracted to
webapp/admin/audio/received_filters.pywith regression coverage intests/test_received_alert_filters.py(12 tests). - Help page documents the new filtering workflow.
Fixed
- Filter chips and pagination links are now built with proper URL encoding;
previously a search term containing
&,#, or spaces produced broken chip-removal and pagination URLs. Removing the last value of an excluded filter also removes its orphaned mode marker from the URL.
[2.121.0] - 2026-07-02 - Detect gate-chopped off-air narration and substitute local TTS on relay
Added
- Off-air narration quality detection (
app_utils/audio_quality.py). A forensic investigation of two stuttering received-alert recordings (relays from two independent stations, both fingerprinted as Sage Digital ENDECs) proved the stutter exists in the received signal itself: the SAME header bursts and attention tone in both recordings are phase-continuous and sample-exact, while the voice narration alternates between full level and near-digital silence with single-sample onsets — the signature of an upstream record-input gate/AGC chopping word boundaries. An end-to-end test of the local capture pipeline (broadcast queue → adapter → unified monitor → ring slicing → WAV encode) reproduced a synthetic alert with max error 3×10⁻⁵ (16-bit quantization), confirming the recorder is transparent.assess_narration_quality()detects the gate-chop signature (silence runs followed by instant full-level onsets ≥ 4 events/min) without flagging natural speech pauses; calibrated against the real degraded captures (8–28 events/min) and clean TTS baselines (0 events/min). - Relay Narration Audio setting (
EASSettings.relay_narration_source, Admin → EAS Encoder Settings). Controls the narration used when relaying a received (OTA) alert:auto(default — captured off-air audio unless it is detected as degraded, then locally synthesised TTS),captured(legacy behaviour), ortts(always local TTS). When no TTS provider is configured the captured audio is always relayed — degraded narration beats silence. Migration20260702_relay_narration_source. - Quality verdict surfaced in the UI: the received-alert detail page now shows whether the captured narration was clean or gate-chopped, and which narration source the relay actually used. The unified EAS monitor logs a warning at capture time when a degraded narration is recorded.
- Regression coverage in
tests/test_narration_quality.py(8 tests).
Fixed
- The 16 kHz EAS ingest stream carried a filter-edge glitch at every
~100 ms chunk boundary on non-16 kHz sources.
_resample_for_eas()calledscipy.signal.resample_polyon each capture chunk independently; the resampler assumes zeros outside the block, stamping a transient of up to ~13 % of signal amplitude onto every chunk edge (~10 events/second on 44.1 kHz sources) in the audio the SAME decoder and the alert recorder consume. The integer-decimation fast path (48 kHz → 16 kHz) additionally droppedlen % factorsamples on every chunk whose length was not divisible by the factor (e.g. one sample per 4096-sample chunk — a click plus ~10 samples/second of clock drift). Both paths are replaced by stateful streaming resamplers (app_core/audio/eas_resampler.py) that carry filter history and remainder samples across chunks; the streamed output now matches a whole-signal resample to below −150 dB (float32 rounding). Regression coverage intests/test_eas_resampler.py(6 tests). - Dropped audio chunks were invisible.
BroadcastQueuelogged drop-oldest events at DEBUG, so a consumer falling behind real time (the root cause of audible stutter on the live monitor/Icecast stream) never surfaced in the system logs. Drops are now logged at WARNING, rate-limited per subscriber, naming the exact subscriber that is consuming too slowly. - Recorded alerts were shredded once the unified monitor fell behind —
the stored WAV itself stuttered. The monitor loop read exactly ONE
100 ms chunk per source per cycle, while every source that momentarily
produced nothing (a stream reconnecting, an SDR service restart, a
configured-but-idle input) blocked the cycle for its full 0.1 s read
timeout. One stalled sibling capped every active source at exactly
real-time consumption (zero margin); two stalls or any decode/GC overhead
put active sources permanently behind — and there was no catch-up
mechanism, so the backlog grew monotonically until the 10 000-chunk
subscription queue engaged drop-oldest permanently. From that point a
slice of every source's audio was silently discarded and every recorded
alert came out fragmented with ~100 ms holes. A service restart cleared
the queues (appearing to fix it) before the system silently degraded
again. The loop now drains a source's entire backlog when one is waiting
(bounded at 50 chunks/cycle so one source cannot starve others), recovers
from any stall faster than real time, reports
queue_backlog_chunksper source in the monitor status API, and logs a rate-limited warning whenever a backlog persists. Regression coverage intests/test_eas_monitor_catchup.py(2 tests — the stalled-siblings scenario fails against the old one-chunk-per-cycle loop). - Live listen streams stuttered by construction under any timing jitter.
The web WAV stream (
/api/audio/stream/<source>) and both EAS decoder MP3 feeds wrote a 50–100 ms block of zeros on EVERY queue-read timeout — and the decoder feeds' 100 ms timeout raced the ~100 ms producer cadence, so a chunk arriving even a few milliseconds late was replaced with an injected silence block spliced into the middle of continuous audio, with the real chunk playing after the gap. Under normal scheduling/network jitter this produced audible stuttering several times per second on the Listen feature and pushed the stream progressively behind real time until the subscription queue overflowed and real audio was dropped too. All three streams now use a keep-alive gate (app_core/audio/stream_keepalive.py): transient jitter emits nothing (the browser/ffmpeg simply waits for the late chunk), and paced comfort silence is emitted only after a source has been genuinely quiet for 1.5–2 s, keeping the connection alive when a source is down. Regression coverage intests/test_stream_keepalive.py(5 tests).
[2.120.0] - 2026-06-24 - Centralize relay keying in the GPIO subprocess
Fixed
- Broadcast relays (transmitter PTT, audio mute, duration-of-alert holds, and
flash) were not reliably keying the physical hardware — most visibly, an
automated RWT could leave no relay action at all, or hold the relay for the
full 300 s watchdog instead of the broadcast length. The root cause was
GPIO pin-ownership contention across processes.
lgpio'sgpio_claim_outputis exclusive per process, but theeas-station-gpiosubprocess claimed every configured relay pin at boot (and only used them for the tower light), while each broadcast producer — the RWT scheduler and manual-send path (both inside the gunicorn gevent web workers),EASBroadcasterin the poller, and the detached resend helper — built its ownGPIOControllerand tried to claim the same pins. Those claims failed and silently fell back to the no-op backend, so the relay never moved; in the web workers, importinglgpioalso stalled the gevent event loop. Relay keying is now centralized in the single pin-owningeas-station-gpiosubprocess, which keys the relay off theeas:broadcast_active/eas:incoming_alertRedis markers every producer already publishes (rising edge → hold for the broadcast window via the behavior manager, falling edge → release). The relay is therefore asserted for exactly the broadcast duration. New regression coverage intests/test_gpio_centralized_keying.py(8 tests) on top of the existing 74 GPIO tests. - A forwarded alert could leave its
FORWARDING_ALERThold relay energised until the watchdog. The broadcast-state marker drops itssourceon the falling edge, so the release path now callsend_alert(forwarded=True)(a safe superset — a no-op for any behavior that wasn't actually held) to guarantee forwarding-hold pins are released at end-of-message. - An overlapping broadcast could release the relay early. Broadcasts share
one global
eas:broadcast_activemarker; an older playout finishing used to delete it unconditionally, which could erase a newer overlapping broadcast's marker and drop the relay mid-air.clear_broadcast_active(identifier=...)now compare-and-deletes — it only clears the marker when it still belongs to the finishing broadcast. The pre-broadcasteas:incoming_alertmarker also now carries the alert identifier so the INCOMING_ALERT pulse is attributed correctly in the GPIO audit log.
Changed
- The web app, poller, RWT scheduler, and resend helper no longer build a
GPIOControlleror key relays directly. They publish the broadcast-state marker (now carrying the alertidentifierso the GPIO activation audit log still links to the originating alert) and let the GPIO subprocess do the keying. This removes the cross-process pin contention and the gevent-worker stall entirely. - Operator-initiated manual relay control (the GPIO Control page test
buttons) and live pin-state display now flow through Redis instead of a
web-process controller:
POST /api/gpio/activate|deactivatepublish a command on the neweas:gpio_commandschannel that the subprocess executes, and/api/gpio/status,/api/gpio/live-pin-states, the/admin/gpiopanel, and the WebSocket GPIO push render from a pin-state snapshot the subprocess publishes each heartbeat. When the GPIO service is down, manual control returns503with an actionable message and the panel flags that relays can't be controlled. New moduleapp_core/gpio_commands.py. The manual-send and resend paths now report airchain status from the actual marker-publish result (set_broadcast_active()returns whether the write succeeded) rather than assuming success, and/api/gpio/live-pin-statesreportsUNKNOWNfor config-only fallback pins and honours each relay'sactive_highpolarity.
[2.119.3] - 2026-06-22 - Move copyright/ownership to EAS Station, LLC
Changed
- Transferred all project copyright, licensing, and attribution from the
personal name "Timothy Kramer (KR8MER)" to the newly formed legal entity
"EAS Station, LLC (KR8MER)". Following the filing of the Ohio Articles of
Organization for EAS Station, LLC (effective 2026-06-17), every ownership and
legal-attribution reference was updated: source-file
Copyright (c)headers across the codebase, theLicensorline inLICENSE-COMMERCIAL, theNOTICEattribution string and endorsement clause, the recommended "Powered by EAS Station" attribution, the in-app footer (templates/base.html) and attribution page (templates/attribution.html), theauthormetadata inwebapp/routes_monitoring.py, and the copyright/ownership statements indocs/process/CONTRIBUTING.md,docs/policies/TERMS_OF_USE.md,docs/policies/TRADEMARK_POLICY.md, anddocs/reference/dependency_attribution.md. The personal "about the maintainer" biography and portrait (templates/about.html,docs/reference/ABOUT.md) were intentionally left intact, as they describe the human maintainer rather than asserting ownership. - Updated operational contacts and policy text for the LLC. The security
vulnerability contact is now
security@easstation.com(docs/security/SECURITY.md), the general public contact inREADME.mdanddocs/README.mdis the business inboxsales@easstation.comand business line, the privacy policy (docs/policies/PRIVACY_POLICY.md) now names EAS Station, LLC as the maintaining entity, anddocs/process/CONTRIBUTING.mdadds a commercial-relicensing grant so contributions can be included under the dual-license commercial terms.
[2.119.2] - 2026-06-19 - Firewall sync state ignores unmirrorable bans
Fixed
- The Security Center reported SSH/web bans as perpetually "not synced" and
re-ran a no-op resync every cycle whenever the Global Ban List held a CIDR
range or loopback entry. The actuator jail enforces bans with
fail2ban-client ... banip, which only accepts single routable IPs, so CIDR ranges and loopback addresses are intentionally never mirrored to the firewall — they stay enforced at the application layer. But the "is the firewall in sync?" checks compared the jail's IP count against the full ban-list size (active_blocklist_count/app_ban_count). A list holding even one range therefore always showedfirewall_ban_count < app_ban_count, soheal_firewall_bans(app_core/auth/firewall.py) fired a wastefulresync_banson every 60s background cycle and the UI showed a "Some bans aren't mirrored yet — click Resync bans" banner that Resync could never clear. Addedmirrorable_blocklist_ips()(active, non-expired, single, non-loopback IPs — the set the jail can actually hold) andfirewall_pending_bans(), and pinned the drift check and the status endpoint to that subset.resync_bansnow reuses the same helper. The status payload exposesfirewall_pending(webapp/admin/fail2ban.py); the Security Center only prompts for a Resync when bans are genuinely pending and otherwise explains that the remaining difference is range/loopback entries enforced at the app layer only (static/js/pages/security_center.js). Regression coverage intests/test_firewall_sync_state.py.
[2.119.1] - 2026-06-19 - fail2ban SSH bans sync in the background
Fixed
- fail2ban SSH bans only reached the Global Ban List while the Security Center
page was open. The host
sshdjail blocks SSH brute-force at the firewall around the clock, but those bans were copied into the application ban list (ip_filters) only as a side effect of the Security Center pollingGET /admin/fail2ban/status. With the UI closed (e.g. overnight) attacks were still firewalled but never recorded, so the list looked empty until an operator reopened the page — and each entry'screated_atreflected when the page was next loaded rather than when fail2ban actually banned the IP. Added a background sync scheduler (app_core/fail2ban_sync.py) that runs the SSH-ban import,sshd-jail re-apply, and firewall self-heal on a short interval, independent of the UI (started fromapp.pyalongside the other schedulers). The import/self-heal logic moved intoapp_core/auth/firewall.py(import_ssh_bans,heal_firewall_bans,active_blocklist_count) so it runs without importing the web layer;webapp/admin/fail2ban.pynow delegates to it, keeping a single code path shared by the UI poll and the scheduler.
[2.119.0] - 2026-06-19 - SSH bans survive a fail2ban restart
Fixed
- fail2ban restarts (including every Security Center "Save & Apply") silently
un-banned known SSH attackers. A restart flushes every live ban; web bans
were restored afterwards (
resync_bansre-pushes theeas-stationjail from the database) butsshd-jail bans were not re-applied to any jail, so active SSH offenders dropped off the firewall until re-detected. Addedresync_ssh_bans()(app_core/auth/firewall.py), which re-applies still-active SSH-sourced ban-list entries (source = ssh_brute_force) back into thesshdjail, skipping expired and loopback entries. The Security Center now snapshots live SSH bans into the durable ban list before a stop/restart and re-applies them after — wired into Save & Apply, the Restart action, and the manual Resync button (webapp/admin/fail2ban.py). Unlike web-ban mirroring, this only requires SSH protection to be enabled, not firewall mirroring.
[2.118.0] - 2026-06-19 - Allowlist self-lockout guard
Added
- Adding an allowlist entry that excludes your own IP is now blocked by
default. The moment any active allowlist entry exists, login flips into
allowlist-only mode (
IPFilter.is_ip_allowed/webapp/admin/auth.py) where only listed IPs may sign in and loopback is not exempt — so an admin who allowlisted, say, a private LAN range on a public deployment locked themselves out of login entirely. ThePOST /security/ip-filtersendpoint (webapp/routes_security.py) now checks the requesting admin's IP against the resulting allowlist and refuses with alockout_riskresponse (HTTP 409) unlessconfirm_lockout=trueis supplied. The Security Center "Banned IPs" UI surfaces a warning note on the allowlist modal and prompts for explicit confirmation before proceeding (templates/security/security_center.html,static/js/pages/security_center.js).
[2.117.1] - 2026-06-19 - Manual-only release workflow
Changed
- The
ReleaseGitHub Actions workflow no longer fires automatically when theVERSIONfile changes onmain. It is now triggered manually only (Actions → Release → Run workflow), making publishing a deliberate, on-demand step. Removed thepushtrigger from.github/workflows/release.yml, leavingworkflow_dispatchas the sole trigger. Updateddocs/process/RELEASING.md,docs/process/CONTRIBUTING.md, anddocs/development/AGENTS.mdto describe the manual release step.
[2.117.0] - 2026-06-19 - Scrollable audio modals on touch devices + bulk-clear alerts
Fixed
Long modals (e.g. the Add/Edit Audio Source stream form) could not be scrolled on iPadOS/iOS — and Chrome on those devices — leaving the footer Save button unreachable; dragging scrolled the page behind the modal instead. Three independent WebKit behaviours combined to cause this, so the fix defends against all three (
static/css/styles.css):- Backdrop blur killed the inner scroll. Bootstrap's
.modal-dialog-scrollableturns.modal-contentinto anoverflow: hiddencontainer around the scrolling.modal-body, and WebKit silently disables scrolling inside anoverflow: autodescendant of abackdrop-filteredoverflow: hiddenancestor. The global.modal-contentglass blur is now dropped on scrollable modals (no visible change — the panel is already a solid surface colour). - Scroll chained to the background.
.modal-bodynow usesoverscroll-behavior: contain(plus-webkit-overflow-scrolling: touch) so a scroll gesture stays inside the modal. - The page behind stayed scrollable. Bootstrap's
.modal-openonly locks<body>, but this layout's document scroller is<html>; anhtml:has(.modal.show) { overflow: hidden }rule now locks the background while any modal is open.
Fixes 2 and 3 are global, so this resolves the same bug for every modal site-wide (Audio Sources, Stream Profiles, Radio, Radio Diagnostics, Hardware Settings, Admin, and all others).
- Backdrop blur killed the inner scroll. Bootstrap's
Added
- "Clear All" button for unresolved audio alerts. Silence/health alerts can
accumulate into the tens of thousands, and the only previous way to clear them
was one alert at a time (and the per-alert list is hidden on the Audio Sources
page). The Alerts card now has a Clear All action backed by a new
POST /api/audio/alerts/resolve-allendpoint that bulk-resolves every outstanding alert in a single query (webapp/admin/audio_ingest.py,static/js/audio_monitoring.js,templates/admin/audio_sources.html).
[2.116.3] - 2026-06-18 - Save & Apply verifies the jail actually loaded
Fixed
"Save & Apply Configuration" could report success (and leave enforcement showing "On") even when it never wrote the jail/filter files. On a host with a stale sudoers file, the privileged
teewrite fails, so/etc/fail2ban/jail.localand the filter were never created and theeas-stationjail never existed — yet the toggle still read On (settings are saved before the write).configure()now:- returns an actionable error when a privileged write is denied, pointing at
sudo bash update.shto redeploy sudoers; and - verifies the
eas-stationjail is actually loaded after restart, failing with the real fail2ban error (actuator_error) instead of falsely reporting enforcement is on (webapp/admin/fail2ban.py).
Root cause on affected hosts: the fail2ban sudoers
teeentries weren't deployed yet — running the updater (which redeploysconfig/sudoers-eas-station) resolves the write failure.- returns an actionable error when a privileged write is denied, pointing at
[2.116.2] - 2026-06-18 - Reliably load the firewall actuator jail + surface its errors
Fixed
- The
eas-stationfirewall jail still failed to load (0 mirrored) because its logpath never existed./var/log/eas-station/security.logis only created by the app at need, and the application security logger writes to the journal — not that file — so the path was frequently absent. UnderProtectSystem=strictthe web process also can't always create it. fail2ban then refuses to start the jail._ensure_security_log()now creates the file as root viasudowhen the sandboxed direct write fails (webapp/admin/fail2ban.py; new sudoers entries formkdir -p /var/log/eas-stationandtee -a …/security.log).
Added
- The fail2ban error is now shown in the UI when the jail won't load. The
status endpoint runs fail2ban's own config self-test (
fail2ban-client -t, newly allowed in sudoers) and returnsactuator_error; both the Host Firewall tab and the Enforcement Status sync warning display it, so an operator can see why the jail failed instead of just that it did (webapp/routes_security.py,static/js/pages/security_center.js).
[2.116.1] - 2026-06-18 - Carry fail2ban SSH ban expiration into the Global Ban List
Fixed
- SSH bans imported from fail2ban became permanent instead of inheriting the
jail's expiration.
_import_ssh_bans()calledadd_to_blocklistwith no expiry, so an offender that fail2ban would unban afterssh_bantimestayed in the Global Ban List forever. The import now setsexpires_atto the SSH ban time (from import, which tracks the jail's own expiry), so the Global Ban List entry and the sshd jail expire together (webapp/admin/fail2ban.py). Updated the SSH Jail Bans card copy accordingly. Existing permanent SSH entries from before this fix are unchanged — remove them from the Global Ban List if needed.
[2.116.0] - 2026-06-18 - Unified Global Ban List with enforcement layers
Changed
- Refactored the Security Center around one ban list with multiple enforcement
layers, replacing the "app bans vs fail2ban bans" mental model. The
ip_filterstable is the single source of truth; the application gate, host firewall, and fail2ban act on it.- The Banned IPs tab is now the Global Ban List, and the fail2ban
tab is now Host Firewall (an enforcement layer). Terminology updated
across the UI,
help.html,about.html, anddocs/security/SECURITY.md.
- The Banned IPs tab is now the Global Ban List, and the fail2ban
tab is now Host Firewall (an enforcement layer). Terminology updated
across the UI,
Added
- Ban
sourcefield + badges (IPFilterSource): Manual, Login Brute Force, SSH Brute Force, Malicious Request, Flood, API Abuse, Stream Abuse. Newsourcecolumn onip_filters(migration20260618_ip_filter_source, backfilled from existingreason/description). Every ban-creation site now stamps a source;to_dict()exposessource+source_label; the Global Ban List and SSH tables show a colored source badge. - Enforcement Status card — application gate, host firewall, fail2ban service, and firewall-sync state at a glance.
- Security Metrics — failed logins (24h), IPs banned (24h), active bans, SSH attacks blocked, and mirrored-to-firewall counts.
- Firewall synchronization health — detects "N active bans, M mirrored" drift and offers one-click Resync firewall enforcement.
- New read-only endpoint
GET /security/overview(webapp/routes_security.py) backing the cards above.
Notes
- Preserves all existing functionality: manual bans, allowlist, SSH/login brute-force detection, GeoIP display, expiration, removal, and service controls.
[2.115.2] - 2026-06-18 - Fix "Mirrored to firewall: 0" and clarify ban counts
Fixed
- App bans weren't reaching the host firewall (showed "Mirrored to host
firewall: 0" with enforcement on). Root cause: the
eas-stationactuator jail declareslogpath = /var/log/eas-station/security.log, and if that file didn't exist yet, fail2ban refused to start the jail — so every mirror/resync targeted a jail that wasn't loaded.configure()now ensures the log file exists before restarting fail2ban (_ensure_security_log()inwebapp/admin/fail2ban.py). - Self-heal: the status endpoint now re-pushes the ban list into the firewall when enforcement is on, the actuator jail is loaded, and not all active bans are mirrored — so the count corrects itself on refresh instead of requiring another Save & Apply.
Changed
- The fail2ban tab now warns when enforcement is on but the
eas-stationjail isn't loaded (the real cause of a 0 count), and explains why the SSH Jail Bans list can be shorter than the Banned IPs list: fail2ban'ssshdbans expire after the SSH ban time, while each offender is copied into the ban list permanently. That divergence is expected, not a mismatch (static/js/pages/security_center.js,templates/security/security_center.html).
Fixed
- The Banned IPs action buttons were unreadable — they were icon-only and
rendered as near-blank colored bars (especially on mobile). They now carry
text labels: Enable/Disable (toggle) and Remove/Delete
(
static/js/pages/security_center.js).
Changed
- The fail2ban tab's "SSH Jail Bans" list now uses the same card/table layout
as the Banned IPs list — IP/Range, Reason, Location (country flag + city),
Description, Created, Status, and a labeled Unban action — instead of a bare
IP + button. The backend (
webapp/admin/fail2ban.py) returns rich records by reusing each offender's importedip_filtersentry and the same MaxMind geolocation.
Added
- The Banned IPs list now shows where each IP is from — country flag, city,
region, and country — reusing the same operator-supplied MaxMind GeoLite2
database as the Traffic Analytics dashboard (Settings → Traffic Analytics).
GET /security/ip-filtersenriches each entry with alocationblock via a new_attach_geo()helper (webapp/routes_security.py) usinggeo.classify_location(). It is best-effort and never raises: with no database configured (or the optionalmaxminddbreader missing) entries show a dash and no network calls are made. CIDR ranges and local addresses get no location.- The Banned IPs / Allowlist tables gained a Location column rendering the
bundled flag-icons SVG (no CDN) plus "City, Region, Country"
(
static/js/pages/security_center.js,templates/security/security_center.html). - Works for IPv4 and IPv6, matching the rest of the ban pipeline.
- Documented in
templates/help.html,templates/about.html, anddocs/security/SECURITY.md.
Added
- SSH (
sshd) jail offenders are now automatically added to the unified ban list. An IP hammering port 22 is a bad actor everywhere, so when the host SSH jail is enabled its bans are imported intoip_filters— blocking the IP at the web layer and, when firewall enforcement is on, at all ports via theeas-stationjail. Previously SSH bans were siloed in the sshd jail only.- New
_import_ssh_bans()inwebapp/admin/fail2ban.py: readsfail2ban-client status sshdand adds any new offender to the blocklist (reasonauto_brute_force, labeled "SSH brute-force detected by fail2ban"). One-way and idempotent; runs on fail2ban-tab refresh and on Resync bans. - The SSH Jail Bans unban action now also removes the IP from the unified ban list (and lifts the all-ports firewall ban), so unbanning fully clears an offender from one place instead of leaving it globally blocked / re-imported.
- Updated the fail2ban tab copy,
docs/security/SECURITY.md, andtemplates/help.htmlto describe the global-ban behavior.
- New
[2.113.0] - 2026-06-18 - fail2ban as a UI-managed firewall enforcer for the ban list
Added
- fail2ban now enforces the existing application ban list at the host
firewall, configured entirely from the web UI — the Security Center →
fail2ban tab was previously a static copy-paste-only reference (violating the
project's CLI-free mandate). Rather than introducing a second ban list,
fail2ban is wired up as an optional firewall actuator for the single
authoritative
ip_filtersban list (the Banned IPs tab):- fail2ban ships pre-installed via the base package list in both
install.shandupdate.sh— there is no in-UI installer (the web service runs underProtectSystem=strictand cannot install packages anyway). The tab configures and applies the jails; if fail2ban is somehow missing it tells the operator to runsudo bash update.sh. - Every application ban/unban — manual and automatic (malicious,
brute-force, flood) — is mirrored to a dedicated
eas-stationfail2ban jail (bantime = -1) by the newapp_core/auth/firewall.pybridge, hooked intoIPFilter.add_to_blocklist/remove_filter/cleanup_expiredand the blocklist toggle route. fail2ban does not independently scan the web log, so there is still only one ban list to maintain. - The fail2ban tab exposes: service status, a Mirror application bans to the
host firewall toggle, a Resync bans button (the firewall is also
auto-resynced on apply/restart, since fail2ban flushes bans on restart), and
an optional host SSH (
sshd) jail — clearly scoped as a separate concern from the web ban list, with its own banned-IP view/unban. - New
Fail2banSettingsdatabase model (app_core/_models_settings.py) with Alembic migration20260618_fail2ban_settings, following the project's "settings in the database, not env vars" policy. - New
webapp/admin/fail2ban.pyblueprint (/admin/fail2ban/*): status, configure, resync, service control, and sshd-unban endpoints. IPs are validated before being passed tofail2ban-client. Allsudocalls are non-interactive (sudo -n). The firewall bridge is best-effort and never breaks the application-level ban path, so web bans stay enforced at the app layer even if fail2ban is stopped. - Rewrote the fail2ban tab in
templates/security/security_center.htmland the controller instatic/js/pages/security_center.js. - Added the required
fail2bansudoers entries toconfig/sudoers-eas-stationand addedfail2banto the base package lists ininstall.shandupdate.sh. - Documentation updated:
docs/security/SECURITY.md(one list, two enforcement layers),templates/help.html,docs/reference/dependency_attribution.md, and a discoverability note inrequirements.txt.
- fail2ban ships pre-installed via the base package list in both
[2.112.0] - 2026-06-18 - Attribution page redesigned to match the About page
Changed
- The Attribution page now uses the same polished "stack list" layout as the
About page instead of generic license tables (
templates/attribution.html). The Open-Source Software, Infrastructure, and Frontend Assets sections are grouped into labeled categories (Application Framework, Data Layer, Networking & Parsing, Signal & Audio, Hardware & Displays, Security & Notifications, Runtime & Ops, …). Each entry is a row with a colored gradient icon chip showing the real project brand mark (self-hosted, white-tinted via the shared.stack-item-icostyling), the project name, a one-line description of what it does for EAS Station™, and a compact license pill. Reuses the sharedstack_iconmacro pattern and.stack-*components — no new dependencies and no external requests. The previous flat logo wall and plain<table>credit lists were removed in favor of this. The Data Sources and Acknowledgments sections are unchanged.
[2.111.0] - 2026-06-18 - Project logos on the Attribution page
Added
- Self-hosted project logos on the Attribution page (
templates/attribution.html). The Open-Source Software and Infrastructure sections now lead with a wall of the actual project brand marks (Flask, SQLAlchemy, PostgreSQL, Redis, nginx, Icecast, NumPy, SciPy, …) drawn from the existingstatic/img/tech-stack-logos/*.svgassets. Each mark sits on a fixed dark tile so the white-filled SVGs (built for the shields.io footer badges) stay visible under every theme, light or dark. No new dependencies or external requests — the logos are served locally, consistent with the page's self-hosted philosophy.
Notes
- The About page already renders these marks inline via its
stack_iconmacro + the globalshield_logoscontext, so no change was needed there.
[2.110.0] - 2026-06-18 - Audit log action filter + Configuration Changes quick-link
Added
- "Configuration Changes" quick-link in the navbar Logs menu
(
templates/components/navbar.html) that jumps straight to the audit trail filtered toconfig.updatedevents — the fastest way to answer "who changed a setting?". - Audit-action filter on the Logs → Audit page (
/logs?type=audit&action=…). The/logsroute and both exporters (/logs/export.csv,/logs/export.pdf) now accept anactionparameter applied at the database level so the newest matching rows survive the record limit (webapp/routes_public.py). The audit tab gained an Audit Action dropdown (Configuration Changes, EAS Broadcast, Manual Activation, Alert Deleted, Login Success/Failure, etc.) that preserves the selection and is included in CSV/PDF exports and the Clear Filters control (templates/logs.html).
[2.109.0] - 2026-06-18 - Traffic analytics: purge-all, faster dashboard, clearer auto-purge
Added
- "Purge all traffic data" control. A new Danger Zone in Security Center →
Traffic → Settings wipes the entire
web_request_logstable in one click, resetting every report to zero while keeping your collection settings and GeoIP databases. Backed byPOST /api/traffic/purge-all(system.configure) →traffic_privacy.purge_all()(webapp/routes_traffic.py,app_core/analytics/traffic_privacy.py,templates/security/_traffic_content.html,templates/security/_traffic_scripts.html). Use it to reclaim space and speed up a dashboard slowed by a huge log table.
Changed
- Faster dashboard loads. The Traffic dashboard endpoint assembles ~35
aggregation queries per request and auto-refreshes every 60s / is often open
in several admin tabs at once.
get_full_dashboard()now has an opt-in, short-TTL (30s) in-process cache that collapses those repeat and concurrent loads into a single set of queries. Caching is enabled only on the live dashboard route (use_cache=True); every other caller and the test suite still computes fresh, and the cache is invalidated immediately whenever rows change (purge-all, per-IP purge/anonymize) —app_core/analytics/traffic_stats.py. - Clearer automatic purge-by-age. The retention setting (which already
prunes old rows hourly in the background recorder) is relabeled
"Auto-purge records older than (days)" with guidance to lower it for a
smaller, faster table —
templates/security/_traffic_content.html.
Tests
tests/test_traffic_analytics.py— added coverage for the purge-all route + helper, and the opt-in dashboard cache (fresh when disabled; serves-then- invalidates when enabled).
[2.108.1] - 2026-06-17 - Fail-closed audit for manual EAS purge
Changed
- Purging manual EAS activations is now fail-closed on the audit write.
The
alert.deletedaudit row is written before any activation is removed, and the purge aborts with a 500 if the tamper-evident ledger write fails — a missing audit row can never accompany a real deletion of compliance records (webapp/eas/workflow.py). The send path remains non-blocking by design: by the time it audits, the alert has already physically aired and cannot be un-broadcast. AuditLogger.log()gained araise_on_errorflag (defaultFalse) so callers that must be fail-closed can propagate ledger-write failures, and a failed audit write is now logged at critical level so a missing tamper-evident row is never silent (app_core/auth/audit.py).
Tests
tests/test_audit_config_changes.py— added coverage forraise_on_errorpropagation vs. the default swallow-and-log behavior.
[2.108.0] - 2026-06-17 - Audit configuration changes and EAS transmissions
Added
- Settings changes are now audited. Every settings-save route writes a
tamper-evident
config.updatedrow to the audit ledger (audit_logs) recording who changed the configuration, when, and from which IP — answering "who changed this setting?". Wired into:- Environment variables (
webapp/admin/environment.py,PUT /api/environment/variables) — records the list of changed variable names only; secret values are never written to the trail. - Application settings, including the password policy and retention policy
(
webapp/admin/application_settings.py). - Hardware settings (
webapp/admin/hardware.py). - TTS settings (
webapp/admin/tts.py) — API keys/passwords redacted to field names. - Poller settings (
webapp/admin/poller.py). - Icecast settings and password regeneration (
webapp/admin/icecast.py) — credentials redacted; password rotation recorded as an action only. - Certbot/SSL settings (
webapp/admin/certbot.py). - EAS decoder monitor settings (
webapp/admin/eas_decoder_monitor.py). - ENDEC feed settings (
webapp/admin/endec_feeds.py). - Location settings and alert-filtering settings (
webapp/admin/maintenance.py).
- Environment variables (
- New
AuditLogger.log_config_change(...)helper (app_core/auth/audit.py) as the canonical entry point for settings-change auditing (auto-detects the acting user, IP, and user-agent from the request context).
Changed
- EAS transmissions and purges now land in the tamper-evident audit ledger.
Sending a manual EAS activation (
POST /eas/manual/events/<id>/send) records aneas.broadcastaudit row, and purging activations (POST /eas/manual/events/purge) records analert.deletedrow — both in addition to the existingsystem_logsentries — so the most consequential operator actions are captured alongside every other security event with full user attribution (webapp/eas/workflow.py).
Tests
tests/test_audit_config_changes.py— coverslog_config_changechaining, signing, session-user capture, failure recording, and the new EAS broadcast/delete audit actions.
[2.107.0] - 2026-06-17 - Unified Security Center + real IP-ban enforcement
Added
- Security Center (
/security/center,templates/security/security_center.html,static/js/pages/security_center.js) — a single tabbed page combining four previously-scattered areas: Traffic, Malicious Logins, Banned IPs (IP allowlist/blocklist management with an explicit "Ban IP" action and one-click ban from the attacking-IP stats), and fail2ban configuration. Reachable from Tools → Analytics → Security Center (replacing the standalone Traffic Analytics link) and from the Admin and Settings hubs. - The full Traffic Analytics dashboard is rendered natively in the Traffic
tab (not in an iframe). The former
traffic_dashboard.htmlwas split into reusable partials (templates/security/_traffic_content.html,_traffic_scripts.html,_traffic_styles.html) that the Security Center includes directly; the page's JS is wrapped in an IIFE exporting onlywindow.SC.*handlers so it can never collide with the dashboard's globals. - Visitor map now plots US states, not just countries. A new
get_region_breakdown(app_core/analytics/traffic_stats.py, surfaced asregion_breakdownin the dashboard payload) groups hits by state/region, and the map draws an amber per-state marker (sized by hits) over the blue country markers using a newstatic/js/data/us_state_centroids.jscentroid table, with a Country / US-state legend. Requires a GeoLite2 City database (country-only databases cannot resolve states). - The Traffic tab's heavy full-width dark hero header was replaced with a slim control toolbar (range / refresh / export / settings) so it sits flush under the Security Center tab strip instead of stacking a second header.
Fixed
- Banning now actually blocks access. Previously
IPFilter.is_ip_allowedwas only consulted inside the login POST handler, so a "banned" IP was merely prevented from signing in while it could still browse every page and call every API. A globalbefore_requestgate inapp.pynow denies blocklisted IPs (HTTP 403) across the entire application. Loopback (127.0.0.1/::1) and/static/assets are always exempt so an over-broad ban can never lock the appliance out of itself. Backed by a newIPFilter.is_ip_blockedhelper (app_core/auth/ip_filter.py) that checks only the blocklist — it never applies allowlist-exclusivity — and tolerates timezone-naiveexpires_atvalues so it can never raise inside the per-request gate.
Changed
- The legacy
/security/malicious-loginsand/trafficroutes now redirect to the Security Center (Malicious Logins / Traffic tabs) so traffic has exactly one home; the standalonetemplates/admin/malicious_logins.htmlandtemplates/traffic_dashboard.htmlpages were removed (superseded by the unified page and its traffic partials).
Tests
- Added
tests/test_ip_ban_enforcement.pycoveringIPFilter.is_ip_blocked(exact + CIDR matches, expired-ban deactivation, inactive entries, and the safety property that an allowlist entry must not turn the gate into deny-all).
[2.106.0] - 2026-06-16 - Traffic Analytics: IPv6 reverse-DNS reliability + IPv4/IPv6 breakdown
Added
- IPv4 vs IPv6 breakdown panel on the Traffic Analytics dashboard
(
get_ip_version_breakdowninapp_core/analytics/traffic_stats.py, surfaced asip_version_breakdownin the dashboard payload and a new card intemplates/traffic_dashboard.html). For each address family it reports total hits, unique visitors (IPv6 grouped by /64 so rotating privacy addresses don't over-count), distinct addresses, and reverse-DNS coverage (resolved / ips) — making it obvious at a glance that IPv6 hosts are being seen even when most publish no PTR record. The panel is also included in the CSV export.
Fixed
- IPv6 reverse-DNS lookups no longer get stuck unresolved.
resolve_hostname(app_core/analytics/geo.py) now distinguishes an authoritative "no PTR record" miss (herror/NXDOMAIN, cached) from a transient failure (timeout / temporary resolver error, not cached). The background backfill loop (app_core/analytics/traffic_recorder.py) only adds an IP to its permanent "already tried" set on a definitive outcome, so a slowip6.arpalookup that times out once is retried on a later pass instead of being remembered as a permanent miss. IPv6 PTR lookups also get a longer default timeout (3.0s vs 1.5s for IPv4) to reflect their slower nibble-reversed resolution. The function gained an optionalreturn_status=Truemode exposing theresolved/no_record/error/invalidoutcome.
Tests
- Added IPv6 reverse-DNS regression coverage to
tests/test_traffic_analytics.py(successful v6 PTR, authoritative v6 miss, transient-failure retry semantics, backfill retry-not-blacklist behaviour, and the IPv4/IPv6 breakdown aggregation).
[2.105.0] - 2026-06-16 - Traffic Analytics: custom ranges, drill-down, anomaly detection & privacy tools
Added
- Custom date ranges. The Traffic Analytics window selector gains a
Custom range… option with start/end date pickers. The dashboard endpoint
now accepts
?start=&end=(ISO dates) in addition to?days=. - Drill-down filtering. Clicking a country, path, status family, browser,
OS, HTTP method, or visitor IP — in any table or chart — filters the entire
dashboard to matching traffic. Active filters render as removable chips, and
the larger tables (Top Pages, Top Visitors, Recent Requests, Error URLs) gain
a live text-filter box. All powered by a new shared
TrafficFiltersabstraction (app_core/analytics/traffic_filters.py) threaded through every aggregation intraffic_stats. - Anomaly detection (
app_core/analytics/traffic_anomalies.py). A dashboard banner surfaces elevated error rates, 5xx surges, likely vulnerability scanners (many 4xx from one IP), login brute-force bursts, and traffic spikes. Thresholds are tunable from Settings → Anomaly Detection; the same data is exposed atGET /api/traffic/anomalies. In-app + API only (no coupling to the EAS broadcast notification pipeline). - Privacy / GDPR tools (
app_core/analytics/traffic_privacy.py). Settings → Privacy adds an Anonymize visitor IPs mode (masks addresses at capture time so a raw IP is never stored) and an erase/mask-by-IP tool — Anonymize keeps rows but masks the IP/hostname, Purge deletes them. New endpoints:POST /api/traffic/privacy/purgeand.../anonymize. - New settings columns on
traffic_analytics_settings(anonymize_ip,anomaly_detection_enabled,anomaly_error_rate_pct,anomaly_5xx_threshold,anomaly_failed_login_threshold,anomaly_scanner_threshold) with migration20260616_traffic_privacy_anomaly.
Changed
traffic_statsaggregation helpers now accept an optionalfilters=argument (backwards-compatible: the legacy trailing-dayswindow still works). The full-dashboard payload includesanomaliesandfilter_meta.
[2.104.1] - 2026-06-16 - RBAC audit: close unauthorized settings access for demo/viewer roles
Security
- 80 state-changing routes had no authorization check and were reachable by
any signed-in account — including the read-only
demoandviewerroles. The app enforces authentication globally (app.before_request) but authorization is opt-in per route, and these routes carried neither a@require_permissionnor a@require_roledecorator. Added the appropriate permission decorator to each. Notable exposures now closed:- User & session management (
/admin/userscreate/update/delete,/api/admin/sessions/*) →system.manage_users. Previously any logged-in user could create admin accounts, reset passwords, or delete users (privilege escalation). - Manual EAS (
/manual/generate,/manual/events/<id>/send, purge,/messages/*resend/delete/purge,/admin/eas/manual_generate) →eas.broadcast. - System maintenance (
/admin/operations/backup,/admin/operations/upgrade,/admin/optimize_db,/admin/env_config,/admin/trigger_poll,/admin/location_settings,/admin/eas_settings,/admin/alert_filtering,/admin/import_alert,/admin/mark_expired,/admin/clear_expired,/admin/alerts/<id>PATCH/DELETE), boundaries (upload/clear/preview), and intersection recalculation →system.configure. - Radio receivers (
/api/radio/receivers/*CRUD/restart/diagnostics) and audio sources (/api/audio/sources/*) →receivers.configure; Icecast, audio archives, audio-source acknowledge/resolve, stream profiles, RWT schedule, EAS-monitor control, ENDEC/decoder-monitor settings, and diagnostics →system.configure(RWT test →eas.broadcast).
- User & session management (
- Four permission decorators referenced permissions that do not exist in
PermissionDefinition, so the routes were unreachable by everyone, including admins. Remapped to defined permissions:security.manage→system.manage_users(RBAC management API,routes_security.py),system.view→system.view_config,analytics_manage→system.configure(routes_analytics.py), andsystem.edit_config→system.configure(routes_ipaws.py).
Added
tests/test_rbac_route_coverage.py— a static-analysis regression test that fails if any state-changing route (POST/PUT/DELETE/PATCH) lacks an authorization decorator (@require_permissionor@require_role; bare@require_authdoes not count), or if any decorator references a permission not defined inPermissionDefinition.
[2.104.0] - 2026-06-16 - Traffic Analytics: fix PDF export, file-type logos, login-IP reverse DNS
Fixed
- "Export → PDF report" failed with "PDF export failed". The export renders
the dashboard with html2canvas, but the Leaflet visitor map paints
cross-origin OpenStreetMap tiles, which taint the capture canvas and make
toDataURL()throw aSecurityError. html2canvas now skips the map (and any Leaflet tile panes) during capture viaignoreElements; everything else on the page is same-origin, so the multi-page A4 PDF builds cleanly. The failure toast now also includes the underlying error message for easier diagnosis. - README "Stratum 1 — GPS/PPS" badge linked to a dead anchor. The badge
pointed at
#a-broadcast-grade-time-source-built-in, but the heading is "Timekeeping — A Broadcast-Grade Time Source Built In", so GitHub's anchor is#timekeeping--a-broadcast-grade-time-source-built-in. Updated the link.
Added
- True-colour brand/language logos for file types. The Traffic Analytics
"File Types" table previously showed generic FontAwesome file glyphs. Common
developer file types now render their real brand marks (PHP, JavaScript,
Python, JSON, SVG, Markdown, HTML, CSS) from self-hosted SVGs under
static/img/logos/, matching the existing browser/OS logo treatment. Types without a dedicated logo keep the coloured glyph fallback. - Reverse-DNS hostnames + country flags in the Login Security tables. "Top Login Source IPs" and "Recent Logins" came from the audit log (which has no hostname) and so always showed bare IPs while the rest of the dashboard showed resolved hostnames. Those login IPs are now enriched with the PTR hostname and GeoIP country already resolved for the same address in the web-request log, so every IP across the dashboard renders consistently.
[2.103.2] - 2026-06-16 - Fix Traffic Analytics Export dropdown clipped to one item
Fixed
- Export dropdown only showed "Excel / CSV"; the "PDF report" item was hidden.
The global
.page-headerusesoverflow: hiddento clip its decorative glow, which also clipped the Export menu that opens inside the Traffic Analytics header. The appliance-style header doesn't use that glow, so it now drops the::beforedecoration and allows overflow, letting the full menu show.
[2.103.1] - 2026-06-16 - Stop /api/broadcast/state flooding logs with 401s
Fixed
/api/broadcast/statereturned 401 on every unauthenticated poll.base.htmland the navbar poll this endpoint on every page (a 1.5s WebSocket-fallback) to drive the global air-chain broadcast overlay — including the login page and after a session expires. It was missing from the deny-by-default public GET allowlist, so each poll hit the auth gate and logged a 401, accumulating tens of thousands of errors from any browser tab left open pre-login or on an expired session. Added it toPUBLIC_API_GET_PATHS(the payload — active flag, active-alert count, timestamp — is non-sensitive and less revealing than the already-public/api/alertsand/api/system_status).
[2.103.0] - 2026-06-16 - Traffic Analytics: reverse-DNS backfill + self-diagnosing error sources
Added
- Top Error Sources now shows what each noisy IP errors on. Every row in
the Errors & Scanners → Top Error Sources table (and the CSV export) is
annotated with that source's single most-frequent error path, its status code,
and the hit count — so a scanner probing
/wp-login.php(404) is instantly distinguishable from a dashboard hammering a403/500endpoint, without cross-referencing the Top Error URLs table.
Changed
- Reverse-DNS hostnames now backfill over time. Hostnames were resolved only once, at insert; rows captured before "Resolve hostnames" was enabled, during a transient DNS hiccup, or for an IP that only later published a PTR record stayed a bare IP forever. A background pass (every 10 min, when the setting is on) now retries the most-recently-active unresolved IPs in bounded batches and fills in their hostname, while remembering no-PTR addresses so they aren't re-queried each pass. IPs that genuinely have no PTR record still show as a bare address (expected).
[2.102.0] - 2026-06-16 - Dedicated Attribution & Credits page
Added
- Dedicated, branded
/attributionpage consolidating all open-source credits in one predominantly-linked place: the project's own AGPL-3.0 / Commercial dual license and required attribution string, the full Python dependency stack, infrastructure & system packages, frontend assets, and public data sources & standards. Surfaced from the Help dropdown (Help → Attribution & Credits) and the footer's Legal & Info column. - Canonical
docs/reference/dependency_attribution.mdreference document. This file was referenced throughout the codebase (CLAUDE.md, the About page) but had never actually been created, leaving a broken link on/about. It is now the authoritative, version-pinned attribution record and ships in the docs site (viewable in-app at/docs/reference/dependency_attribution).
Fixed
- Broken attribution link on the About page. The "dependency attribution documentation" callout pointed at a GitHub file that did not exist; it now links to the new in-app Attribution & Credits page.
[2.101.1] - 2026-06-16 - Fix Traffic Analytics sparklines stretching the page
Fixed
- Traffic Analytics page rendered as tall vertical streaks. The new per-tile
sparklines were responsive Chart.js canvases (
maintainAspectRatio:false) appended directly into a status tile with no fixed height, so each canvas grew its own height every layout pass and stretched the whole page. Sparkline canvases now live in a fixed-height (26px)position:relativewrapper, with a hardmax-heightsafety net on any canvas inside an at-a-glance tile.
[2.101.0] - 2026-06-16 - Traffic Analytics: logos everywhere, visitor map, new reports, IPv6 & state
Added
- Self-hosted logos across every report. Traffic Analytics now renders brand marks for file types (coloured FontAwesome file glyphs by extension), robots/spiders (Googlebot, Bingbot, GPTBot, AhrefsBot, uptime monitors, …), languages (Accept-Language → country flag), referrers and search engines (Google/Bing/DuckDuckGo/YouTube/Reddit/GitHub/…). All marks use bundled FontAwesome/flag assets — no external favicon or CDN calls, matching the existing browser/OS logos.
- Visitor world map. A new Leaflet map plots a proportionally-sized marker
over each country in the breakdown (reuses the bundled flag SVGs; tiles from
OpenStreetMap as the other maps already do). Backed by a new
static/js/data/country_centroids.jslookup. - New breakdown reports: Device Types (Desktop/Mobile/Tablet), HTTP Methods, Authenticated vs Anonymous, Search Engines, and a Slowest Endpoints table (avg/max server response, min-3-hits) — all added to the dashboard, CSV export, and the JSON payload.
- Bounce rate tile (single-page visits) and "vs previous period" deltas
with sparklines on the at-a-glance status tiles, powered by a new
summary_prevprevious-window summary. - State / region for cities.
classify_locationnow reads the most-specific subdivision from a GeoLite2 City database, stored in newweb_request_logs.region/region_codecolumns (migration20260616_traffic_region). "Top Cities" now reads e.g. "Springfield, IL" and disambiguates same-named cities.
Changed
- IPv6 visitor counting. Unique-visitor and time-series visitor counts now
group IPv6 addresses by their /64 prefix (new
geo.network_key), so a single device's rotating privacy/temporary addresses are no longer counted as many visitors. IPv4 is unchanged; GeoIP country/city/ASN and reverse-DNS already worked for IPv6 (theipaddressmodule and GeoLite2 are dual-stack).
[2.100.0] - 2026-06-16 - Stream auth + clear 403 reporting
Added
- Stream URL preflight test — a new
POST /api/audio/sources/test-streamendpoint and a Test button next to the Stream URL field on the Audio Monitoring add/edit forms. It actually connects to the stream and reports the real HTTP status, so an authentication-gated stream (e.g.https://15693.live.streamtheworld.com/WMJVFMAAC.aac) returns a clear "403 Forbidden — stream requires authentication or is access restricted" message instead of a generic error. Adding a stream now runs this preflight automatically and blocks creation on a hard failure (bypassable withskip_url_check=true). - Stream authentication — stream sources accept optional credentials via new
device_params:auth_username/auth_password(HTTP Basic) or a rawauth_headervalue (e.g.Bearer <token>), exposed as optional fields on the add/edit forms. Credentials are applied to the FFmpeg request headers, playlist resolution, ICY-metadata polling, and the preflight test. Stored passwords are redacted from API responses (auth_password_setboolean) and preserved on edit when the password field is left blank.
Changed
- Stream sources no longer retry forever on HTTP
401/403/404. FFmpeg now only auto-reconnects on transient5xxerrors; auth/availability failures are marked fatal, stop the restart loop, and surface the HTTP status as the source'serror_messagein the UI status card.
[2.99.0] - 2026-06-16 - awstats parity: visits, file types, search, robots + City/ASN GeoIP
Added
- Visits, entry & exit pages. Non-bot requests are sessionised per host (30-minute inactivity timeout) into "visits", with Entry Pages, Exit Pages, a Visits count and Avg Visit Duration tile — awstats' visit/navigation reports.
- File Types report (hits grouped by extension), Search Keyphrases
(keywords parsed from search-engine referrers), and a Robots / Spiders
report (bot traffic grouped by friendly name via a new
classify_bot). - GeoLite2 City & ASN support. The dashboard now uses City databases
(adds Top Cities) and ASN databases (adds Top Organizations / ISPs).
New
web_request_logs.city/asn_orgcolumns, a separategeoip_asn_database_pathsetting, andgeo.resolve_asn(). - One uploader for all three GeoIP databases. The upload control auto-detects the database type (Country/City/ASN) from its metadata and routes it to the correct slot automatically — Country and City share the location slot; ASN gets its own. The GeoIP status badge reports both.
- Open-source attribution surfaced in-app. The About page's Software Stack
now lists
geoip2+ MaxMind GeoLite2 (with the required attribution) andflag-icons; the same MaxMind attribution appears in the Settings dialog.
[2.98.0] - 2026-06-16 - Real flag images, referrer accuracy & noise filtering
Fixed
- Country flags now render on every OS. They were emitted as Unicode emoji,
which Windows/Edge does not display as flags (it shows the two letters). Flags
are now real images served locally from bundled
flag-iconsSVGs (static/vendor/flags/, MIT-licensed, ~250 countries) — no emoji, no CDN calls. Used in Countries, Top Visitors, and Top Error Sources. - Referrers are now meaningful. The Top Referrers report grouped raw full URLs and was dominated by internal navigation. It now groups by domain and excludes self-referrals (the station's own host), matching awstats' external-referrer report.
Added
- Internal-traffic noise filtering (awstats-style SkipHosts/SkipFiles). New
settings: Exclude internal (loopback) traffic (on by default — drops
server-internal
127.0.0.1/::1service calls that aren't real visitors) and a Skip paths list for ignoring additional path prefixes. - GeoIP status indicator in Settings — shows at a glance whether the reader package is installed, a database is loaded, and a sample public-IP lookup resolves, so missing flags are easy to diagnose (e.g. "geoip2 not installed", "no database configured", or "Active — 8.8.8.8 → United States (US)").
[2.97.4] - 2026-06-16 - Fix broken logo URLs (cache-buster in path)
Fixed
- Traffic Analytics browser/OS logos 404'd/403'd because the static URL helper
appends a
?v=<version>cache-buster toLOGO_BASE; appending the per-file name produced…/logos?v=2.97.3/chrome.svg(the filename landed in the query string, so the browser requested the directory → 403). The base now strips the cache-buster and re-appends?v=after the filename, yielding correct…/logos/chrome.svg?v=2.97.3URLs.
[2.97.3] - 2026-06-15 - Multicolor browser/OS logos
Changed
- Traffic Analytics now renders true multicolor brand logos for browsers
(Chrome, Firefox, Edge, Safari, Opera) and operating systems (Windows, Apple,
Android, Linux/Tux) as bundled SVG assets under
static/img/logos/, replacing the single-color FontAwesome glyphs. The colored FA glyph remains the automatic fallback for any agent without a dedicated logo (e.g. Samsung Internet, Internet Explorer). Logos appear in the Browsers and Operating Systems tables and the Recent Requests column. The marks are original simplified renditions, so nothing extra is bundled or fetched at runtime.
[2.97.2] - 2026-06-15 - Brand-coloured browser/OS logos
Changed
- Traffic Analytics now tints the browser and operating-system logos their
official brand colours (Chrome blue, Firefox orange, Android green, Windows
blue, etc.; Apple/Linux use neutral tones so they stay legible on dark
themes) instead of rendering them as flat grey glyphs. Confirmed the
FontAwesome brand webfont (
fa-brands-400) ships locally so the logos render offline. - Added a Browsers table (with logos) to the Visitor Environment section so browser logos have a home alongside the existing bar chart; the Recent Requests browser column is brand-coloured too.
[2.97.1] - 2026-06-15 - GeoIP database dependency & resilient upload
Fixed
- GeoIP upload failed with "No module named 'maxminddb'".
geoip2(which provides themaxminddbreader) was an optional, commented-out dependency, so on a default install the upload validation hard-failed and country/flag resolution could never work.geoip2>=4.8.0is now a first-class requirement. - Upload no longer hard-fails when the reader is missing. The upload route
validates with the
maxminddbreader when available and otherwise falls back to the MaxMind DB magic-marker check, so a valid.mmdbis accepted and stored even before the package is installed — with a clear note thatpip install -r requirements.txt+ restart is needed for flags to resolve.
[2.97.0] - 2026-06-15 - Traffic analytics: GeoIP upload, graphics, errors & exports
Added
- Upload the GeoIP database from the browser. Traffic Analytics → Settings
now has a file-upload control that accepts a MaxMind GeoLite2
.mmdb, validates it by opening it withmaxminddb, stores it underdata/geoip/, and points the settings path at it automatically — no shell access required. NewPOST /api/traffic/geoip/uploadroute;data/geoip/is gitignored (MaxMind's license forbids redistribution). - Browser version + User-Agent reporting.
classify_user_agent()now parses the browser version (e.g.Chrome 120,Safari 16.1) into a newweb_request_logs.browser_versioncolumn, and a new Recent Requests table surfaces method/path/status/browser and the full User-Agent string. - Graphics for OS & Browser — Operating Systems is now a doughnut chart alongside Browsers, and OS/Browser tables carry FontAwesome brand icons (Chrome/Firefox/Edge/Safari, Windows/Apple/Linux/Android), awstats-style.
- Errors & scanners report — Top Error URLs (4xx/5xx by path + status) and Top Error Sources (IPs generating the most errors, with hostname/flag), so bot/scanner 404 noise is legible at a glance.
- Bandwidth + when-they-visit metrics — a Bandwidth tile (total + avg/req
from
content_length) plus Hits-by-Hour-of-Day and Hits-by-Day-of-Week histograms. - Export reports to CSV (Excel) and PDF. A new Export menu produces a multi-section CSV of every report and a multi-page A4 PDF (charts + tables) rendered client-side with the already-vendored jsPDF + html2canvas.
[2.96.0] - 2026-06-15 - Traffic analytics: reverse DNS, countries & flags
Added
- Reverse-DNS hostnames (awstats-style "Hosts"). A new opt-in toggle
(Traffic Analytics → Settings → "Resolve hostnames (reverse DNS)") makes the
background recorder resolve each visitor IP to its PTR/hostname. Lookups run
off the request path in the flush thread, are bounded by a short socket
timeout, and are cached positively and negatively so a given address is
looked up at most once. The Top Visitors table now shows the hostname above
the raw IP. New
web_request_logs.hostnamecolumn andtraffic_analytics_settings.resolve_hostnamesflag. - Country flags. When a MaxMind GeoLite2 database is configured, public
visitor IPs now resolve to an ISO 3166-1 alpha-2 country code in addition to
the country name (new
web_request_logs.country_codecolumn). The Countries/Networks and Top Visitors tables render the matching flag emoji (via Unicode regional-indicator symbols — no image assets), with a network-wired icon as the fallback for local/unresolved addresses. app_core/analytics/geo.pygainsclassify_location()(label + ISO code) andresolve_hostname()(cached, timeout-bounded reverse DNS);classify_ip()is retained as a thin string-only wrapper.
Changed
get_top_visitors()andget_country_breakdown()now carry hostname and country-code data so the dashboard can render hosts and flags.
[2.95.0] - 2026-06-15 - Traffic analytics dashboard & real client IPs
Fixed
- Logins/sessions no longer all show as
localhost. The app runs behind nginx, which connects to Gunicorn over127.0.0.1, sorequest.remote_addralways read as localhost and everyadmin_sessionsrow recorded that as the source IP. Wrapped the WSGI app in Werkzeug'sProxyFix(trusting exactly one proxy hop) sorequest.remote_addrnow reflects the real client address fromX-Forwarded-For. This corrects Active Sessions, audit logs, and the new traffic dashboard in one place (app.py).
Added
- Traffic Analytics dashboard (
/traffic, Tools → Analytics → Traffic Analytics) — a webalizer/awstats-style view of web traffic, styled to match the System Health and GNSS appliance dashboards (status strip, tiny-caps section heads, themed cards). Shows hits/page-views/unique-visitors over time, top pages, top visitors, status-code mix, browsers, operating systems, screen resolutions, locations/networks, languages, and referrers — plus a login security section (successful/failed logins over time, top source IPs, recent logins, active sessions). - In-app request logging. A new
web_request_logstable records each non-static request via a buffered background recorder (after_requestonly appends to an in-memory buffer; a daemon thread bulk-inserts and prunes past the retention window), so the request path never pays for a synchronous DB write. New modules:app_core/analytics/web_traffic.py,traffic_recorder.py,traffic_stats.py,geo.py; routes inwebapp/routes_traffic.py. - Screen-resolution capture via a tiny per-session client beacon
(
static/js/core/traffic-beacon.js→GET /api/traffic/client), since screen size isn't available in HTTP headers. - Optional GeoIP country resolution. Public visitor IPs resolve to country
names when a MaxMind GeoLite2
.mmdbpath is configured in the dashboard's settings and the optionalgeoip2package is installed; otherwise addresses are classified as Local Network / Internet (Public) with no network calls. - Web-UI collection settings (
traffic_analytics_settingstable): enable or disable collection, retention days, whether to log API requests, authenticated -only mode, bot exclusion, and the GeoIP database path — all from the dashboard.
[2.94.2] - 2026-06-15 - One-tap Actions run cleanup
Added
Cleanup Old Workflow Runsworkflow (.github/workflows/cleanup-runs.yml) — a manually-dispatched maintenance tool that deletes accumulated Actions run history server-side, keeping only the newest N runs (default 50). Supports adry_runpreview mode and reports per-run progress in the job log, so the backlog can be cleared from the GitHub mobile app without any localghCLI. Safe to delete the file once the backlog is cleared.
[2.94.1] - 2026-06-15 - Actions/CI junk cleanup
Changed
Update Repository Statisticsworkflow no longer commits to the repo. It previously ran on every push tomain/developand on a daily cron, auto-committingUpdate repository statistics [skip ci]— the single largest source of automated workflow-run history and bot commit churn. It now runs on the daily schedule (or manually viaworkflow_dispatch) and only reports whetherstatic/repo_stats.htmlhas drifted; refresh it locally withpython scripts/generate_repo_stats.pyand commit the result. Dropped the now-unneededcontents: writepermission (.github/workflows/update-repo-stats.yml).- The GitHub Pages documentation build now sets
retention-days: 1on its uploaded Pages artifact so transient build output expires promptly instead of consuming Actions storage (.github/workflows/docs-pages.yml).
[2.94.0] - 2026-06-15 - Cancelled-alert tracking
Added
- Explicit cancellation tracking for CAP alerts. When a watch or warning is
cancelled early (CAP
msgType=Cancelor VTEC actionCAN) rather than simply reaching its expiry, the alert is now recorded as Cancelled with a newcancelled_attimestamp instead of being indistinguishable from a natural expiry. The originalexpiresvalue is preserved so the alert detail page can show that the event was lifted early (newcancelled_atcolumn oncap_alerts, migration20260615_cap_alert_cancelled_at;app_core/_models_alerts.py). - New
is_cancellation()andterminal_chain_updates()helpers inapp_utils/vtec.pycentralise the cancel-vs-expire decision, covered by unit tests intests/test_vtec_parsing.py.
Changed
- The CAP poller now flags cancellations whether they arrive as a follow-on
VTEC product (handled in
_mark_vtec_chain_superseded) or as an update to an existing CAP identifier (_apply_cancellation_status), in both the insert and update paths (poller/cap_poller.py). - The alert detail page shows a distinct CANCELLED badge (with the lift
time) and SUPERSEDED badge, instead of always labelling ended events
"EXPIRED" (
templates/alert_detail.html). - Cancelled alerts are excluded from the active-alerts query alongside expired
ones (
app_core/alerts.py).
Display/tracking only — this release does not change what is sent to air. Cancellations remain suppressed from broadcast by the existing auto-forward terminal-VTEC guard.
[2.93.2] - 2026-06-15 - README screenshot tour
Added
- Screenshot tour in the README. Added a
## 📸 Screenshot Toursection with eight dashboard screenshots (operational dashboard, alert timeline, statistics/analytics, EAS compliance, tamper-evident audit ledger, SDR diagnostics, GPS & time / stratum-1, and system health) so visitors can see the web UI before installing. Images live underdocs/screenshots/and were captured from a running instance with sample alert data (README.md,docs/screenshots/*.jpg).
[2.93.1] - 2026-06-14 - Public docs, help & sponsorship pages
Fixed
- Documentation, help and sponsorship pages no longer require sign-in. The
deny-by-default
before_requestallowlist inapp.pydid not include the documentation viewer, so the entire/docstree — the Markdown guides, the Theory of Operation and the docs search — plus the/supportsponsorship page and/repo-statsredirected anonymous visitors to the login screen. These are public information pages and are now reachable without authentication./docsand/docs/<path>(covering Theory of Operation and search),/support, and/repo-statswere added to_PUBLIC_PAGE_PATHS/_PUBLIC_PAGE_PREFIXES; admin, settings and API routes remain protected (app.py,tests/test_public_pages_authz.py).
[2.93.0] - 2026-06-14 - Active session expiry & cleanup
Fixed
- Active Sessions no longer accumulate forever. An
admin_sessionsrow was created on every login but only ever closed on an explicit logout or a manual "Terminate" click. Because users almost always just close the tab (or let the cookie expire), every login left an orphaned row showing as "Active" — the monitor reported 100 "active" sessions for a single admin going back weeks. Sessions now expire automatically once idle past the cookie lifetime (SESSION_LIFETIME_HOURS, default 12h) and are marked withended_reason='expired'(app_core/auth/session_tracking.py).
Added
- Session heartbeat tracking. The global
before_requesthook now refresheslast_seen_atfor the active administrator's session (throttled to one write per 60s) and runs an inline sweep that closes idle rows, so stale sessions are reaped even when nobody opens the monitoring page (app.py,app_core/auth/session_tracking.py). - Lazy expiry on view.
GET /api/admin/sessionsnow sweeps stale rows before returning, so opening or refreshing Tools → Active Sessions always shows an accurate count (webapp/admin/dashboard.py). - Bulk "Terminate All Others" control. A new button on the Active Sessions
page plus
POST /api/admin/sessions/terminate-bulk(scope=others/all) lets an admin clear the backlog in one click without signing themselves out. The page also now shows a Last Active column (templates/admin/sessions.html).
[2.92.0] - 2026-06-14 - Navbar cleanup + Lightning theme readability
Changed
- Navbar Settings dropdown slimmed down. The single "Settings" menu had
grown to seven sections (Configuration, Personalization, Observability, Logs
& Reports, Analytics, Testing, Data Continuity) and duplicated links found
elsewhere. It now contains only genuine configuration — System Settings
(the
/settingshub) and Display Units personalization (templates/components/navbar.html). - New top-level "Tools" menu. Observability (System Diagnostics, Health Dashboard, IPAWS Poller Debug), Analytics (Analytics Dashboard, Active Sessions, Operations Report), Testing & Validation (Audio Tests, Alert Verification) and Data Continuity (JSON/CAP/CSV export, County Boundaries) moved out of Settings into their own "Tools" dropdown. No features were removed — every link remains reachable.
- New top-level "Logs" menu. Per request, logging is now its own category instead of living under Settings: All Logs, Audit Log, Compliance Log and FCC Reports.
Fixed
- Removed duplicate "Display Units" entry that appeared in both the Settings and Help dropdowns; it now lives only under Settings → Personalization.
- Removed duplicate "Backup Manager" entry from the navbar; backups remain
accessible via Settings → System Settings → System (
/admin/backups). - Lightning theme readability: card-header titles/icons rendered white on
the bright yellow→cyan gradient used by
.card-header.bg-primary,.bg-infoand.bg-secondary(e.g. the "EAS Continuous Monitor" header on the Audio Monitoring page). The global.card-header.bg-* *rule forced every descendant to white while the theme only re-inked the header element itself. The lightning override now re-inks the descendants too (static/css/styles.css). - Undefined CSS surface variables caused contrast bugs across every theme.
Several pages referenced theme variables that no palette actually defines,
so they silently fell back to flat values that broke contrast on some themes:
- GPS & Time dashboard (
templates/admin/gps_dashboard.html) painted its header banner and section cards with the undefined--bg-secondary, which fell back to a near-invisiblergba(127,127,127,.04)tint that vanished on dark themes (lightning, midnight, obsidian…), leaving the header reading as bare text on the page.--bg-secondaryis now aliased to the theme's defined--surface-color, scoped to the dashboard so other pages are unaffected. - Display Units popover (
.eas-units-popover,static/css/styles.css) fell back to a fixed dark-navy background on all themes, producing dark-text-on-dark on the light themes (Cosmo and friends). Now uses--surface-color. --card-bgtypo — certbot, tailscale, icecast, screens and alert-detail pages usedvar(--card-bg)(which no theme defines) instead of--bg-card, so those surfaces rendered transparent on every theme. Corrected to--bg-card.--text-primarytypo on the dashboard highlight chip (templates/index.html) corrected to--text-color.network.htmlsecurity/interface badges usedvar(--bg-secondary)with no fallback (transparent on every theme); now use--surface-color.
- GPS & Time dashboard (
- Full codebase sweep for the same undefined-variable class of bug. Audited
every
var(--…)reference against the variables the theme palettes actually define and fixed all remaining no-fallback or wrong-mode-fallback usages:screen-editor.cssreferenced an entire--color-*token set (--color-info,--color-info-dark,--color-info-light,--color-warning,--color-neutral-200,--color-text-inverse) that was never defined, so the editor's info buttons, warning badges, focus rings and neutral surfaces lost their colour. These tokens are now defined (with a dark-mode override for the neutral surface).- Hover states that fell back to a fixed light
#f8f9fa(county_boundaries.html,zones.html,icecast.html,system_logs.html,system_health.html) turned table/list rows near-white on dark themes, making light text unreadable on hover. Replaced with theme-awarecolor-mix(... var(--primary-color) …)tints. rgba(var(--primary-rgb)/--primary-color-rgb, …)tints (alert_feeds.html,settings_hub.html,nav-tabshover instyles.css) always rendered a fixed indigo/blue regardless of theme; converted tocolor-mixagainst--primary-color.- Other typo'd variables corrected to their real names
(
--muted-text→--text-muted,--primary-hover→--primary-dark,--input-bg/--surface-raised→--surface-color/--bg-card).
- Bootstrap
--bs-*variables made theme-aware (invisible breadcrumb fix). The app never setsdata-bs-theme, so anyvar(--bs-…)reference in our own code fell back to Bootstrap's light defaults on the custom dark themes (lightning, midnight, …). Most visibly, the JS-injected breadcrumb trail (.eas-breadcrumbsinstatic/js/core/nav-enhance.js) drew a near-white bar from--bs-tertiary-bgwhile its text inherited the theme's light colour — rendering the whole "Dashboard › Help › …" trail invisible. Reworked all of our--bs-*colour/background references to use the app's own theme tokens first, with the--bs-*value kept only as a fallback:- Breadcrumb trail + command palette (
nav-enhance.js) now use--surface-color/--text-color/--border-color/--text-muted(the palette's active row stays Bootstrap-blue with white text on purpose). - SDR audio-monitor panels (
templates/admin/radio.html) and the SAME-header / raw-metadata code blocks (templates/audio_detail.html) used--bs-body-bg/--bs-body-secondary-bg/--bs-secondary-bg(light) and now use--surface-color/--bg-card. - Muted helper text on
templates/gpio_pin_map.htmlandtemplates/components/confidence_scale.htmlnow uses--text-muted.
- Breadcrumb trail + command palette (
[2.91.1] - 2026-06-14 - Alert Purge: batched deletes + Admin panel relocation
Fixed
- Large purges no longer silently fail. A purge covering thousands of
audio-heavy received alerts (e.g. ~2,600 rows / 5.6 GB) was executed as a
single
DELETE/SUMstatement, which routinely exceeded the 30-second PostgreSQLstatement_timeoutconfigured for the web connection (app.py). Postgres aborted the statement and rolled the whole transaction back, so nothing was deleted and the UI appeared to do nothing.app_core/alert_purge.execute_purge()now deletes / strips audio in batches ofPURGE_BATCH_SIZE(200) rows and commits each batch, so every statement stays well under the timeout and progress is durable even if the request is later interrupted. Byte accounting is summed per batch via the new_sum_audio_bytes()helper. The manual-purge summary now also reports thebatchescount.
Changed
- Alert Purge moved from the Settings navbar dropdown to the Admin panel.
To declutter the navbar, the link now lives on Admin → Operations as an
"Alert Purge" card (
templates/admin.html) instead of Settings → Data Continuity (templates/components/navbar.html). The page URL (/admin/alert-purge/) is unchanged. - The Alert Purge page surfaces request failures clearly instead of swallowing
them: gateway timeouts / non-JSON responses (HTTP 502/504) now show an
actionable toast explaining the purge runs in batches and can be re-run or
narrowed (
templates/admin/alert_purge.html).
Added
- Regression tests in
tests/test_alert_purge.pycovering multi-batch full purges and multi-batch audio-only strips.
[2.91.0] - 2026-06-14 - Received-alert purge system
Added
- A single Alert Purge admin page (
/admin/alert-purge/,webapp/admin/alert_purge.py,templates/admin/alert_purge.html) to remove received alerts and their captured audio from one place. Reachable from Admin → Settings → Alert Purge in the navbar and from a new Purge button on the Received Alerts page (templates/audio_received.html).- Filter by age (older than N days), source, forwarding decision (e.g. everything not forwarded), and/or SAME event code.
- Scope selector: Audio only strips the stored
raw_audio_dataWAV but keeps the row for the FCC compliance log; Entire alert deletes the row and can also delete the generated EAS broadcast message and its on-disk audio files so no instance of the audio remains. - Preview shows the matching record count and reclaimable audio size before anything is deleted, and a storage-overview header summarizes current usage.
- Automatic purge (
AutoPurgeSettingsmodel,AutoPurgeScheduler) that runs the same rules in the background shortly after startup and roughly every six hours, configurable on the same page and disabled by default. Wired intoapp.pyalongside the existing retention scheduler. - Purge service
app_core/alert_purge.pywith preview/stats/execute primitives shared by the manual action and the scheduler, plustests/test_alert_purge.pycovering criteria validation, previews, both scopes, generated-message cleanup, the empty-criteria guard, and auto-purge. - Migration
20260614_add_auto_purge_settings.pyadds theauto_purge_settingstable.
Notes
- Purging alerts never purges logs — every purge writes a
SystemLogaudit entry recording exactly what was removed. This is distinct from the existing retention sweep, which only ever strips audio and keeps the compliance row.
[2.90.0] - 2026-06-13 - Dedicated Support page
Added
- A dedicated
/supportpage (templates/support.html,support_pageroute inwebapp/routes_public.py) that consolidates the project's funding messaging into one themeable, self-contained page: a Ko-fi call-to-action, a "Where Your Support Goes" breakdown, and an "Other Ways to Help" section (star, file issues, contribute). No external scripts are loaded — it reuses the vendoredstatic/img/kofi.svg. - Navigation entry points so the page isn't buried: a "Support the
Project" item in the navbar Help dropdown
(
templates/components/navbar.html), a card on the Site Navigation page (templates/site_navigation.html), and a "More ways to help" link from the existing About card (templates/about.html).
Changed
- Footer support button now links to the new
/supporthub instead of jumping straight to Ko-fi, and is relabeled "Support the Project" (templates/base.html). - Ko-fi cup icon renders as a clean white monochrome mark on the blue
buttons (footer, support page, About card) via a CSS filter, matching
Ko-fi's own button style — the source
kofi.svguses the same blue for its strokes/saucer, so it previously blended into the button background.
[2.89.0] - 2026-06-13 - Feature the Ko-fi link prominently
Changed
- Ko-fi support link is now featured prominently instead of being buried
on the About page.
- Footer (
templates/base.html): added a styled "Support on Ko-fi" button to the footer brand column on every page, beneath the copyright line. New.footer-kofi-btnstyles instatic/css/styles.cssuse the Ko-fi brand color (#29abe0), the vendoredstatic/img/kofi.svgcup mark (no external asset fetch), and theme-aware shadow variables. - README: added a Ko-fi badge to the badge block at the top and a dedicated "❤️ Support the Project" section linking to ko-fi.com/easstation and the repository Sponsor button.
- The existing "Support the Project" card on the About page is unchanged.
- Footer (
[2.88.1] - 2026-06-13 - Font-independent footer app icon
Fixed
- Footer app logo now renders reliably on every browser/OS
(
static/img/eas-app-icon.svg). The "EAS" / "STATION" / "™" wordmark previously relied onArial Black/Montserrat/Impactbeing installed; when they were absent the browser silently substituted a generic font, so the footer mark looked inconsistent across machines. The wordmark is now embedded as vector<path>outlines (sourced from the open-licensed Archivo Black), sized and positioned to match the canonical brand icon, with the ™ tucked tight against the top-right of the "S". No font file is shipped — only the outlines — so the icon is fully self-contained. The equalizer bars were already pure vector and are unchanged.
[2.88.0] - 2026-06-13 - GPS TDOP (timing geometry)
Surfaces TDOP — the Dilution of Precision term that bounds timing accuracy — across the GPS & Time dashboard, since that is the DOP that matters most for the PPS / chrony discipline an EAS station relies on. Idea borrowed from the ChroGPS Dash stratum-1 project, which plots all four DOP terms together.
Added
- TDOP capture in
app_core/gps/gps_manager.py— the gpsdSKYhandler now readstdopalongsidehdop/vdop/pdopand publishes it on the GPS status dict. (NMEA GSA omits TDOP, so it staysNoneon direct-NMEA receivers and only populates in gpsd mode — surfaced honestly.) - TDOP trend sampling in
services/gps/trends.py— added to the per-sample row and the rollup averaging field list so the tiered archive keeps TDOP history alongside the other DOP terms. - TDOP on the dashboard (
templates/admin/gps_dashboard.html) — the hero readout is nowDOP(H/V/P/T), the DOP History sparkline gains a fourth (cyan) TDOP series with its own min/now/max badge, and the DOP help cards explain why TDOP is the term to watch for clock accuracy.
Changed
- Fix-quality gotcha documented — added a note on the
_FIX_QUALITYmap inapp_core/gps/gps_manager.pythat u-blox timing receivers (e.g. NEO-M8T) with SBAS enabled can make gpsd misreport a genuine TIME/PPS fix as a DGPS fix, so the dashboard's fix-quality readout isn't mistaken for a fault. (Insight from the ChroGPS Dashconfigure_m8t.pyconfiguration.)
[2.87.0] - 2026-06-13 - Animated header & navbar polish
Built on the previous header refresh with a round of motion and "ops-console"
touches across the navbar. Everything is theme-aware and every ambient
animation is gated behind prefers-reduced-motion.
Added
- Breathing "aurora" sheen behind the navbar — a slow-drifting translucent
white gradient layered over the themed background (
soft-lightblend), so the header subtly comes alive without changing any theme colours. - Live header clock in the brand cluster (US/Eastern), a NOC-style time +
date readout driven by the existing 1-second tick in
static/js/core/utils.js. Hidden on narrow viewports to keep the navbar on a single row. - Active-page indicator — the current top-level nav item now lights up
(existing
.nav-link.activepill plus a new sliding underline). Longest-prefix matching lights up a parent menu for deep pages and setsaria-current="page". - Scroll-aware navbar — wired up the previously-unused
.navbar.scrolledstyle so the header shrinks and deepens its shadow once the page scrolls. - "On Air" navbar glow — when a broadcast is live the whole header gains a pulsing red top edge + soft glow, mirroring the stack light's on-air state.
- Brand wordmark shimmer — a one-time diagonal light sweep across the logo on first paint, repeated on hover.
Changed
- Frosted-glass navbar on light themes — light-theme navbar gradients are
now ~90% opaque (82% when scrolled) so the heavy
backdrop-filterblur the navbar already declares actually frosts the content scrolling beneath it, while keeping nav text fully legible.
[2.86.0] - 2026-06-13 - Hardware-faithful navbar stack light + header polish
The navbar status "stack light" was a four-lens widget (red / amber / green /
blue) that lit several lenses at once — a layout the physical hardware can
never show. The real indicator is an ANDONT 7-color USB stack light: a
single lamp that shows one colour at a time, driven by
services/gpio/alert_indicators.py, which resolves to one state at a time.
The header widget now mirrors that hardware exactly, and the navbar/header got
a round of visual polish.
Changed
- Navbar stack light now matches the physical ANDONT 7-color light. Replaced
the 4-lens (incl. blue) housing — which lit red/amber/green/blue
simultaneously — with a realistic single-lamp tower (black domed cap, one
translucent lamp, weighted base) that glows one colour at a time from the
ANDONT palette (off/green/blue/red/cyan/yellow/magenta/white). The
client-side controller in
templates/components/navbar.htmlmirrorsresolve_tower_state()'s single-resolved-state priority (fault > on-air/alert > test > incoming/degraded > standby) and the GPIO guide's default state→colour table: fault = magenta (flashing), test (RWT/RMT/NPT/DMO) = cyan (flashing), **active alert = red (flashing- buzzer)**, incoming/degraded = yellow, standby = green (steady). A
buzzer cue badge pulses only during a real active alert, matching the
hardware (
alert_buzzer). The retired blue "active alert" lens is folded into the yellow incoming state.
- buzzer)**, incoming/degraded = yellow, standby = green (steady). A
buzzer cue badge pulses only during a real active alert, matching the
hardware (
Added
- Header / navbar visual polish. Brand wordmark gains a soft drop shadow with a hover glow, the live status indicators (system health + WebSocket) are grouped into a cohesive cluster with a hover lift, the navbar gains a crisp glass top-highlight, and dropdown menus get softer/deeper shadows, rounded items with a subtle hover slide, and tightened section headers. All scoped to the navbar and theme-agnostic (white/rgba over the themed gradient).
[2.85.5] - 2026-06-13 - Resend on Air now actually emits audio out the air-chain
The 2.85.4 fix made "Resend on Air" key the GPIO relay and show the countdown
overlay from a detached worker, but it never re-injected the stored audio into
the live Icecast stream — so listeners heard silence unless an
AUDIO_PLAYER_CMD local player happened to be configured (it is unset on every
deployment). A live alert, by contrast, pushes its composite audio into the
broadcast queues via eas_stream_injector.inject_eas_audio. A resend now does
the same, so a re-transmitted warning sounds exactly like a fresh one.
Fixed
- Resending an alert produced no audio on the Icecast air-chain. The resend
helper (
scripts/resend_eas_broadcast.py) runs in a detached subprocess with noAudioIngestController, so it cannot reach the audio-service's in-memoryBroadcastQueueobjects directly. It now dispatches a newinject_eas_audiocommand over the existing Redis audio-command channel; the audio-service process — which owns the controller and the runningIcecastStreamerthreads — loads the stored WAV for that message id from the database and injects it viaeas_stream_injector.inject_eas_audio, mirroring the live-alert path (EASBroadcaster.handle_alert). The message id (not the raw audio) crosses Redis because every service runs withPrivateTmp=true, so a shared temp-file path would not be visible across processes. Injection failures (Redis down, no running sources) are non-fatal: GPIO is still keyed and the air-chain is still held for the full composite duration. The resend'sSystemLogentry now recordsaudio_injectedalongsidegpio_activatedandaudio_played.
Added
inject_eas_audioRedis command (app_core/audio/redis_commands.py):AudioCommandPublisher.inject_eas_audio(message_id)and the matchingAudioCommandSubscriberhandler, which loads the message audio inside an app context and reports whether any source queue received it. The subscriber now accepts the Flaskapp(passed fromeas_monitoring_service.py) for database access. Regression coverage added intests/test_eas_resend_injection.py.
[2.85.4] - 2026-06-13 - Stop the "Resend on Air" button from hanging the whole site
Resending a stored warning from the Audio Archive (/audio), audio detail, or
alert detail pages worked, but it locked up the entire web UI for several
minutes — every other page timed out until the broadcast finished — and the GPIO
relay was held for the wrong length of time (observed 303 s and 31 s for a
79.7 s alert) instead of the actual composite-audio duration.
Fixed
- Resending an alert froze the web UI for minutes. The resend endpoint
(
/eas/messages/<id>/resend) ran GPIO activation, audio playout, and the full-length air-chain hold inline in the gunicorn gevent worker. Driving GPIO there instantiates thelgpiobackend, whose native notification thread stalls the gevent hub for the whole alert (seeapp_utils/gpio.py), so all in-flight requests on that worker blocked and gunicorn's 300 s--timeoutthen killed the worker mid-broadcast. The playout is now delegated to a detached helper process (scripts/resend_eas_broadcast.py), matching how every other broadcast in the system already runs outside the web workers; the request returns202immediately and the web UI stays responsive. The live countdown overlay is unaffected because it is driven by the Redis broadcast-state marker the helper sets and clears. - GPIO relay duration did not match the alert length. Because the worker was frozen, the relay lingered until the 300 s GPIO watchdog (or the gunicorn timeout) fired rather than dropping at end-of-message. The helper now anchors the hold window to the GPIO activation and releases the relay (force, ignoring per-pin min-hold) exactly when the composite-audio duration has elapsed, so the relay duration matches the broadcast length.
Added
- Guard against stacking broadcasts. The resend endpoint now returns
409if a broadcast is already on the air, so a double-click — or a forwarded alert already in flight — cannot key the same relays from two processes at once. The three resend buttons surface the server's status message via toast.
[2.85.3] - 2026-06-13 - Stop requiring a reboot for GPS to re-lock after update.sh
Fixed
- Running
update.shleft the GPS receiver stuck in "ACQUIRING" until a manual reboot. The update flow restarted the EAS GPS client (eas-station-gps.service) but never restarted gpsd itself, whereas a reboot restarts everything. Disturbing the serial link by stopping/starting the EAS GPS service around an update is exactly the condition that wedges gpsd in the "stuck acquiring" state — historically cleared only by a reboot. The in-process watchdog does restart gpsd, but only after 15 minutes without a fix, far longer than anyone waits before rebooting.update.shnow refreshes the timing stack as part of the update: it stops the EAS GPS service to free the serial port, restartsgpsd.socket+gpsd.service, restarts chrony so its refclock re-locks, then lets theeas-station.targetrestart reconnect the GPS manager to the freshly-restarted gpsd. The step is guarded so it is a no-op on installs without gpsd. eas-station-gps.servicecould lose the gpsd-vs-serial race at boot. Addedgpsd.service/gpsd.socketto the unit'sAfter=(ordering only — noWants/Requires, so gpsd is never force-started on installs that don't use it). When gpsd is part of the same systemd transaction, the GPS manager now waits for it and connects viasource=gpsdinstead of falling back to grabbing the serial port directly (source=serial), which starves gpsd. Existing installs pick this up viaupdate.sh(it copies the unit files and runssystemctl daemon-reload).
[2.85.2] - 2026-06-13 - Fix broken "Resend" button and add labels to audio-archive action buttons
The Resend button on the Audio Archive (/audio), audio detail, and alert
detail pages never worked: it POSTed to /admin/eas_messages/<id>/resend, which
does not exist (404), so clicking it only ever showed "Failed to resend". The
real endpoint lives on the eas blueprint at /eas/messages/<id>/resend. The
icon-only action buttons on those pages were also hard to interpret at a glance.
Fixed
- Resend button POSTed to a non-existent URL. All three templates
(
templates/audio_history.html,templates/audio_detail.html,templates/alert_detail.html) now call the correct/eas/messages/<id>/resendendpoint, so re-broadcasting a stored EAS message works from the UI.
Changed
- Action buttons are now labelled. The previously icon-only buttons in the Audio Archive and alert-detail broadcast tables now show text labels (Play, Summary, Details, EOM Burst, "Resend on Air") with clearer tooltips, stacked vertically so each action is self-explanatory. The detail-page Resend button is relabelled "Resend on Air".
[2.85.1] - 2026-06-13 - Fix air chain / overlay / tower light held hostage after a broadcast
The air chain, the on-air overlay popup, and the tower light could all stay "on air" for ~300s after a short alert (e.g. a ~14s RWT) finished. Three distinct failure modes were found and fixed, all anchored on the broadcast's own playout duration as the single source of truth.
Fixed
- Worker could block ~310s on a hung audio player, holding the air chain and the overlay. The manual send (
webapp/eas/workflow.py) and automated RWT (app_core/rwt_scheduler.py) ran the audio player withtimeout=max_activation_seconds + 10(~310s), so a player that didn't exit promptly (busy/blocked audio device, stalled network sink) kept the worker blocked — the relay stayed asserted until the 300s watchdog force-released it (logging a ~301s activation), andclear_broadcast_active()didn't run until the worker unblocked, so the overlay and tower light lingered. Both paths now bound the player toplayback_duration + 30, matching the resend path (webapp/eas/messages.py) which was already correct. The worker can no longer block past the broadcast itself. - Min-hold (
hold_seconds) could keep the relay keyed after end-of-message. The broadcast-completion release paths calledGPIOController.deactivate()withoutforce=True, so the controller honoured each pin's anti-chatter min-hold bytime.sleep(hold_seconds - elapsed)while the relay was still asserted and while holding the controller lock — keying the transmitter and freezing all GPIO state reads until the min-hold elapsed. The min-hold is anti-chatter for rapid toggles, not a broadcast timer.GPIOBehaviorManager._release_hold()and thedeactivate_all()fallbacks in all three send paths now passforce=True, so the air chain drops the instant playout ends regardless ofhold_seconds. - The overlay popup and tower light depended on a worker thread surviving to clear the marker. "Broadcast active" was a raw Redis flag read verbatim by every consumer (overlay via WebSocket/poll, tower light via the GPIO service), so if
clear_broadcast_active()was ever delayed or the worker died, the indicators stayed lit until the marker's TTL.get_broadcast_state()now derivesactivefrom the broadcast's ownstart_ts + duration_seconds(plus a short grace), so the overlay and tower light self-clear at end-of-message even if the worker never clears — the worker's clear is now just an early-out. This is the authoritative-state design rather than relying on imperative cleanup.
New regression tests in tests/test_gpio_controller.py and tests/test_broadcast_state_expiry.py; hold_seconds guidance clarified in the GPIO guide.
[2.85.0] - 2026-06-12 - Tower-light state engine: fault/test states, severity colors, quiet hours, buzzer kill switch
Added
- Master buzzer kill switch. A new "Disable buzzer entirely" option on Admin → Hardware Settings → Tower Light guarantees the stack-light buzzer never sounds in any state — enforced inside the controller so no other setting or code path can override it.
- Test-broadcast state. Active broadcasts whose SAME event code is a test (RWT/RMT/NPT/DMO) now show their own configurable color (default cyan) instead of the alert color, and never sound the buzzer — a weekly test no longer looks like a live warning. The event code was already in the Redis broadcast state.
- System-fault state. When the GPIO service loses Redis / the alert pipeline (meaning the station may be deaf), the tower light flashes a configurable fault color (default magenta) instead of sitting on a stale state; it returns to standby automatically on recovery. Detection uses a direct Redis ping each refresh, since the state readers deliberately swallow connection errors. Can be disabled.
- Severity-based alert colors. An optional mode colors real active alerts by product class from the event-code registry — warnings (default red), watches (default yellow), advisories/statements (default white) — instead of the single Active Alert color.
- Quiet hours. An optional schedule (HH:MM local, may span midnight) darkens the standby light. Incoming and active alerts always override quiet hours — the indicator can never sleep through an alert.
- The tower light now runs on a resolved-state engine in
services/gpio/alert_indicators.py(priority: fault > test/alert > incoming > quiet > standby) with a pure, unit-tested resolver; hardware is written only when the resolved state changes. New columns via migration20260612_tower_light_states; state table documented in the GPIO guide; 14 new tests acrosstests/test_gpio_alert_indicators.pyandtests/test_gpio_controller.py.
[2.84.2] - 2026-06-12 - Fix inverted ANDONT buzzer byte
Fixed
- ANDONT stack light buzzer byte was inverted. The vendor's published control table lists buzzer
0x01 = on / 0x02 = off, but real hardware behaves the opposite way (confirmed on an actual ANDONT light):0x02sounds the buzzer and0x01silences it. With the table values, the buzzer sounded continuously in every silent state and stayed silent during alerts with "Enable buzzer on alert" set. Constants, tests, and the GPIO guide now match observed hardware behavior.
[2.84.1] - 2026-06-12 - Fix systemd DeviceAllow blocking USB serial devices beyond ttyUSB0
Fixed
- The GPIO service could not open a USB tower light on any port other than
/dev/ttyUSB0—eas-station-gpio.servicewhitelisted exactlyDeviceAllow=/dev/ttyUSB0, so a light enumerating at/dev/ttyUSB1(normal when another USB-serial adapter is plugged in) failed with[Errno 1] Operation not permitted. DeviceAllow has no path wildcards, so the unit now allows thechar-ttyUSB/char-ttyACMdevice groups (the patterneas-station-web.servicealready documents). The same latent bug is fixed ineas-station-displays.service(VFD/LED serial) andeas-station-zigbee.service(coordinator), and the groups were added toeas-station-gps.servicefor USB GPS receivers. Existing installs need the updated unit files installed (update.shdoes this; or copy fromsystemd/andsystemctl daemon-reload). - Tower light could miss the initial standby frame. CH340-based lights can drop bytes written immediately after the serial port opens (the adapter resets on open), so the controller now waits 1 s after opening before sending the first state frame.
[2.84.0] - 2026-06-12 - ANDONT 7-color stack light support and configurable tower-light state colors
Added
- ANDONT 7-color USB stack light support. The tower-light driver previously spoke only the Adafruit #5125 protocol (three independently switchable red/yellow/green segments, single-byte commands). A new Device Protocol selector on Admin → Hardware Settings → Tower Light adds the ANDONT 7-color USB stack light, which works fundamentally differently: it shows one color at a time (off / green / blue / red / cyan / yellow / magenta / white) and every state change is a complete
FF <lighting-mode> <buzzer-mode> <flash-frequency> AAframe per the vendor's control instructions (e.g.FF 02 01 01 AA= green + buzzer, steady). Buzzer and blink options map onto the frame's buzzer and flash-frequency bytes. - Configurable state → color mapping for the tower light. Three new dropdowns — System Ready (standby), Incoming (pre-alert), and Active Alert — choose the color for each lifecycle state (defaults preserve the previous behavior: green / yellow / red). On an ANDONT light all seven colors are selectable, enabling e.g. green-for-ready / blue-for-active-alerts; on the Adafruit #5125 the UI grays out colors beyond its three physical segments and the backend clamps unsupported values to that state's default. New
tower_light_protocol,tower_light_standby_color,tower_light_incoming_color, andtower_light_alert_colorcolumns (migration20260612_tower_light_protocol_colors); the tower-light section of the GPIO guide documents both protocols. New protocol/color coverage intests/test_gpio_controller.py(frame layout, color clamping, config loading).
[2.83.1] - 2026-06-12 - Fix "already attached to session" error on Hardware Settings save
Fixed
- Hardware Settings save (and therefore "Save & Restart") failed with
Object '<HardwareSettings …>' is already attached to session 'N' (this is 'M'). The module-level cache inapp_core/hardware_settings.pystored a liveHardwareSettingsORM instance and only checkeddetachedbefore reusing it. Under gunicorn/gevent each request runs with its own scoped SQLAlchemy session, so the cached instance could still be persistent in a previous request's session when the next request came in; handing it out and re-add()ing it to the current session raisedInvalidRequestError, which blocked saving and — because the Save & Restart button only restarts services after a successful save — also made it impossible to restart hardware services from the web UI. The cache now verifies the instance is attached to the current session (insp.persistent and insp.session is db.session()) and re-queries otherwise, andupdate_hardware_settings()no longer re-adds an already-persistent instance. Regression coverage intests/test_hardware_settings_cache.pyreproduces the two-session scenario with a holder thread.
Changed
- Tower Light serial-port help text on Admin → Hardware Settings now explains that the Adafruit #5125 enumerates via its CH34x chip as
/dev/ttyUSB<n>and recommends the stable/dev/serial/by-id/usb-1a86_USB_Serial-…path when multiple USB-serial devices are attached (enumeration order can change between boots).
[2.83.0] - 2026-06-12 - Audit chain verification UI, signing-key provisioning, and integrity documentation
Added
- One-click audit-chain verification in the web UI. The tamper-evident audit chain shipped in v2.75.0 with a complete cryptographic core (
AuditLogger.verify_chain()) but no way to actually run it — no route, no button, no CLI. The Audit tab of the unified logs hub (/logs?type=audit) now has a Chain Integrity card with a scope selector (entire chain, or newest 100/1,000/10,000 rows for very large tables) and a Verify Chain Integrity button. Results render as a green "chain intact" banner (rows checked, hash links and Ed25519 signatures verified), a red "TAMPERING DETECTED" banner with the first bad row id and the precise reason (prev_hash mismatch/entry_hash mismatch/signature invalid), plus contextual warnings for unsigned legacy rows and the ephemeral-key condition. Backed by a newGET /security/audit-logs/verifyendpoint (logs.viewpermission, optional?limit=N) that also returnstotal_rowsandverified_at. Every verification run is itself recorded into the chain as a newaudit.chain.verifiedaction — the log carries its own receipt of when it was last checked and what the verdict was. Each audit row's expanded details on/logs?type=auditnow include all three chain fields (prev_hash,entry_hash,signature; previously onlyentry_hash), and the Audit tab gained the description blurb it was missing. Endpoint regression coverage added totests/test_audit_chain.py(intact chain, tamper detection through the route,limithandling, and the self-recordingaudit.chain.verifiedrow). - The Ed25519 signing key is now actually provisioned.
app_core/auth/_audit_signing_key.pydocumented thatinstall.shinstalls the production key at${INSTALL_DIR}/secrets/audit_signing.key— but no installer code ever did, so every real deployment silently ran on an ephemeral in-memory key whose signatures died with each restart.install.shnow generates the key (openssl genpkey -algorithm ed25519, dir0700, key0600, owned by the service user) and writesAUDIT_SIGNING_KEY_PATHinto the generated.env;update.shdoes the same idempotently for existing installs (an existing key is never overwritten — rotation would orphan old signatures) and appends the missingAUDIT_SIGNING_KEY_PATHto.envwhen absent.AUDIT_SIGNING_KEY_PATHis documented in.env.exampleand editable under Settings → Environment → Core Settings.secrets/added to.gitignoreso the key can never be committed from the install-dir git checkout. - Thorough integrity documentation. New
docs/security/AUDIT_LOG_INTEGRITY.md(registered inmkdocs.yml) covers the full design with a Mermaid write-path diagram, the verifier's three checks and verdict fields, an honest threat model (what is detected — edits, deletions, insertions, reorderings, unkeyed re-hashing — and what is not: tail truncation, key + DB compromise, root compromise, never-logged events), an explicit "why the signing key is a file and not a database row" rationale (a DB-resident key would let a database attacker re-sign rewritten history, defeating the feature), key management (provisioning, resolution order, ephemeral-key warning, rotation/loss procedures, backup-separation guidance), schema reference, troubleshooting table, and a review cadence.docs/guides/AUDIT_LOG_REVIEW.mdgained a "Verifying the Log Hasn't Been Tampered With" walkthrough plus the automatic alert-lifecycle andaudit.chain.verifiedevent types;docs/security/SECURITY.mdand the README's "Tamper-Evident Audit Ledger" section now point at the verification UI and the new doc;templates/help.htmlgained a "Verifying the Audit Log (Tamper Evidence)" accordion entry under Routine Operations.
[2.82.0] - 2026-06-12 - Self-healing GPS watchdogs, automated data retention, and GPS history charts
Added
The GPS reader now self-heals from every receiver/gpsd wedge mode short of dead hardware. Three new watchdogs: (1) Serial NMEA silence — a receiver or USB-serial adapter that leaves the port "open" while emitting no bytes is detected after 30 s (
GPS_SERIAL_WATCHDOG_S/serial_watchdog_sconfig,0disables) and the port is closed and reopened, retrying every 5 s so an unplugged receiver recovers on replug. (2) gpsd event silence — a healthy gpsd emits TPV/SKY at ~1 Hz with a WATCH active, fix or no fix, so 60 s without events (GPS_GPSD_WATCHDOG_S) forces a socket reconnect instead of looping on the read timeout forever. (3) gpsd stuck acquiring — gpsd is known to wedge after serial/USB hiccups in a state where it keeps reporting but never reaches a 2D/3D fix, historically cleared only by a reboot; after 15 min without a fix (GPS_GPSD_STUCK_ACQUIRING_S) the watchdog restarts the gpsd daemon itself via newconfig/sudoers-eas-stationentries (plain-systemctlfallback for root deployments), rate-limited to once per 30 min so a poor sky view cannot cause a restart loop. All interventions are logged and counted in GPS status (watchdog_restarts,gpsd_watchdog_reconnects,gpsd_daemon_restarts,gpsd_last_daemon_restart_at). Watchdog overview added to GPS HAT setup. New coverage intests/test_gps_serial_watchdog.pyandtests/test_gps_gpsd_watchdog.py(27 tests).Automated data-retention policies for everything that previously grew without bound. Broadcast audio archives already had age/quota pruning; a new single-row
retention_settingstable andRetentionSchedulerdaemon thread (first sweep ~2 min after startup, then every 6 h) now also cover: IQ capture.npyfiles inRADIO_CAPTURE_DIR, debug audio in/tmp/eas-audio, and thestream_metadata_log/audio_alerts/audio_source_metricstables. Received-alert rows are never deleted — only theirraw_audio_datablobs are stripped after the cutoff, preserving compliance history. Each artifact class has its own max-age in days (0= keep forever) plus a master enable switch, configurable from a new Data Retention card on Admin → Application Settings or theGET/PUT /admin/application/retentionAPI. Migration20260612_add_retention_settings; sweep results logged in a single summary line; failures in one step never block the others. New coverage intests/test_retention.py(20 tests).Historical trend charts on the GPS & Time dashboard. The tiered Redis trend archive has kept up to ~91 days of GPS/chrony history for a while but was never charted. A new "Historical Trends" section adds a 1h/6h/24h/7d/30d/90d window selector and four Chart.js panels: clock discipline (chrony frequency drift in ppm + offset with automatic µs/ms scaling), PPS jitter with ADEV(10 s/100 s) overlays on a logarithmic σy(τ) axis, satellites used/visible plus average SNR, and SoC temperature with a holdover overlay when present. Local-time axes via the vendored date-fns adapter (with a linear-axis fallback), client-side decimation of large tiers, nulls rendered as gaps with per-chart empty states, and a 60 s auto-refresh that is fully independent of the existing 1 Hz status polling (
static/js/gps_trends_charts.js).A "Support the Project" card on the About page. Links to ko-fi.com/easstation with a vendored Ko-fi cup logo (
static/img/kofi.svg, no external asset fetch), and theko_fientry in.github/FUNDING.ymlis fixed so the repository's Sponsor button works.Edge-case test coverage for the audio fan-out path.
tests/test_broadcast_queue_overflow.pypins BroadcastQueue's drop-oldest overflow behavior, slow-consumer isolation, per-subscriber chunk-copy isolation, and the Icecast streamer's subscription read/unsubscribe path.CLAUDE.md(repository root) is now a symbolic link todocs/development/AGENTS.md, so Claude Code sessions auto-load the project's agent guidelines — including the mandatory versioning rule, which has been expanded with the concrete release-cut steps (VERSION + changelog heading + README badge +tests/test_release_metadata.py).Social share cards: watch / warning / advisory and severity colour coding. Three reinforcing signals now distinguish the action ladder and severity at a glance. (1) A tier badge leads the header metadata row — WARNING (red), WATCH (orange), ADVISORY (amber), STATEMENT (slate), EMERGENCY (magenta) — with the rule under the header band in the same colour; events without a tier word (AMBER Alert, telephone outages) show no badge. (2) The severity pill is now a solid severity-colour fill (red / orange / amber / blue / slate) in the same larger bold face, white-cased so it pops on same-coloured gradients — previously it was 11 px coloured text on a white pill, indistinguishable across severities once the feed downscaled the card. (3) The header gradient itself cools with urgency: hazard-family hue is kept, but watches, advisories, statements, and lower severities are progressively desaturated/dimmed (and their particle layer calmed), so a Heat Advisory no longer glows as red-hot as an Excessive Heat Warning. Resolver + rendering coverage in
tests/test_image_export_themes.py.Social share cards: 2× supersampled export for Facebook. Facebook (and most platforms) re-encode every upload to JPEG and display it downscaled in the feed; at the native 1200×630 the compression read as grain over the map and small text.
generate_alert_imagegains ascaleparameter (1–3, Lanczos upscale just before encode) and the/alerts/<id>/export-image.pngendpoint now defaults toscale=2(2400×1260 for landscape), which shrinks the platform's compression artefacts below visibility on screen; passscale=1for the native canvas. Email notification cards are unchanged (native size).GPS dashboard: TDEV & MTIE panel with ITU-T G.811 PRC masks. The PPS phase record now also yields the two telecom time-domain stability metrics — TDEV (time deviation, via the overlapping modified Allan variance) and MTIE (maximum time interval error per observation window, computed in O(N) with monotonic deques) — plotted log-log beside the Allan chart with the G.811 wander masks dashed in and a within/above-mask verdict in the card header. The help popover spells out that the local PPS timestamping chain is part of the measurement, so short-τ mask violations usually indict the measurement, not the clock. The whole ADEV/TDEV/MTIE block now recomputes on its own 5 s throttle (instead of every 1 s status poll) since its inputs only shift as the 1 Hz PPS ring turns over.
GPS dashboard: five new history/correlation panels. Stability Trend (archived σ_y at τ=10 s/100 s — catches an oscillator degrading over days), Oscillator Skew (chrony's own frequency-uncertainty estimate, immune to local timestamping noise), Satellite Counts (used-in-fix vs visible), Temp vs Frequency (host-SoC temperature against chrony's applied frequency with a least-squares ppm/°C sensitivity readout), and Position Wander (east/north fix scatter around the window median with CEP50/CEP95 circles, for multipath/spoofing triage). All five are fed by new trend-archive fields (
skew_ppm,root_dispersion_s,adev_10s/adev_100s,sats_used/sats_visible,cpu_temp_c,lat/lon) so they survive page reloads and render at every archive resolution.GPS dashboard: jitter tail percentiles. The PPS jitter summary now reports p95/p99 of |Δ| (nearest-rank) alongside σ/median/peak — on a loaded host the distribution is heavy-tailed (median in ns, σ in µs) and a handful of scheduler-latency outliers were defining the headline numbers.
GPS dashboard: Allan chart noise-floor overlay and noise-regime label. The chart now extends to τ=1000 s, draws the white-PM measurement floor √3·σ_x/τ (σ_x = jitter σ/√2) as a dashed reference, and labels the noise regime fitted from the log-log slope (white PM / white FM / flicker FM / random walk) so an operator can see at a glance when the curve is timestamping-noise limited.
Signed GitHub Releases. A new
release.ymlworkflow automatically publishes a GitHub Release whenever theVERSIONfile changes onmain: it re-runs the release-metadata guardrail, builds agit archivesource tarball plus aSHA256SUMSmanifest, signs both with keyless GitHub artifact attestations (Sigstore-backed SLSA build provenance — no GPG keys to manage), creates thevX.Y.Ztag, and attaches the matchingCHANGELOG.mdsection as the release notes. Anyone can verify a download withgh attestation verify eas-station-X.Y.Z.tar.gz --repo KR8MER/eas-station. The full process and verification steps are documented indocs/process/RELEASING.md.ENDEC Device Feeds — Sage-ENDEC-compatible TCP output for character generators, newsroom software, and capture tools. EAS Station can now stream every alert event over TCP in the four wire formats a Sage Digital ENDEC emits on its serial device ports: Generic Character Generator (
<STX><sev><text><ETX>), News Feed (<ENDECSTART>…<ENDECEND>), Decoder status (local:/match:/nomatch:/dup:+ ZCZC header + plain-language text), and a raw EAS Encoder byte mirror (0xABpreamble +ZCZC×3 +NNNN×3) of outgoing alerts. The pure formatters live inapp_utils/endec_feeds.py(with a severity-by-event-code map derived from the event-code registry); the detection/forwarding pipeline publishes feed events to Redis at four decision points (match/nomatchinapp_core/audio/eas_monitor.py,dup/localinapp_core/audio/auto_forward.py) viaapp_core/audio/endec_feed_publisher.py; and a newservices.endec_feedssubprocess (systemd uniteas-station-endec-feeds.service, health on port 5111) fans the rendered bytes out to connected TCP clients — one listener per configured feed. Feeds are configured under Admin → ENDEC Device Feeds (newEASSettings.endec_feeds_enabled/endec_feedscolumns, migration20260605_add_endec_feeds_to_eas_settings); saving hot-reloads the service over a Redis control channel with no restart. Behavior, wire formats, and a clean-room IP basis are documented indocs/reference/protocols/SAGE_ENDEC.md. New coverage intests/test_endec_feeds.pyandtests/test_endec_feeds_server.py(29 tests: formatter framing, severity mapping, config validation, and a live TCP fan-out round-trip).A parametric 3D-printable enclosure for the hardware build. New
hardware/enclosure/adds an OpenSCAD case (eas_station_case.scad) that exports two printed parts (base + lid) for a Raspberry Pi 5 with a front-mounted Noritake GU140x32F-7000B VFD, a 0.96″ SSD1306 OLED, and a navigation button. It includes Pi standoffs on the official 58×49 mm pattern, VFD/OLED glass windows with mounting bosses, side service slots for the Pi port banks, a rear GPS-antenna (SMA) feedthrough and optional DB9/USB-serial slot, ventilation, and a lipped screw-down lid. Every dimension is a top-of-file variable; module outlines the project never published in millimetres are tagged// VERIFYwith a measure-before-print checklist inhardware/enclosure/README.md(BOM, render/slice commands, print settings, and assembly steps included).The WYSIWYG screen editor now exposes the full graphics capability of the OLED and VFD hardware. The visual editor (
static/js/screen-editor.js,templates/screen_editor.html) previously offered only text and bar elements even though the device renderer (app_core/oled.py) and screen renderer (scripts/screen_renderer.py) already supported a much richer set. The editor was rebuilt around a schema-driven element registry so the toolbar, property panel, layer list, canvas preview, and save payload all stay in sync from a single definition. New element types — rectangle, line, horizontal/vertical/dotted dividers, circle, arc, built-in icons (antenna, speaker, warning, check, cross, network, shield, wave, clock, heartbeat), semicircular gauge, and analog clock — are drawn live on the canvas and draggable. The "Add Element" list is filtered per display type (OLED gets the full set; VFD gets text plus the shapes its GU-7000 firmware can draw; LED stays text-only and now correctly emits thelinespayloadrender_led_screenconsumes). Unknown/advanced element types (e.g.pixel_pattern) round-trip on save instead of being dropped.Three new live "rich" screens — GPS, Decoder, and Airchain Capture — in both OLED and VFD layouts.
scripts/create_example_screens.pyaddsoled_gps_status/vfd_gps_status(fix quality, latitude/longitude, altitude, satellites, HDOP from/api/hardware/gps/status),oled_decoder/vfd_decoder(SAME/AFSK sync state, message-in-progress, health bar, bytes decoded, alerts, scans from/api/eas-monitor/status), andoled_airchain_capture/vfd_airchain_capture(whether broadcast audio is being captured viaaudio_flowing, capture uptime/duration viawall_clock_runtime_seconds, buffer fill, and throughput). All six are added to the default OLED/VFD rotations.Screen templates support a Python format spec on any variable, e.g.
{gps.latitude:.5f}.ScreenRenderer.substitute_variablespreviously forced every float to one decimal place, which silently truncated GPS coordinates to ~10 km resolution. A{path:spec}suffix is now honored (booleans still map to Yes/No, missing values still render empty), so the GPS screen shows full-precision coordinates and durations/percentages render as whole numbers.GPIO gains explicit Transmitter PTT and Audio Mute behaviors, plus startup validation. A full audit of the GPIO subsystem (
app_utils/gpio.py,services/gpio/) closed several gaps between documented and implemented behavior. Two new lifecycle behaviors are now assignable in the behavior matrix (Admin → GPIO → Pin Map):transmitter_ptt(held for the full broadcast — the purpose-built way to key a transmitter) andaudio_mute(held during playout to mute/duck station program audio while the alert is on air). The behavior manager now runsvalidate_configuration()on construction and logs a warning when no pin carries a transmit-capable behavior (so the transmitter would silently never key), when a behavior targets a non-active pin, or when a flash partner isn't itself flashing. See the rewritten GPIO guide §5. New coverage intests/test_gpio_controller.py.Tower-light / NeoPixel indicators are now event-driven instead of poll-only. The broadcast pipeline publishes a nudge on a new Redis pub/sub channel (
eas:indicator_events) whenever broadcast or incoming-alert state changes, and the GPIO subprocess refreshes the lights the instant it arrives (sub-second) via a new thread-safeAlertIndicatorMonitor. The previous 1-second poll is retained as a safety net for missed notifications, so a single state change is still applied exactly once. Previously the lights could lag the audio by up to a second at the start and end of an alert. New coverage intests/test_gpio_alert_indicators.py.SAME event-code registry now covers the four codes added in NWS Instruction 10-1712 (April 20, 2022). An audit of
app_utils/event_codes.pyagainst NWSI 10-1712, NOAA Weather Radio All Hazards (NWR) Specific Area Message Encoding (SAME), Appendix A.4, foundEVENT_CODE_REGISTRYtracked a pre-2022 edition and was missing EWW (Extreme Wind Warning), SSW (Storm Surge Warning), SSA (Storm Surge Watch), and BLU (Blue Alert) — exactly the products the April 2022 revision introduced. All four are now registered with directive-matching names and default products (EWW/SSW→WRN,SSA→WCH,BLU→WRN). Operator-visible: CAP alerts and off-air SAME headers for these events now resolve to human-readable names, are selectable in the event-code allowlist, and decode with their proper label instead of showing only the raw three-letter code. The NWR transmitter-control codes from A.4.3 (TXF/TXO/TXB/TXP) remain intentionally absent because the directive states they are not to be implemented on SAME-capable receivers. New regression coverage intests/test_event_codes_nws_10_1712.pypins the full A.4 EAS-relevant code list so the registry cannot silently drift behind a future revision.Documentation audit and refresh across the whole
docs/tree. Restored 16 corrupted lines inABOUT.md(hard-truncated at 201 characters by an earlier automated edit) and resolved its Python 3.11-vs-3.13 contradiction; scrubbed the remaining Docker/Portainer-era instructions and refilled dozens of command blocks that an old docker cleanup had left empty (SETUP_INSTRUCTIONS, SDR_SETUP, SDR_SERVICE_ARCHITECTURE, FIREWALL_REQUIREMENTS, the SDR master troubleshooting guide,scripts/diagnostics/README.md); replaced every ASCII/box-drawing diagram with Mermaid or tables; standardized ~90 relative doc links on.mdextensions and repaired all dead links; fixed wrong navigation paths, stale template names, Redis key names, and theme/version counts; and corrected the in-app docs viewer so links that include.mdno longer 404 (webapp/documentation.py). The viewer also gained copy-to-clipboard buttons on code blocks.New documentation for previously undocumented features. Added guides for the Application Settings page (
/admin/application), Local Authority EAS access management (/admin/local-authorities), the health-check REST endpoints (/api/health/*), and the Audio/SDR Fix tool, plus a duplicate-alert cleanup section in the maintenance guide; all registered in the mkdocs nav. MarkedX-API-Keyauthentication as planned (not shipped) everywhere it was presented as available. Published maintainer contact details (email/phone) in the README, About page, and docs index.GitHub Pages deployment for the documentation site. New
.github/workflows/docs-pages.ymlbuilds the mkdocs-material site and deploys it to GitHub Pages on pushes tomain; fixed the mkdocs logo/favicon paths which pointed outsidedocs/and 404'd on the published site.
Changed
- Social share cards: readability pass on the info panel. Body copy (headline, affected areas, description, action) moved from 12 px to 13 px with taller line spacing, and each text section now renders as one continuous card block instead of per-row stripes — the old 1-px gaps between rows turned into shimmering scan-lines after Facebook's JPEG re-encode. Headline text that no longer fits is ellipsised instead of silently clipped mid-sentence.
- GPS dashboard: the Timing Integrity "Stability" grade is noise-floor compensated. σ_y(τ=10 s) computed from PPS timestamps is bounded below by the white-PM measurement floor √3·σ_x/τ; on a Pi with kernel PPS that floor sits in the low 10⁻⁷s, so the previous fixed 1×10⁻⁷ warn threshold re-graded the timestamping chain (already covered by the Peak Jitter tile) and dropped the composite letter to B on systems whose chrony skew read 10⁻⁸. The tile now warns only above max(1×10⁻⁷, 3× floor) and faults above max(1×10⁻⁶, 10× floor), and floor-limited readings are graded green with a "noise-floor limited" tag. The help popover documents the compensated ladder.
- GPS dashboard: Signal Quality / Signal Integrity average only tracked satellites. Both the health-summary verdict and the receiver-status bar previously averaged SNR across every satellite in view, including the long visible-but-untracked tail multi-GNSS receivers report at 0 dBHz — which halved the average and pushed healthy installs into "bad". They now average used-in-fix satellites (falling back to any tracked SNR > 0 while a fix is forming), and the verdict labels which population it used. The
/termsroute rendersdocs/policies/TERMS_OF_USE.md, buttemplates/terms.html(unreferenced dead code — nothing renders it, and the policy-page fallback targetsTERMS_OF_USE.html, notterms.html) had drifted from the served document. The orphaned template is removed, the SMS Messaging Terms it carried (TCPA/Twilio consent) are ported into the served markdown as §6a linking the/sms-complianceroute, and §4c is reordered to follow §4a/§4b. - Section 4b adds two recent, verified FCC EAS enforcement actions. New Case 4 documents the August 2019 multi-party consent decrees totaling $600,000+ — ABC/Jimmy Kimmel Live! ($395,000, WEA tones in comedy), AMC/The Walking Dead ($104,000, EAS tones in scripted drama), Discovery/Animal Planet ($68,000), and Meruelo Radio Holdings ($67,000) (FCC Public Notice DOC-359101A1). New Case 5 documents the December 2024 Paramount Global consent decree ($244,952) covering Young Sheldon's dramatized tornado scene, Entertainment Tonight, and CBS News Radio (Consent Decree DA 24-1285). Both reinforce §4c's prohibition on fictional/entertainment use; the prior "ongoing enforcement pattern" entry is renumbered to Case 6.
Removed
- Deleted the orphaned
templates/terms.html. It was never rendered (the live Terms of Use page is served fromdocs/policies/TERMS_OF_USE.md); see the Terms of Use consolidation above. - The Docker-era
legacy/directory (232 KB of archived scripts), the deprecated no-opinitialize_radio_receivers()stub ineas_monitoring_service.py, and the unreachable Icecast direct-source-access fallback.get_broadcast_queue()is defined on theAudioSourceAdapterbase class, so every source supports broadcast-queue subscription and the fallback was dead code. Thelegacy/rsync exclude was dropped frominstall.sh; everything remains available in git history.
Fixed
- The TTS pronunciation dictionary is now applied to actual broadcast audio, not just the web preview.
_load_pronunciation_rules()inapp_utils/eas.pyrequired an active Flask application context and silently returned an empty list otherwise — but real alert narration is synthesized by the standalone CAP poller and the OTA monitor, which run with a plain SQLAlchemy session and no app context. The result: every rule on the Pronunciation Dictionary page (built-ins like Bellefontaine → "Bell-fountain" and user-added entries alike) worked in the TTS Settings preview yet was skipped on-air. This is the same no-app-context failure mode previously fixed for TTS settings inload_eas_config(), and it gets the same fix:EASBroadcasternow hands its rawdb_sessiontoEASAudioGenerator, which threads it through_compose_message_text()→_normalize_text_for_tts()→_load_pronunciation_rules(), so layer 4 queries the database directly when no Flask context exists. Flask-context callers (preview, manual workflow, admin audio) are unchanged. Regression coverage intests/test_tts_text_normalization.py::TestPronunciationDictionaryRawSession. - Notification emails: the coverage map now falls back to the affected counties when the alert has no stored polygon. Emailed share cards were still arriving with the "Map not available" placeholder (e.g. the Severe Thunderstorm Warning issued 2026-06-12 12:20 AM EDT by NWS Cleveland) because
generate_alert_imagesilently skipped the map whenevercap_alerts.geomwas NULL — a county-coded product, or any alert whose polygon write failed at ingest. The renderer now falls back to the PostGIS union of the alert's SAME-geocoded counties fromus_county_boundaries(the same shape official NWS county-based warning graphics show), so the email card carries a real coverage map whenever the alert names counties. Every formerly-silent skip now logs its reason — NULL geometry without a county fallback, an unusable bounding box, or a bbox exceeding the 30-tile budget — andsend_alert_notificationslogs when the card image could not be built or when an OTA broadcast has no linked CAP alert (and therefore can never carry a map). New coverage intests/test_image_export_themes.py. - A PostGIS failure during intersection bookkeeping can no longer prevent an alert from airing. Root cause of the missed statewide Ohio RMT on 2026-06-10 (
CAPNET-1-14329-20260610034200): inpoller/cap_poller.py::_insert_new_alert,process_intersections()— which re-raises database errors by design — ran between the alert save and theauto_forward_cap_alert()call, so a single intersection-query failure on the 88-county statewide geometry aborted the pipeline before any forwarding decision was made. The alert was left witheas_forwarded=Falseand a NULLeas_forwarding_reason, and because forwarding only runs on first insert it permanently missed its broadcast window. Three changes close this:- Forward first, map later.
_insert_new_alertnow makes the forwarding decision (and sends notifications) before boundary-intersection analytics, and both poller call sites ofprocess_intersections()are wrapped so an intersection failure is logged as non-fatal instead of aborting the save. Geometry building stays ahead of forwarding (it is internally guarded and the notification email's coverage map needsgeom). - Catch-up sweep. A new
CAPPoller.retry_unevaluated_forwards()runs every poll cycle and re-evaluates recent (≤ 60 min), unexpired alerts whoseeas_forwarding_reasonis still NULL — the signature of a pipeline that died (PostGIS error, OOM kill, service restart) between the save and the forwarding decision. All ofauto_forward_cap_alert's gates (status/scope/msgType, expiry, allowlist, cross-source dedupe) still apply, so the sweep can never air something the normal path would have rejected. When the sweep finds anything to rescue it also writes a system-log ERROR naming the affected identifiers, so the underlying pipeline fault is surfaced to the operator instead of being silently repaired. - Honest alert trail. The trail page previously rendered this state as "Forwarding suppressed — reason: null", which reads like a deliberate decision. A NULL reason is now rendered as "Forwarding decision never recorded" at ERROR level with an explanatory note, distinct from a genuine suppression (every real exit path of
auto_forward_cap_alertrecords a non-NULL reason). - Missed broadcasts are terminal-stamped and alarmed. When the sweep finds an alert that expired while never evaluated (and was not already expired at ingest — historical imports are excluded via
created_at < expires), the broadcast window is gone: the sweep stamps a terminal"Never evaluated — ingest pipeline fault…"reason and writes a "MISSED BROADCAST" system-log ERROR naming the identifiers. The trail renders the stamp as "Missed broadcast — never evaluated before expiry" at ERROR level. - Health endpoint now watches the forwarding pipeline and poller liveness.
/health/dependenciesgains two checks:alert_forwarding(reports unhealthy when any alert in the last 24 h missed its forwarding decision, degraded when an unexpired alert has been awaiting a decision for >3 min) andcap_poller(unhealthy when the last completed poll cycle is older than 10 minutes — a stalled poller means nothing is being ingested at all). - A missing forwarding decision is now loud in every UI surface. Previously an alert with a NULL
eas_forwarding_reasonshowed no badge on the alerts list and no Forwarding Status card at all on the alert detail page. Now: the alerts list shows a red "No decision" badge (or red "Missed" for terminal-stamped alerts), the alert detail page always renders the Forwarding Status card with a red header and an explanatory reason, and the trail header shows "Never recorded" in red instead of omitting the row. Alerts already expired at ingest (historical imports) are excluded — skipping those carries no reason by design. - New regression coverage in
tests/test_forwarding_pipeline_guard.py(forwarding ordering, insert survival on intersection failure, sweep pickup/no-op/error isolation, missed-broadcast stamping and alarm, and the four trail renderings).
- Forward first, map later.
- The Audio/SDR Fix tool is now discoverable, permission-gated, and actually restarts the SDR service. The page (
/admin/audio-sdr-fix) previously had no navigation entry, required only a login (any authenticated user could rewrite receiver sample rates), and its "restart" endpoint merely echoed asystemctlcommand. It now appears as an Audio/SDR Fix card on the Settings hub, every route requires thesystem.configurepermission, andPOST /api/admin/audio-sdr-fix/restart-servicerestartseas-station-sdr.servicevia the existing sudoers whitelist (returning the manual command if the restart fails). The page gained a "Restart SDR Service Now" button. install.shno longer advertises Icecast stream URLs on a port nothing listens on. The generated.envsetICECAST_EXTERNAL_PORT=8001(a Docker-era port mapping) while Icecast itself listens on 8000 and no proxy fronts 8001 on bare metal, so advertised stream URLs were dead out of the box. The default is now 8000, matchingICECAST_PORT, with a comment explaining when to diverge; the setup wizard description and firewall documentation were aligned.eas-config's Hardware Integration menu no longer edits.envvariables the runtime ignores. The TUI toggledGPIO_ENABLED/LED_SIGN_ENABLED/VFD_DISPLAY_ENABLEDin.env, but hardware configuration moved to database-backed settings managed in the web UI, so the toggles had no effect. The menu entry now directs operators to Settings → Hardware Settings (/admin/hardware).- The VFD display path could never draw text or clear the screen from a rendered screen.
services/displays/api.pycalledvfd_controller.clear_display()(no such method — it isclear_screen()) and invokeddraw_text(text, x, y)when the controller signature isdraw_text(x, y, text), so text landed at the wrong coordinates or raised. Both are corrected. - VFD screens built in the visual editor silently rendered nothing. The editor saves graphics in the
elementsformat usingbar/rectanglewithwidth/height, butScreenRenderer.render_vfd_screenonly understoodprogress_barand the legacyx1/y1/x2/y2rectangle shape. The VFD renderer now consumes the same element format as the OLED path —text,bar/progress_bar,rectangle(both coordinate styles),line, andhline/vlinedividers — so anything drawn for a VFD in the editor now appears on the device. - The bundled
oled_eas_decoderexample screen pointed at a non-existent endpoint. Its data source was/api/eas/monitor, which is not a registered route; the live decoder status lives at/api/eas-monitor/status. The screen now binds to the correct endpoint (all of its referenced fields —health_percentage,audio_flowing,alerts_detected,active_sources,scans_performed— are present there), so it shows real data instead of blanks. - The two GPIO flash implementations no longer fight over a shared pin. The controller's built-in flash engine (
flash_enabled) and the behavior manager'sflashbehavior were independent code paths; a pin assigned both could be driven by two threads at once. The behavior manager now delegates to the controller's single flash engine via a new tri-stateactivate(flash=...)parameter (Trueforces flashing,Falsekeeps a held relay solid,Noneuses the pin's config).flashbehavior takes precedence over hold behaviors on the same pin, and the controller's flash cleanup now rests the partner pin OFF (previously a partner could be left energised after flashing stopped). - The "Resend EAS message" endpoint was completely broken.
webapp/eas/messages.pyimported a module that doesn't exist (app_utils.gpio_behavior) and a model that doesn't exist (GPIOConfig), so every resend raisedImportErrorbefore any audio or GPIO fired. It now loads the same database-backed pin map and behavior matrix the live broadcast path uses, so a resend keys exactly the same relays as a fresh alert. - The WebSocket GPIO status push silently emitted nothing.
_emit_gpio_status_updateinapp_core/websocket_push.pyqueried the non-existentGPIOConfigtable inside a swallowedexcept, so the live dashboard only ever updated via its polling fallback. It now reports configured pins from the database hardware settings in the same shape as/api/gpio/status. - GPIO cleanup gaps that could leave a relay (e.g. a keyed transmitter) energised on shutdown are closed.
GPIOController.cleanup()now shuts down its attached behavior manager first (stopping flash threads and releasing held pins) so nothing can re-energise a pin after cleanup, and the GPIO subprocess registers anatexitbackstop so controllers are released on non-signal exit paths too — not just the watchdog timeout. - Stale GPIO docs and a stale reserved-pin test corrected.
docs/hardware/GPIO_GUIDE.mdreferenced the retiredhardware_service/eas-station-hardwareunit (noweas-station-gpio), a non-existent.envconfiguration path, a behavior matrix keyed by alert severity (it is keyed by lifecycle behaviors), an incorrect pin-map JSON shape, and an incomplete reserved-pin set ({2, 3}→ actually{2, 3, 14}). The guide has been rewritten and a stale assertion intests/test_gpio_controller.pythat expected BCM 4 to be OLED-reserved was corrected to BCM 3. - CAP-to-EAS pipeline now matches the ECIG Implementation Guide V1.0 in five places it previously diverged. An audit against
docs/ECIG-CAP-to-EAS_Implementation_Guide-V1-0.mdsurfaced five spec deviations inapp_core/audio/auto_forward.pyandapp_utils/eas.py; all five are now closed with unit coverage intests/test_ecig_compliance.py.- §3.4.1.7 — Governor's Must-Carry. A new
_is_must_carry()helper reads<parameter><valueName>EAS-Must-Carry</valueName><value>True</value></parameter>from the CAP message; when asserted,auto_forward_cap_alertbypasses the operator'sforwarded_event_codesallowlist and the default RWT suppression. Location filtering and cross-source dedupe still apply, per spec. Operator-visible: an alert that would normally be rejected by the event-code allowlist will now be aired when the originator marks it as Must-Carry — the log line says"EAS-Must-Carry asserted — bypassing originator and event-code filters (ECIG §3.4.1.7)". - §3.8 — msgType filtering.
auto_forward_cap_alertnow rejectsCancel(cancelled messages MUST NOT be aired),Ack,Error, and any unknown msgType value with a reason citing§3.8.AlertandUpdatepass through to the existing supersede pipeline. Prior to this fix, the broadcast path treated every non-Test message as airable regardless of msgType. - §3.5.1 — Resource-URI fetch timeouts.
_fetch_embedded_audionow uses a per-resource cap: 120 s for downloadable MP3/WAV (EMBEDDED_AUDIO_DOWNLOAD_TIMEOUT) and 30 s for streaming sources (EMBEDDED_AUDIO_STREAMING_TIMEOUT). Streaming is detected fromaudio/x-ipaws-streaming-audio-*MIME or aresourceDesccontaining "streaming". On timeout the loop falls through to TTS as before, just within a bounded time. - §3.5.2 / §3.5.4 —
***deletion pause._normalize_text_for_ttspreviously stripped all asterisks, including the three-asterisk markers the spec uses for "text deleted, insert a one-second pause." It now converts runs of three or more asterisks to a sentence break (.) which every TTS backend in the project renders as an audible sentence-length pause. Adjacent spaces are consumed so the downstream multi-space normalizer does not append a comma to the pause. - §3.10 — Default originator when
EAS-ORGis absent._compose_message_textpreviously defaulted the FCC-required-text originator toWXR. It now defaults toCIVper spec, withWXRretained only when the alert'ssourceisNOAA/NWS(those feeds intentionally omit the parameter and are by definitionWXR).
- §3.4.1.7 — Governor's Must-Carry. A new
- MDC1200 PTT-ID pre/post bookend is now discoverable in the UI. The MDC1200 op-code dropdown lists "PTT-ID Pre" and "PTT-ID Post" as separate presets, which made operators think they had to choose one — even though setting both the Pre- and Post-Alert Signals to MDC1200 with the default
ptt_id_prepreset already auto-substitutesptt_id_poston the post side. The EAS Broadcast Settings page now shows a "Bookend pair active" note (Admin → EAS → Pre/Post-Alert Signaling) the moment that configuration is in effect, explaining that PTT-ID Pre (op 0x01) is sent before the alert and PTT-ID Post (op 0x00) after it automatically. No backend behavior changed — the pairing in_resolve_mdc1200_op_for_position()was already correct. - Corrected the MDC1200 verification instructions across the docs and UI. Multiple places (
templates/admin.html,docs/reference/protocols/MDC1200.md,docs/guides/ALERT_SIGNALS.md,app_utils/mdc1200.py, and an earlier changelog entry) told operators to verify generated packets withmultimon-ng -a MDC -t wav <file>.wav. Mainline multimon-ng does not include an MDC demodulator (MDCis not a valid-aargument), so that command does not work. The docs now point to dedicated MDC1200 decoders —mdc-decoder(decodes a WAV directly),mdc-encode-decode(the reference modem this implementation is checked against), andfsync-mdc1200-decode.
[2.81.1] - 2026-05-24 - Historical alerts 500 fix
Fixed
GET /api/alerts/historicalreturned 500 whenever any alert in the result window haddescription = NULL. The serializer ranalert.description[:500] + '...'unconditionally, so a single null-description row poisoned the entire page response (TypeError: 'NoneType' object is not subscriptable). The handler now coerces withalert.description or ''before slicing and extracts the truncated value into a named local for readability. (PR #2180.)
Changed
- Historical alerts query parameters accept
start/endaliases in addition to the existingstart_date/end_date. Callers that follow the more common short form (used by most JS date pickers and by the new external dashboards) no longer have to translate parameter names. Resolution usesrequest.args.get('start_date') or request.args.get('start')so existing integrations keep working byte-for-byte. (PR #2180.)
[2.81.0] - 2026-05-24 - Customizable dashboard header branding
Added
- Operator-customizable dashboard headline and subtitle. Two new fields —
dashboard_headline(≤120 chars) anddashboard_subtitle(≤160 chars) — were added toapplication_settingsso each station can brand the main/dashboard with its own call sign, market name, or mission statement instead of the hard-coded "Emergency Alert Dashboard" / county-state line. The Application Settings page (templates/admin/settings.html) gained a "Dashboard Header" card with both inputs, client-sidemaxlengthenforcement, and helpful placeholder text. The settings update endpoint enforces the same character limits server-side. TheApplicationSettingsmodel defaults both fields to empty strings so existing deployments render unchanged until an admin explicitly fills them in. The Flask context processor now injects the branding values globally (only outside setup mode) andtemplates/index.htmlconsumes them with{{ dashboard_headline or 'Emergency Alert Dashboard' }}/ county-state fallback. (PR #2179.) - Migration
20260524_add_dashboard_branding_to_application_settings.py— fully additive, idempotent up/down, defensive existence-check on both columns so it can be re-applied against partially-migrated environments without erroring.
Changed
- Removed the Quick Actions button strip from the main dashboard (Audio Archive, Statistics, Admin Panel, etc.). The buttons duplicated entries already in the top navbar and were the largest single contributor to dashboard above-the-fold clutter; their removal makes room for the new headline/subtitle without changing the overall page height. The same entry points remain available from the navbar.
[2.80.1] - 2026-05-24 - True-vector wordmark and footer app icon
Changed
- Footer brand mark replaced with a purpose-built SVG app icon (
static/img/eas-app-icon.svg). The Font Awesome broadcast-tower glyph rendered at small sizes was visually indistinguishable from the navbar tower icon and aliased badly on hi-DPI laptops. The new icon is a self-contained SVG (linear-gradient squircle background, radial-gradient beacon glow, hand-drawn tower + signal-wave path) with fullrole/aria-label/<title>/<desc>accessibility attributes and the standard?v={{ static_asset_version }}cache-bust query string.templates/base.htmlswaps the<i class="fa-...">for an<img>andstatic/css/styles.cssdrops the per-mark gradient (now on the SVG itself), addsoverflow: hiddento.footer-logo-mark, and introduces.footer-logo-mark-imgto make the SVG fill the container. Theeas-system-wordmark.svg/.pngwere re-exported from the same design source so the navbar wordmark and footer mark stay visually paired. (PR #2178.)
[2.80.0] - 2026-05-23 - GPS dashboard hover tooltips and adaptive histograms
Added
- Adaptive jitter histogram bucketing on the GPS & Time Dashboard. The fixed ±100 µs / 20 µs bucket layout collapsed to a single tall bar on stratum-1-grade receivers (sub-microsecond jitter) and clipped silently on noisy installs. The backend now picks bucket width from a 1-2-5 sequence sized so the bulk of samples fill 5–10 buckets, with a 100 ns floor to prevent zero-width buckets on very clean clocks. The endpoint additionally returns
bucket_width_nsso the front-end can colour-grade bars by distance-from-zero (≤1 bucket = green, ≤3 = amber, >3 = red) and align the centre divider with the actual zero crossing instead of the midpoint. X-axis labels now show the leftmost finite edge, zero, and the rightmost finite edge with adaptive sig-figs (±2 µs,±450 ns, etc.) instead of the dead±100 µslegend. (PR #2177.) - Generic hover-tooltip infrastructure for canvas charts. Added
_installChartHover()on the GPS dashboard which provides consistent positioning (left/right + top/bottom edge avoidance), per-chart hit-test closures stored on the canvas element so they can be swapped on redraw without re-binding event listeners, optionalsnapXvertical crosshair, and inherits the existing sparkline tooltip stylesheet so light/dark themes are picked up for free. All tooltip HTML is escaped to block injection from numeric labels. (PR #2177.) - Hover readouts on every non-sparkline GPS chart:
- Jitter Histogram — bucket label, sample count, and percentage of total.
- Allan Deviation — snaps to the nearest (τ, σ_y) marker within ~30 px and falls back to a τ-only readout when scrubbing between markers.
- SNR-vs-Elevation Scatter — snaps to the nearest satellite dot within ~12 px, shows PRN, constellation, elevation, and SNR.
- PRN Heatmap — shows PRN key, timestamp, and SNR for populated cells; explicitly labels sparse bins as "no sample in window" instead of rendering a blank tooltip.
Changed
- Allan-deviation Y-axis labels render in proper scientific notation (
10⁻⁷via Unicode superscript digits) instead of1e-7, matching the rest of the project's timing displays. (PR #2177.)
[2.79.2] - 2026-05-23 - Hold GPIO airchain for full composite duration
Fixed
- GPIO relays dropped the airchain assert as soon as the audio player exited, which on hosts without a real audio device (most container/dev installs and any production node where
aplayreturns immediately if the ALSA card is busy) released the relay before the downstream encoder finished framing — the composite was being trimmed mid-EOM. Bothwebapp/eas/messages.py(resend) andwebapp/eas/workflow.py(manual send) now (1) compute the authoritative composite duration with_wav_duration_seconds()against the actual WAV header (falling back to the stored metadata and a 60 s ceiling), (2) capturetime.monotonic()before invoking the player, and (3)time.sleep(remaining)after the player exits so the relay stays asserted for the full composite duration regardless of whether playback was blocking. Comments at both call sites explain the invariant so the next reader doesn't "optimise" the sleep away. (PR #2176.)
[2.79.1] - 2026-05-23 - Locally vendored Chart.js / Bootstrap / Font Awesome
Changed
- Repo Stats page (
static/repo_stats.htmland itsscripts/generate_repo_stats.pygenerator) no longer hits external CDNs for Chart.js, Bootstrap, or Font Awesome. All three are now served from the existing/static/vendor/tree (chartjs/chart.min.js,bootstrap/bootstrap.min.css,fontawesome/css/all.min.css) so the page renders identically on air-gapped installs, survives upstream CDN outages, and produces deterministic builds. The generator and the rendered HTML were updated in lockstep so re-running the generator does not regress the page to the old CDN URLs. (PR #2175.)
[2.79.0] - 2026-05-23 - Documentation search and trademark typography
Added
- In-app documentation search backed by an on-demand index.
webapp/documentation.pygained_build_doc_search_index()(walks the docs root, parses out the H1 title of every.mdfile, keeps both original and lowercased bodies for case-insensitive substring matching, tags each doc with its containing-directory category) and_make_snippet()(produces a contextual ±80-char excerpt around the first match, with the matched span bolded). Results are cached in-process and invalidated by tracking the highestmtimeacross all scanned files, so an admin editing a doc and refreshing the page sees the change without a service restart. (PR #2174.)
Changed
- Standardised
EAS Station™(no space before the symbol) across the entire repository. The mixedEAS Station ™/EAS Station™formatting drifted across markdown, templates, CSS comments, JS file headers, VS Code workspace files, and config samples. All occurrences were normalised to the no-space form (proper typographic convention) so the brand reads consistently on every page, every email, every system-tray tooltip, and every doc PDF. This is purely a presentation pass — no functional code paths were touched. (PR #2174.)
[2.78.0] - 2026-05-23 - Dual-mode SAME audio capture buffer
Changed
Replaced the single fixed-size SAME audio ring with two operating modes. The old single
_ring_max_samplesbudget had to be sized for the worst case (full ZCZC→EOM capture, potentially several minutes), which permanently held that buffer per source even during idle 99 %+ of the time, and conversely failed long alerts when the operator had tuned the buffer down for memory. The capture loop now distinguishes:- Pre-roll mode (idle):
_preroll_max_samples≈ 10 s @ 16 kHz, enough to retain the ZCZC back-track (~1.5 s) plus the three SAME header bursts (~4.8 s) the decoder needs. - Capture mode (live ZCZC→EOM):
_capture_max_samples≈ 5 min + 10 s headroom, no eviction while the alert is in-flight so the full audio body is captured intact, with the ceiling acting as a sanity stop for a stuck capture.
A new
_capturing_sourcesset tracks which sources are mid-capture and is guarded by_audio_rings_lockso the monitor-loop's trim policy and_handle_alert()/_on_eom_received()agree on capture state under all interleavings. Lock ordering is preserved (capture-state updates always take_audio_rings_lockfirst), and the ring snapshot is taken after the capture-state flag is cleared so the snapshot has consistent semantics. (PR #2173.)- Pre-roll mode (idle):
[2.77.0] - 2026-05-23 - Marine zone catalog support
Added
- Marine zone DBF upload now actually works (
/admin/zones/uploadpreviously 500'd on everymz_*.dbfwithValueError: DBF is missing required fields: STATE, CWA, TIME_ZONE, FE_AREA, ZONE, STATE_ZONE, SHORTNAME). The public-zone schema parser atapp_utils/zone_catalog.pywas the only one wired in even though the upload page andtools/download_nws_gis_data.py --marineboth advertised marine support.iter_zone_recordsnow detects the DBF schema by column inspection and dispatches to either_parse_public_record(existing public/forecast columns) or_parse_marine_record(marine schema:ID,WFO,GL_WFO,NAME,LON,LAT). The marine parser stores the 2-letter UGC prefix (PS,GM,AM,LM, …) innws_zones.state_codeso the new marine state-tree builder can find them with a simpleIN (...)filter. Verified against the officialmz16ap26.dbffromweather.gov/gis/MarineZones(569 zones across 15 marine prefixes). - Admin FIPS picker surfaces marine areas loaded via
mz_*.dbfandoz_*.dbf.app_utils/fips_codes.pygainedMARINE_PREFIX_TO_SAME_STATE(UGC prefix → SAMESSdigits, sourced from the NWS Coastal and Offshore Marine Codes Listings for EAS and NWR Applications §6 — all 15 marine prefixes covered: PZ=57, PK=58, PH=59, PS=61, PM=65, AN=73, AM=75, GM=77, LS=91, LM=92, LH=93, LC=94, LE=96, LO=97, SL=98),MARINE_AREA_LABELS(the official geographic-area names from the same table),get_marine_state_tree()(queriesnws_zonesand emits state-shaped entries with 6-digitPSSCCCcodevalues per marine area), andget_extended_state_county_tree()which composes the static US tree with the runtime marine tree.webapp/admin/dashboard.pynow feeds the admin template from the extended version so the State/County dropdown can show e.g. Gulf of Mexico → Coastal waters from Pensacola FL to Pascagoula MS out 20 NM (077650) and operators can add marine SAME codes through the normal picker UI. The GM=77 mapping was additionally cross-verified on-station against an SMW header carrying077650/077633/077632/077631whose CCC values match GMZ650/GMZ633/GMZ632/GMZ631 byte-for-byte in the NWS marine zone DBF. All 699 zones acrossmz16ap26.dbf+oz16ap26.dbfare now pickable. The baseget_us_state_county_tree()remains DB-free so import-time callers (app_core/location.py:70,app_core/alert_filtering.py:46) are untouched. tools/match_same_to_zone.py— discovery CLI for verifying any future NWS marine prefix → SS mapping (and for cross-checking ignored alerts). Given one or more 6-digit SAME codes, it splits each into P/SS/CCC and prints everynws_zonesrow whosezone_numbermatches the CCC portion across every prefix, letting the operator pick the candidate whose area name matches the alert's described location. Useful when NWS adds a new marine prefix or for sanity-checking that a particular alert's codes really do correspond to the geographic area the alert text describes.
Changed
sync_zone_cataloggaineddelete_scope(None | False | "public" | "marine"). The previous full-replace behaviour deleted orphans unconditionally, which meant uploading any ofz_*.dbf/mz_*.dbf/oz_*.dbfover each other wiped every previously-loaded zone from the other catalogs. Admin uploads now passdelete_scope=Falseso they are purely additive — operators can layer the public, marine coastal (mz), and marine offshore (oz) catalogs without one displacing another. The schema-scoped values ("public","marine") remain available for the startup auto-load / explicit Reload path where a single authoritative file should drive a full sync.current_app.config['NWS_ZONE_DBF_PATH']is only updated for public-zone uploads so the on-restart auto-load isn't redirected to a marine-only file.
Fixed
/admin/zones/uploadnow logs the actual exception instead of juststr(e). The handler now useslogger.exception(...)so the full traceback lands in the journal, rolls the SQLAlchemy session back so a partial sync doesn't poison subsequent requests, and prefixes the JSON error with the exception class (ValueError: …,PermissionError: …) so the browser response body identifies the failure mode without needing to read server logs. The assets upload directory is also resolved againstcurrent_app.root_pathinstead of the process CWD (the systemd unit'sWorkingDirectoryis/opt/eas-station, but CWD can shift in other launch modes), and filenames thatsecure_filename()reduces to an empty string are rejected up front instead of lettingfile.save()write to a directory path.
[2.76.0] - 2026-05-22 - Hardware service split into per-subsystem subprocesses
Changed
hardware_service.py(single process, port 5001) split into five per-subsystem subprocesses bundled undereas-station-hardware.target. The monolithic hardware service was a single point of failure: a stuck zigpy-znp serial read, a hung I²C OLED transaction, or a busy-looping GPS NMEA reader would block every other subsystem in the same process — including the per-subsystem REST endpoints the web app relies on. The process is now split across five systemd units listening on dedicated ports, each owning exactly one subsystem and one Flask blueprint:eas-station-network.service→ port 5101 (services.network)eas-station-zigbee.service→ port 5102 (services.zigbee, owns the zigpy-znp coordinator)eas-station-gps.service→ port 5103 (services.gps)eas-station-displays.service→ port 5104 (services.displays, owns OLED / LED / VFD rendering)eas-station-gpio.service→ port 5105 (services.gpio)
Each unit runs as the
eas-stationuser with subsystem-specificDeviceAllow=/SupplementaryGroups=so only the subprocess that needs serial / I²C / SPI / GPIO access has it.eas-station-hardware.targetWants=all five so a singlesystemctl restart eas-station-hardware.targetrolls them as a group. Process isolation, memory caps (MemoryMax=128Mper unit), and per-unit journal identifiers make subsystem failures diagnosable without correlating against one giant log stream.Shared bootstrap extracted to
services/common/. Logging configuration, Redis connection, Flask-app database initialisation, signal handlers, and the Redis metric publishers that were previously duplicated inline at the top ofhardware_service.pynow live inservices/common/bootstrap.pyandservices/common/metrics.py. Each subsystem's__main__.pyis now a thin orchestrator (Redis connect → DB connect → blueprint register → API thread → heartbeat loop) so adding a new subsystem is a copy-paste-rename, not a re-implementation.Web-app proxies updated to per-subsystem URLs.
HARDWARE_SERVICE_URL(port 5001) was removed and replaced withNETWORK_SERVICE_URL(5101),ZIGBEE_SERVICE_URL(5102),GPS_SERVICE_URL(5103),DISPLAYS_SERVICE_URL(5104), andGPIO_SERVICE_URL(5105) inapp_core/config/services.py. Every web-app call site inwebapp/admin/{zigbee,gps,network,displays,gpio}.pyandwebapp/admin/hardware.pywas updated to dispatch to the correct subsystem service.install.sh/update.sh/uninstall.shupdated for the split. Both installersenable --nowthe five new per-subsystem units and the bundlingeas-station-hardware.target.update.shadditionally retires the legacy monolithiceas-station-hardware.service(disable + remove from/etc/systemd/system/) so existing installs don't keep trying to revive it.uninstall.shwas taught to stop / disable / remove the five new units in addition to the legacy one.eas-station.targetcontinues to be the single user-facing entry point.Phase 6 regression test pinned (
tests/test_hardware_service_url_audit_phase6.py). Asserts (a) no module exportsHARDWARE_SERVICE_URL, (b) no live code imports or attribute-accesses it, and (c) no Python source references port5001for hardware service calls. Adding a backwards-compat shim is therefore CI-blocking — the split is durable.
Fixed
- Automatic RWT broadcasts had never successfully fired — every minute inside the configured window every gunicorn worker logged
Triggering automatic RWT broadcast, thenFailed to trigger RWT broadcast: (psycopg2.errors.NotNullViolation) null value in column "storage_path" of relation "manual_eas_activations" violates not-null constraint. Themanual_eas_activations.storage_pathcolumn isNOT NULLat the schema level — it carries the on-disk directory for operator-triggered broadcasts created viawebapp/eas/workflow.py— but the automated scheduler inapp_core/rwt_scheduler.py:211-233never populated it (automated RWTs write audio to the DB blob columns, not to disk). Every insert violated the constraint and the broadcast row, the audio payload, and thelast_run_atupdate all rolled back together.app_core/rwt_scheduler.pynow passesstorage_path='', which satisfies the NOT NULL constraint and which the cleanup path_remove_manual_eas_files()(workflow.py:1488 —if not activation.storage_path: return) already treats as "no on-disk files to delete". - Every gunicorn worker ran its own RWT scheduler, so the broadcast was being attempted N times per minute.
app.py:793-804starts the scheduler at module-import time, which happens in each worker process — the production journal showed[501597]and[501598](two workers, default config) firing identifierRWT-AUTO-<same timestamp>in the same second every minute. Without a deduplicator each worker would have recorded a separateManualEASActivationrow once thestorage_pathfix above unblocked the insert._check_and_send_rwt()now acquires a RedisSET NX EXlock atrwt:fired:<schedule_id>:<YYYY-MM-DD>(25 h TTL — slightly longer than a calendar day so a window straddling midnight is still covered) before callingtrigger_rwt_broadcast(); losing workers log once per hour and skip silently. If Redis is unreachable the code falls back to the historical best-effort behaviour rather than blocking RWT on a Redis outage. - Zigbee subsystem unit crash-looped with
status=226/NAMESPACEon fresh installs.eas-station-zigbee.servicelists/var/lib/eas-stationinReadWritePaths=(zigpy persists its NCP/network state to/var/lib/eas-station/zigbee.dbperservices/zigbee/coordinator.py:144-147), but neitherinstall.shnorupdate.shever created that directory and no other subsystem unit referenced it. WithProtectSystem=strict, systemd couldn't set up the mount namespace, exited with226/NAMESPACEbefore Python started, and the unit looped forever — the web UI consequently rendered "Serial Port: Not Accessible" because port 5102 was unreachable. AddedStateDirectory=eas-station/StateDirectoryMode=0750to the unit so systemd auto-creates/var/lib/eas-stationowned byeas-station:eas-stationbeforeExecStart, and added explicitmkdir/chownfor the same directory to bothinstall.sh(with the existing log-dir block) andupdate.sh(after the log-dir block, so existing installs that already hit the crash loop recover on the nextsudo bash update.sh). - Zigbee Monitoring "Serial Port: Not Accessible" gave no actionable reason. The UI surfaced only the boolean accessibility flag and dropped the underlying error string from
/api/zigbee/test_port, so a connection refused (Zigbee subsystem subprocess down), a serial open failure (dongle in use / wrong permissions / unplugged), and a non-existent device path all read identically as "Not Accessible".templates/admin/zigbee.htmlnow renders the proxy's error message (e.g. "Cannot reach Zigbee subsystem at http://127.0.0.1:5102. Check: sudo systemctl status eas-station-zigbee.service" or "Cannot open port: [Errno 13] Permission denied: '/dev/ttyUSB0'") directly under the badge so the operator sees which of the four cases they're in. The proxy error string inwebapp/admin/zigbee.pywas also rewritten — it previously said "hardware service" (the now-retired monolithic process) and named no concrete URL or systemd unit; it now names the per-subsystem service URL and the exactsystemctl statuscommand to run. - RWT "Next scheduled fire" appeared to advance every minute when inside the firing window, which operators read as the broadcast being "pushed back".
compute_next_fire()inapp_core/rwt_scheduler.pyreturnedmax(now, window_start)for the today-in-window case, so every page refresh inside e.g. an 08:00–16:00 Wednesday window saw the timestamp climb by ~1 minute. The scheduler thread checks the window every minute regardless of what the UI shows, so the displayed value is now pinned to the operator-scheduledwindow_startand the UI only changes when the schedule itself changes. - Changelog / Version page was unreadable on every dark theme — the page predated the project's theme tokens and was authored against a never-implemented "design system" naming scheme (
--color-surface,--color-text-primary,--color-text-secondary,--color-border,--color-border-light,--color-text-muted,--color-neutral-100,--color-success-light). With those variables undefined,var()fell through to comma fallbacks for backgrounds (white) but had no fallback for text colors, so every text rule inherited the body's theme-driven--text-color. On all dark themes (midnight, obsidian, charcoal, slate, etc.) that produced white-ish text on a white card and the Changelog / Features tabs were effectively blank. Rewrotetemplates/version.htmlto use the variables that actually exist on every theme (--surface-color,--text-color,--border-color) and pinned explicit dark foregrounds (#0d1b2a,#212529,#495057) on the white-surfaced cards plus dedicated.tab-contenttypography rules so headings, paragraphs, change-list bullets, and feature names contrast on every theme. - Live waterfall on Radio Receiver Diagnostics — the "Show Waterfall" control used to fire a single capture (≤5 s) and render one static spectrogram, which neither matched what operators expect from a waterfall nor refreshed without re-clicking. Replaced with a continuously-updating waterfall that polls the existing
/api/radio/spectrum/<id>endpoint (already published to Redis by the SDR hardware service at ~10 Hz and used as the data source for the main/admin/radiospectrum view), scrolls one FFT row onto a canvas every ~500 ms with the same blue→green→yellow→red dBFS colour ramp as the one-shot view, and exposes Start/Stop controls plus a row counter and status line. The one-shot capture-and-verdict workflow (which actually computes the clipping verdict from raw IQ) is preserved under a new "Snapshot" button so the existing peak/RMS/clipping diagnostic is not lost.
[2.75.0] - 2026-05-15 - Tamper-evident audit log
Added
- Tamper-evident audit log. Extended the existing
audit_logstable (added by20251105_add_rbac_and_mfa) with three new nullable columns —prev_hash,entry_hash,signature— and wired chain construction and Ed25519 signing intoAuditLogger.log(). Every newly-recorded audit row now carries the SHA-256 of its predecessor'sentry_hash, an SHA-256 over its own canonical-JSON content (including theprev_hashlinkage), and an Ed25519 signature over that hash. Verification is exposed viaAuditLogger.verify_chain(limit=None), which walks the table, checks every signed row's prev-link, recomputes its content hash, and verifies the signature against the configured public key — returning{ok, checked, unsigned, ephemeral_key, first_bad_id, reason}. The signing key is loaded fromAUDIT_SIGNING_KEY_PATH(production) →${REPO_ROOT}/secrets/audit_signing.key(development) → an in-process ephemeral key (with a loud warning) so the chain never silently breaks startup. New SQLAlchemyafter_insertlisteners onCAPAlert,EASMessage, andManualEASActivation(registered from the app factory viaapp_core.auth.audit_listeners.register_audit_listeners) automatically capture every alert-lifecycle insert without touching the 5+ existing creation sites. Deliberately reused the existingaudit_logstable and/admin/audit-logsviewer rather than creating a parallel "audit ledger" table or admin page, so the menu picks up tamper-evidence with zero new navigation entries. Migration20260515_add_chain_columns_to_audit_logs.pyis fully additive (nullable columns, idempotent up/down, no data backfill). Tests intests/test_audit_chain.pycover happy-path chain construction, verifier acceptance, tamper detection indetails, tamper atprev_hash, signature forgery, mid-chain row deletion, and the ephemeral-key bootstrap fallback. Pinnedcryptography>=46.0.5inrequirements.txt(avoids the GHSA on SECT-curve subgroup validation; Ed25519 itself is unaffected, but the same library exposes the affected curves). - Attribution for the new
cryptographydependency. Added a footer badge intemplates/partials/tech_stack_badges.html, a top-level shield inREADME.md, a "Security / auth / notifications" attributions table row in the README's## 📚 Attributions & Open-Source Creditssection, an Authentication & Notifications stack card ontemplates/about.html, and a System and Utilities bullet indocs/reference/ABOUT.md— each explaining that the library underpins the Ed25519 signing and SHA-256 hashing for the tamper-evidentaudit_logschain. The drift-guardtests/test_tech_stack_badges.pycontinues to pass.
[2.74.0] - 2026-05-14 - Tech-stack attributions, IQ capture, GPS & Time dashboard
Added
- Single source of truth for tech-stack shields, with what-each-library-does explanations everywhere. The footer's "Built With Modern Technologies" badge strip was previously inlined into
templates/base.htmlwhile a second, larger copy lived in the orphantemplates/partials/footer.html(not included by any template — silently drifting). The badge list is now consolidated into a new partialtemplates/partials/tech_stack_badges.htmlwhichbase.html{% include %}s; the orphanpartials/footer.htmlwas deleted. The canonical set was expanded from ~13 to ~35 shields to credit every major dependency that genuinely powers a user-visible feature: Werkzeug, Jinja2, SciPy, Numba, lxml, Pillow, pydub, FFmpeg, eSpeak NG, PyOTP, Twilio, chrony, gpsd, Docker, and Alembic were missing from the on-page footer; they're now attributed. Every footer badge now carries atitle="..."hover tooltip that explains, in 1–2 sentences, exactly what that library does for EAS Station™ specifically (not a generic upstream blurb) — e.g. Numba reads "JIT-compiles the SAME DLL and RBDS workers (~6× faster real-time demod on a Pi)", chrony reads "NTP daemon. Consumes the GPS NMEA fix + PPS edge as a kernel refclock and serves stratum-1 NTP". The README's top badge block was rewritten to match the same curated set, and a new## 📚 Attributions & Open-Source Creditssection near the bottom ofREADME.mdlists every Python dependency fromrequirements.txt, every system package (PostgreSQL/PostGIS, Redis, Nginx, Icecast, FFmpeg, eSpeak NG, chrony, gpsd, systemd, Let's Encrypt/Certbot, Docker), and every vendored/CDN front-end asset (Bootstrap, Font Awesome, Leaflet, Chart.js, Socket.IO client) in nine grouped tables. Each row has a Purpose in EAS Station™ column with the long-form explanation alongside the upstream license identifier and project URL — the proper home for the long tail of credits (Flask-WTF, Flask-Limiter, requests, httpx, pyserial, zigpy, pyshp, pyproj, ...) that don't warrant a top-level shield. - Drift guard
tests/test_tech_stack_badges.py. Asserts (a)base.htmlactually{% include %}s the badge partial, (b) the deletedpartials/footer.htmlis not resurrected, (c) for a curated subset of versioned Python libraries (Flask, Werkzeug, Jinja2, Socket.IO, SQLAlchemy, Alembic, Gunicorn, NumPy, SciPy, lxml, Pillow, pydub, PyOTP) the version pinned inrequirements.txtappears verbatim in both the README badge block and the footer partial, (d) system-level dependencies (Nginx, Icecast, SoapySDR, FFmpeg, eSpeak NG, Raspberry Pi, Docker, Systemd, Redis, chrony, gpsd, Twilio, Numba, gevent) remain attributed in both surfaces, and (e) every<a class="tech-badge">in the footer partial carries a non-trivial (≥25-char, sentence-shaped)title="..."tooltip. Bumping a dependency inrequirements.txtwithout bumping the matching shield now fails CI.docs/process/CONTRIBUTING.mdwas updated with a "Keep tech-stack attributions in sync" rule pointing contributors at the canonical files. - IQ capture-to-file from Radio Diagnostics page (and via SDR-service command). Operators can now grab a raw complex64 IQ recording from any active receiver without SSHing into the host — useful as input to
scripts/rbds_diagnose.py,inspectrum, GNU Radio, or any other offline analyser. A new "Capture IQ" button next to each receiver on/admin/radio/diagnosticstriggers a one-second capture; the browser then streams the resulting.npyfile via a single-use download URL (file deleted after the download completes). The page also gains an "About IQ Captures" collapsible help card that explains, in operator-friendly terms, what an IQ capture is, the typical reasons to grab one (RBDS/RDS decode investigation, SAME/EAS replay, signal-quality analysis, bug-report evidence), and step-by-step instructions for what to do with the downloaded.npyfile (load in Python, feed torbds_diagnose.py, open in inspectrum / GNU Radio). Two new HTTP endpoints back the button:POST /api/radio/diagnostics/capture/<receiver_id>(returnscapture_id, filename, size, and adownload_url) andGET /api/radio/diagnostics/capture/<capture_id>/download(streams as attachment, then cleans up). A newcapture_iqaction insdr_hardware_service.pydoes the actualnumpy.save()toRADIO_CAPTURE_DIR(default/var/log/eas-station/captures, override via env), capped atRADIO_CAPTURE_MAX_SAMPLES(8M samples ≈ 64 MB) to bound memory and disk. Sample count is bounded by the SDR ring buffer (~2 s) and the web layer'sRADIO_CAPTURE_MAX_DURATION_SEC = 5. Path-traversal is blocked on both sides: capture IDs are hex UUIDs and the on-disk path is verified to resolve under the allow-list directory before the file is served. Regression coverage intests/test_radio_diagnostics_capture.py(happy path, traversal rejection on tampered Redis value, invalid capture-id format, missing/expired capture, timeout from the SDR service). - GPS & Time Dashboard at
/admin/gps-dashboard— a dense, single-page status page modelled visually on W0CHP's chrogps-dash and reachable from the Admin → Hardware tab. Surfaces, all in one place: a header banner with the station hostname, a live "GPS LOCKED / ACQUIRING / NO GPS" pill, a fix-mode pill (2D/3D), and a colour-graded "STRATUM N" pill; a System Tracking card that renders every field ofchronyc -c tracking(Reference ID, Stratum, Ref time, System / Last / RMS offsets, Frequency, Residual freq, Skew, Root delay/dispersion, Update interval, Leap status) plus a logarithmic Sync-Health bar that maps |offset| → 0-100 % so a sub-microsecond stratum-1 lock visibly pegs the meter; a Satellite Skyview card with a polar sky plot (cardinal cross-hair, 30°/60° elevation rings, dots coloured by constellation, alpha by SNR, glowing outer rings on used-in-fix sats), a Signal-Integrity bar (avg-SNR-scaled), GPS position (lat/lon/alt), DOP block (HDOP/VDOP/PDOP), serial port + baud, and Constellation Breakdown chips (e.g. GPS 9/9, GLONASS 4/5, Galileo 7/7, BeiDou 17/21); an Individual Signal Levels per-PRN bar chart sorted by constellation then PRN; a Chrony Sources table parsingchronyc -c sources(mode/state glyph, source name, stratum, poll, octal reach, last-sample age, current offset with sign-coloured cells); a Satellite Data table (PRN coloured by constellation, EL, AZ, SNR with the existing 6-stop colour scale, ACTIVE/VIEW status) with click-to-filter constellation chips. The page polls a single new endpoint,/admin/api/gps-dashboard/data, on a configurable 3 / 5 / 10 / 30 s interval (or paused). All styling uses the existing theme CSS variables so it adapts to every dark and light theme — no fixed palette. - New backend endpoints in
webapp/admin/hardware.py:gps_dashboard_page(HTML route, gated bysystem.configure) andgps_dashboard_data(JSON aggregation route). The JSON route composes the live GPS fix from the hardware service's/api/hardware/gps/status(tolerant of an unreachable hardware service so the chrony half still renders during a service restart) with locally-collectedchronyc -c trackingandchronyc -c sourcesoutput. The CSV parsers themselves live in a new pure module,app_utils/chrony_parser.py(parse_chronyc_tracking_csv,parse_chronyc_sources_csv) — split out of the route file so they're testable without the Flask stack and reusable from any future timing view. New "GPS & Time Dashboard" tile intemplates/admin.htmlHardware tab links to the page. Regression coverage intests/test_gps_dashboard_chrony_parser.py(8 cases covering full records, blank fields, truncated CSVs, sign-preserving offsets, mixed mode/state rows, short-row skipping, empty input, and non-numeric "-"/"?" sentinels in numeric columns). - Time-series performance graphs from the chrogps-dash reference (PPS Drift, Clock Stability, Frequency Steering & Skew, Root Dispersion, NTP Measurements, Satellite Visibility History, GPS SNR Trend, DOP History, Satellite SNR by Constellation) are intentionally deferred: they require a historical time-series store this codebase does not yet maintain. The dashboard's footer calls this out.
Fixed
- Constellation colour-coding restored on the GPS sky plot and satellite tables (Hardware Settings + System Health) when running in gpsd mode. The direct-NMEA path emits each satellite with a
"constellation"key (the 2-letter NMEA talker —GP,GL,GA,GB,GQ,GI); the gpsd path was emitting the same value but under the key"talker". The frontend (templates/admin/hardware_settings.htmlconstellationInfo()andtemplates/system_health.htmlgpsConstInfo()) only readssat.constellation, so in gpsd mode every dot, badge, and legend chip silently fell back to neutral grey. Renamed the gpsd-side key to"constellation"inapp_core/gps/gps_manager.py::_handle_gpsd_skyso both ingest paths produce identically-shaped satellite records and the UI colours by constellation again. Crucially this is the recommended mode for stratum-1 timing (gpsd → chrony refclock), so the regression was hitting exactly the deployments that should look the best.
Documentation
- Stratum 1 GPS time server documented as a first-class feature. The repository previously documented the GPS HAT only as a hardware-setup procedure; the resulting capability — a true stratum 1 NTP server, GPS-disciplined via PPS, with a battery-backed RTC and one-click admin-UI setup — was not surfaced where new users decide whether to deploy the platform. Updated the marketing/feature surfaces to call this out: a new "🛰️ Built-In Stratum 1 NTP Time Source" section in
README.md(between Hardware Integration and the Modern Web Dashboard), a new "Stratum 1 GPS-Disciplined Time" section card on the public About page (templates/about.html) with six feature cards (multi-GNSS receiver, hardware PPS edge, true stratum 1 NTP, battery-backed RTC, air-gap friendly, one-click setup), an additional "Stratum 1 Time" hero chip and "Stratum 1 GPS Time Source" Key Features bullet, and a new sentence in the reference build description indocs/reference/ABOUT.md. No code changes; existing GPS HAT setup guide atdocs/hardware/GPS_HAT_SETUP.mdremains the authoritative procedural reference.
Changed
- Backfill completed for recent release metadata; new changes should be documented here going forward.
[2.73.5] - 2026-05-09 - Backfill changelog/version history
Fixed
- Phantom whitespace below the page footer on every page (PR #2040 follow-up):
Three independent bugs were combining to leave a band of empty space below the
footer that resisted multiple earlier fixes.
static/css/styles.csshad 14 un-commented section headers at top level (lines like1. RESET & BASE STYLESandPERFORMANCE OPTIMIZATIONS). Per the CSS error-recovery rules, each one was parsed as the prelude of an invalid qualified rule, silently consuming the first real rule that followed it. The casualty in section 5 was.card { overflow: hidden }, which let a::afterglow pseudo-element escape its parent and stretch the document height; PR #2040 patched section 5 only. Sections 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, and the un-numbered "PERFORMANCE OPTIMIZATIONS" header were also wrapped in/* … */.footer { margin-top: var(--footer-margin-top) }relied on.page-shell { flex: 1 0 auto }to consume any leftover space in thebodyflex column. Switched the footer tomargin-top: auto, the canonical sticky-footer guarantee — the footer is now pinned to the bottom ofbody(which hasmin-height: 100vh) regardless of whether.page-shell's grow is honored, so no whitespace can appear below it. The 4rem footer top-padding still provides breathing room above.templates/base.htmlcache-bustsstyles.csswith?v={{ static_asset_version }}, derived from theVERSIONfile. None of the prior CSS fix commits bumpedVERSION, so browsers and intermediary caches kept serving the stalestyles.css?v=2.73.2bytes even after a hard refresh — meaning every fix landed in source but never reached the rendered page.VERSIONis now bumped per AGENTS.md, forcing a fresh asset URL.
Changed
- EAS Station™ fingerprint trill changed to
0xA9: The post-burst ENDEC fingerprint byte emitted by_generate_station_terminator_samples()(3 copies appended after each SAME burst whenendec_fingerprintis enabled) has been updated to0xA9(10101001). The previous functional value in code was0xBB(introduced in 2.71.54); several comments and documentation files still referenced the original0xAAvalue (from 2.71.51) and have been corrected. The demodulatordetect_endec_mode()voting and post-message terminator capture updated to match (0xA9now triggersENDEC_MODE_EAS_STATION).
Documentation
.envdeprecation cleanup in user-facing docs: Several docs still instructed users to edit.envfor settings that have been migrated to the database (configured via the admin UI). Updated to reflect that polling, EAS broadcast, notifications, and application-logging settings now live in dedicated DB tables (per themigrated_varslist inwebapp/admin/environment.py); only boot-time infrastructure (SECRET_KEY,DATABASE_URL, hostnames, paths) belongs in.env. Files updated:docs/troubleshooting/POLLING_NOT_WORKING.md(no longer tells users to grep / edit.envforPOLL_INTERVAL_SEC/IPAWS_CAP_FEED_URLS/NOAA_USER_AGENT— points at Settings → Poller and thepoller_settingstable);docs/guides/ipaws_feed_integration.md(removed three.env-snippet config examples and the Quick Start "edit.env" instructions, replaced with admin-UI guidance);docs/architecture/THEORY_OF_OPERATION.md(sequence-diagram loop labels and prose no longer referencePOLL_INTERVAL_SECas an env var; "Configuration is read from.env" sentence corrected to distinguish runtime settings (DB) from boot-time infrastructure);docs/architecture/DATA_FLOW_SEQUENCES.md(originator substitution no longer claimsEAS_ORIGINATORenv var as an alternative — it's database-only via the Broadcast admin tab);docs/guides/SETUP_INSTRUCTIONS.md(Station ID validation note points at the Broadcast admin tab /eas_settings.station_idinstead of anEAS_STATION_IDenv var). No code changes.
Changed
- Admin UI Reorganization — Broadcast tab promoted to top-level: The EAS encoder settings (originator, station ID, sample rate, attention tone, pre/post-alert signals incl. MDC1200 / QC-II / DTMF, auto-forward event filter, authorized event codes) previously lived under System Settings → Alert Filtering as an appended section, where they had no logical relationship to alert filtering. They are now in their own top-level Broadcast admin tab (between System and Services). The Broadcast tab also consolidates links to every broadcast-related tool in one place: EAS Workflow (
/eas/), RWT Schedule (/rwt-schedule), EAS Compliance (/admin/compliance), Alert Verification (/admin/alert-verification), EAS Decoder Monitor (/admin/eas_decoder_monitor), Text-to-Speech (/admin/tts), and Audio Sources (/admin/audio-sources). The System Settings tab now contains only Location, Alert Filtering, and Alert Management subtabs — none of them broadcast-related. No backend, route, schema, or API changes; theeasSettingsFormand its/admin/eas_settingsendpoint are unchanged. Documentation updated:docs/guides/ALERT_SIGNALS.mdnow points to the Broadcast tab.
Fixed
update.shno longer silently corrupts the database when Alembic fails. Whenalembic upgrade headexited non-zero (or whenalembicwas not found),update.shwould silently fall back todb.create_all(). For migrations that move data — most recently20260506_split_location_settings— this fallback was actively destructive: it created the newalert_filter_settingstable empty, added an emptyhardware_settings.led_default_linescolumn, left the oldlocation_settings.fips_codes/zone_codes/storage_zone_codes/area_terms/led_default_linescolumns in place, and never advancedalembic_version. The visible symptom was the FIPS / broadcast-zone / storage-zone / area-term lists appearing empty in the admin UI even though the operator had not cleared them.update.shnow: (1) prints bothalembic currentandalembic headsbefore and after the upgrade attempt so the operator can see exactly which revision is pending and whether it advanced, (2) refuses to rundb.create_all()as a fallback (a clean Alembic failure is preferable to a half-migrated database), and (3) emits a clear retry command and a pointer to the new recovery script if the upgrade fails.- Recovery script for already-broken databases: new
scripts/database/recover_split_location_settings.pyfinishes a half-applied20260506_split_location_settings. It detects the half-migrated state (new tables exist but old columns still present onlocation_settingsand/oralembic_versionnot advanced), copies any non-emptyfips_codes/zone_codes/storage_zone_codes/area_termsfromlocation_settingsintoalert_filter_settings(only when the destination row is empty/default — never overwrites populated user data), copiesled_default_linesintohardware_settings, drops the orphaned columns, and stampsalembic_versionto20260506_split_location_settings. The script is idempotent and a no-op on a healthy database; it runs automatically at the end ofupdate.sh's migration step but can also be invoked by hand:sudo -u eas-station /opt/eas-station/venv/bin/python /opt/eas-station/scripts/database/recover_split_location_settings.py [--dry-run] [--quiet].
Changed
- Database Schema Reorganization: Split
location_settingstable into three specialized tables for better separation of concerns:location_settings: Retains geographic identity (county, state, timezone, map coordinates)alert_filter_settings: New table for alert filtering criteria (FIPS codes, zone codes, storage zone codes, area terms)hardware_settings: Now includesled_default_lines(moved from location_settings)
- Admin UI Reorganization: The admin Location subtab has been split into two subtabs: Location (county / state / timezone / map defaults) and Alert Filtering (FIPS codes, broadcast zones, storage zones, zone lookup, location reference card). The two forms now save independently to
/admin/location_settings(PUT) and/admin/alert_filtering(POST) respectively. - API Changes: Added new
/admin/alert_filteringendpoint; existing/admin/location_settingsendpoint remains backwards compatible
Technical Details
- The
get_location_settings()function maintains backwards compatibility by returning a merged dictionary with the same shape as before - Migration
20260506_split_location_settingshandles data migration automatically - No changes required to existing consumers (poller, EAS encoder, audio monitor, etc.) due to compatibility layer
Fixed
- GPS live panel now matches the active theme — replaced all hardcoded GitHub-dark palette values (
#0d1117,#161b22,#30363d,#c9d1d9,#8b949e, etc.) in the GPS section of Hardware Settings with CSS custom properties (var(--bg-color),var(--surface-color),var(--border-color),var(--text-color),var(--text-muted),var(--warning-color),var(--danger-color),var(--accent-color)). The sky-plot canvas now reads theme colors at paint time viagetComputedStyle, so the polar grid, labels, and cardinal marks adapt correctly in all 11 built-in themes (both light and dark). - GPIO control panel and pin-map pages returning 500 errors —
url_for('gpio_statistics_page')intemplates/gpio_control.htmlandtemplates/gpio_pin_map.htmlraised aBuildErrorat render time because the endpoint lives in thedashboardBlueprint and must be referenced asdashboard.gpio_statistics_page. Both templates now use the correct qualified name. - RBDS detail rendering regression in
static/js/rbds_visualization.js. PR #1975 introduced the sharedRBDSVizhelpers but the new helpers only re-implemented a subset of the inline RBDS block previously rendered on the Audio Monitoring page. As a result, the following RBDS fields stopped appearing in the UI even though the API (/api/audio/sources→metrics.metadata) still emits them: PTYN (rbds_pty_name), AF method-B / LF-MF follow-on indicators, linkage actuator and soft-coupling flags, language code (hex), Open Data Applications (rbds_oda_*— assignments, app IDs and raw payloads), Slow Labelling (Group 1A variants), Radio Paging (Group 7A) and Enhanced Paging (Group 13A), Transparent Data Channel, In-House Application Data, and Fast Switching (Group 15B). All of these are now rendered again by the shared module, so all three pages that consume it (templates/admin/radio.html,templates/audio_monitoring.html,templates/admin/radio_diagnostics.html) regain parity with the API payload.
Added
- MDC1200 selective-calling signal profile — Motorola 1200-baud FFSK selective-calling (mark = 1200 Hz, space = 1800 Hz) is now available as a pre/post-alert signal. Each packet carries a 16-bit Unit ID and an op-code (PTT-ID Pre / Post, Emergency, Request to Talk, Remote Monitor, or operator-supplied raw bytes). When both pre and post signals are MDC1200 and the preset is
ptt_id_pre(default), the post side automatically substitutesptt_id_postso receiving Motorola subscribers see a complete bookend pair. The driving use case is forwarding EAS audio over an existing two-way LMR system: subscribers display the calling unit ID, optionally selectively unmute, log the call, and close the call cleanly on the post-ID. All byte/word fields (mdc1200_unit_id,mdc1200_op_code_raw,mdc1200_arg_raw) accept either decimal or0x..hex notation including theA–Fdigits, matching Motorola CPS conventions. New encoder lives atapp_utils/mdc1200.py; full technical reference (frame format, CRC-16, K=7 R=1/2 FEC, 16×7 interleaver, differential modulation, op-code table) at docs/reference/protocols/MDC1200.md. Configured under Admin → EAS Broadcast Settings → Pre/Post-Alert Signaling, persisted ineas_settings(Alembic migration20260505_add_mdc1200_to_eas_settings). Verify generated packets with a dedicated MDC1200 decoder such asmdc-decoder(mainline multimon-ng has no MDC demodulator). - Protocol technical reference docs — new
docs/reference/protocols/directory with engineering-level specifications for both SAME (FCC §11.31 / NRSC-4-B §4 — modulation, burst structure, header field grammar, attention tone, EOM, composite audio assembly, encode/decode pipelines) and MDC1200. The index at docs/reference/protocols/README.md describes how the two protocols compose in a single broadcast. - Pre/post-alert signals — system-level configurable attention signals played before each SAME header (pre-alert) and/or after the EOM (post-alert). Supported profiles:
none,bell(decaying 880 Hz),beep(1 kHz tone),three_tone(440 / 880 / 1320 Hz),qc2(Motorola Quick Call II two-tone with configurable Tone A / Tone B), anddtmf(configurable digit sequence using ITU-T Q.23 / Q.24 100 ms / 50 ms timing). Applies to auto-forwarded CAP/IPAWS, OTA relay, and manual broadcasts; signals sit outside the SAME signalling so they never affect decoder behaviour. Configured under Admin → EAS Broadcast Settings → Pre/Post-Alert Signaling, persisted ineas_settings(Alembic migration20260501_add_alert_chime_to_eas_settings). See docs/guides/ALERT_SIGNALS.md. - Statistics dashboard — new charts (severity-mix-over-time, cumulative alerts, top 5 events trend, hour-of-day × severity, alert duration histogram, year-over-year overlay, EAS forwarding funnel), a one-click PDF report export of the dashboard, and a print stylesheet so the browser "Print to PDF" route also produces a clean report. Filtered alerts CSV / dashboard PDF / summary metrics JSON are now grouped under a single Export ▾ dropdown in the filter panel.
Dependencies
- Added (frontend, vendored under
static/vendor/jspdf/):jspdf4.2.1 andhtml2canvas1.4.1 (both MIT) — used by the Statistics dashboard for client-side PDF report generation. No new server-side dependencies; PDF generation runs entirely in the browser.
[2.73.4] - 2026-05-09 - Enforce VERSION/CHANGELOG guardrails in CI
Added
- Release metadata CI workflow — Added
.github/workflows/release-metadata.ymlto runtests/test_release_metadata.pyon pull requests and pushes tomain/develop. This enforces the existing contributor requirement to keepVERSIONanddocs/reference/CHANGELOG.mdaligned for behavioral changes.
Fixed
- Missed version/changelog updates no longer slip through review — The repository previously had no workflow running release-governance checks, so instructions in
docs/development/AGENTS.md/docs/process/CONTRIBUTING.mdwere advisory only. CI now blocks regressions when release metadata is not updated.
[2.71.74] - 2026-04-29 - FM stereo decoding, per-source decoder streams, WebSocket alerts
Fixed
RBDS Radio Text reassembly in
app_core/radio/demodulation.py(PR #1953). Two long-standing bugs inRBDSDecoder._update_radio_text: (a) the visible RT was joined withrstrip(), so any leading spaces introduced by unprintable RDS Annex-E bytes (e.g. 0xE9, which the printable-ASCII filter maps to space) leaked into the displayed text and pushed the actual content rightward; and (b) once a 0x0D carriage-return arrived at index N, the terminator was permanent — if the station later re-broadcast the same segment with a non-CR byte at index N (the documented RDS-extension idiom), the visible RT could never grow back out. Fix: track the most-recent CR index and release the terminator when a later group writes a non-CR byte to that exact slot, and trim both ends of the assembled RT. Restores the two previously-failing tests intests/test_rbds_demodulation.py(test_group_2a_preserves_high_bit_charactersandtest_radio_text_grows_when_station_extends_without_cr).FM stereo (L–R) decoding now produces real channel separation (PR #1953). The previous
_decode_stereoinapp_core/radio/demodulation.pysynthesized a free-running 38 kHz oscillator and then discarded the bandpass-filtered pilot it had just computed. With any pilot frequency offset (always the case in practice — SDR clocks differ from broadcasters' by tens of ppm) the L-R signal slowly rotated against the carrier and channel separation collapsed toward mono, with random crosstalk from the chunk-boundary phase reset. The fix derives the 38 kHz reference directly from the recovered pilot by squaring it (cos²(ωt) = ½(1 + cos(2ωt))), giving an exact, phase-coherent carrier that tracks both pilot drift and chunk boundaries with no PLL state. Newtests/test_fm_stereo_decoder.pyverifies >30 dB channel separation in the basic case, with a +12 Hz pilot offset, and across chunk boundaries. Resolves the long-standing CHANGELOG note "FM stereo decoding (L-R separation) not yet implemented; only pilot detection works".
Added
- Per-source EAS decoder-tap streaming — new endpoint
GET /api/eas/decoder-stream/<source_name>ineas_monitoring_service.pyserves the 16 kHz audio fed to the SAME decoder for one specific source as MP3 (PR #1953). A companionGET /api/eas/decoder-stream/sourcesreturns the live list of streamable sources for the admin UI. The per-source streams are gated onEASDecoderMonitorSettings.enabledso they only run when the operator has explicitly turned them on; the existing mixed-source endpoint at/api/eas/decoder-streamis unchanged. The admin page (templates/admin/eas_decoder_monitor.html) gains a "Live Decoder Streams" card with one play/stop button per source, replacing the old static reference-only mount-point list. Closes the CHANGELOG TODO "Implement actual streaming endpoint for decoder tap". - WebSocket
alerts_updateevent —app_core/websocket_push.pynow emits a compact list of currently-active CAP alerts every 5 s (id, identifier, event, severity, urgency, headline, expires, eas_forwarded) (PR #1953). A SHA-1 signature of the active set is cached so unchanged ticks skip rebuilding the payload. The frontend client (static/js/core/websocket.js) registers the new event with a 5 s polling fallback against/api/alerts. This is the first concrete delivery against the standing TODO "Extend WebSocket push service to broadcast all data types (alerts, system health, etc.) to eliminate remaining polling".
[2.71.73] - 2026-04-28 - Add maintainer portrait to About page
Changed
templates/about.html— Replaced the generic ham-radio icon in the Maintainer section with an actual portrait photo (PR #1952). New.avatar.has-photoCSS variant fills the circle with the portrait image, with improvedobject-fitsizing and crop alignment for a cleaner presentation.
[2.71.72] - 2026-04-28 - Rewrite README as project overview and feature guide
Changed
README.md— Complete rewrite from ~800 lines of technical documentation to ~390 lines of focused feature coverage (PR #1951). Leads with value proposition and $5K–$7K commercial hardware cost comparison; replaces two sparse feature tables with nine detailed sections covering every major capability (multi-source CAP ingestion, SAME audio engine, PostGIS geographic intelligence, SDR verification, hardware integration, web dashboard, security/RBAC/MFA, notifications, scheduling); reformats Quick Start as a before/after table; removes the duplicate Architecture section; drops the rawapt installblock (handled byinstall.sh), placeholder screenshot SVGs, and the 60-line collapsible tech stack.
[2.71.71] - 2026-04-28 - rbds_diagnose: introspect production pipeline; detect pilot-locked RBDS vs. spurs
Changed
scripts/rbds_diagnose.py— Updated to introspect the liveapp_core/radio/demodulation.pyfor the current Costas α/β values, FIR normalisation marker, and Costas/M&M call order, so recommendations no longer fire for bugs already fixed in production (PR #1950). The three stale "fix this" recommendations (bandpass gain, Costas/M&M order, narrow BW) are now conditional on introspection actually finding the defect. Added pilot-locked RBDS detection: the RBDS subcarrier is identified by its offset frompilot×3rather than from nominal 57 kHz; spectral energy atpilot×3 ± 20 Hzdistinguishes a masked real RBDS signal from an off-frequency spur; peaks ≥ 50 Hz offpilot×3are flagged as interferers with actionable RF/antenna/preselector recommendations.
[2.71.70] - 2026-04-28 - RBDS: lock 57 kHz carrier reference to measured pilot frequency
Fixed
app_core/radio/demodulation.py—RBDSWorkerpreviously built its 57 kHz mixing reference from a hard-coded 19 000.0 Hz constant (PR #1949). RTL-SDR dongles running 25–100 ppm off-frequency left a 1.5–6 Hz residual that the Costas loop had to absorb on top of phase noise. New_estimate_pilot_frequencyuses a Hann-windowed RFFT and parabolic peak interpolation over 18.5–19.5 kHz (SNR ≥ 4× in-band median; ±210 ppm ceiling) to measure the actual pilot once per station._generate_pilot_referenceuses that value so the subcarrier mixes to exactly DC. A single log line per station reports the measured offset and derived ppm error._apply_resetclears the measurement on retune. New tests intests/test_rbds_demodulation.pycover the estimator, fallback path, and reset behaviour.
[2.71.69] - 2026-04-28 - RBDS DSP: fix Costas/M&M order, widen loop bandwidth, correct bandpass gain
Fixed
app_core/radio/demodulation.py— Three compounding RBDS bugs fixed (PR #1947, docstring cleanup in PR #1948):- Costas/M&M processing order — M&M timing recovery was running before the Costas carrier-phase loop, operating on a phase-rotating signal. Fixed to run Costas at 19 kHz first (standard PySDR/GNU Radio architecture), then M&M on the phase-corrected signal.
- Costas loop bandwidth — previous
alpha=0.026 / beta=0.00035gave ~3.5 Hz noise bandwidth at symbol rate, narrower than the 5.7 Hz shift from a 100 ppm RTL-SDR clock error. Updated to PySDR values (alpha=8.7e-3, beta=3.2e-5), yielding ~17 Hz bandwidth at 19 kHz with ≥ 100 ppm margin. - Bandpass filter gain —
_design_fir_bandpassnormalised bymax(|h|)in the time domain, which evaluates to zero for a narrow bandpass and produced +22.5 dB passband gain. Fixed to normalise by|H(f_centre)|in the frequency domain. - Stale docstrings —
_process_rbdsand_costas_pysdrdocstrings updated to reflect the corrected pipeline order and actual loop gain values in use. - Temporary debug capture removed — the
# TEMPORARY CAPTURE — remove after debuggingblock that wroterbds_capture_*.npyto/var/log/eas-stationon every startup has been removed.
Added
scripts/rbds_diagnose.py(new) — standalone offline diagnostic (PR #1947) that processes a captured.npyFM multiplex file and reports pilot frequency, SDR clock offset, filter gain at 57 kHz, Costas BW vs carrier offset, and side-by-side decode rates for old vs. fixed pipeline:python3 scripts/rbds_diagnose.py /var/log/eas-station/rbds_capture_256000.npy.
[2.71.68] - 2026-04-28 - Harden RBDS presync lock against false synchronisation
Fixed
app_core/radio/demodulation.py—RBDSWorkerpresync state machine made more robust against random syndrome collisions (PR #1944). Now requires three consecutive correctly-spaced presync hits (two spacing confirmations) before setting_rbds_synced, rather than declaring sync after a single confirmation. Mixed polarity during presync restarts from the newest candidate to avoid false locks caused by random syndrome collisions. New presync tracking fields (_rbds_presync_hits,_rbds_presync_polarity) are cleared by_apply_reseton retune. New teststest_rbds_presync_requires_three_spaced_hits_before_lockandtest_rbds_presync_two_hits_do_not_lockcover the state machine.
[2.71.67] - 2026-04-28 - Improve RBDS synced-mode polarity recovery and CRC failure tracking
Fixed
app_core/radio/demodulation.py— Fragile synced-mode handling caused immediateRBDS SYNC LOSTafterRBDS SYNCHRONIZEDwith zero groups decoded (PR #1943). Refactored block CRC validation into a_crc_ok_for_blockhelper that correctly handles C/C′ offset semantics and reduces duplicated logic. Added in-sync polarity recovery: when the current polarity fails CRC but the alternate succeeds,_rbds_inverted_polarityis immediately flipped and decoding continues without waiting for the 50-block sync-loss window._rbds_consecutive_crc_failuresis now properly tracked per-block, with alogger.infoemitted on polarity flip for diagnostics.
[2.71.66] - 2026-04-28 - Widen RBDS presync spacing tolerance from ±2 to ±4 bits
Fixed
app_core/radio/demodulation.py— Presync spacing tolerance widened from ±2 to ±4 bits (PR #1942). M&M timing recovery in a streaming pipeline carries forward ~1 bit of residual per batch; over 2–3 blocks this reaches ±3–4 bits, which the former ±2 threshold rejected unconditionally. Valid 26-bit block spacings {26, 52, 78, 104} maintain non-overlapping ±4 windows so no new ambiguity is introduced.
[2.71.65] - 2026-04-28 - Fix M&M symbol clock drift by buffering unconsumed samples across batches
Fixed
app_core/radio/demodulation.py—_mm_timing_pysdrwas silently dropping up tosps-1(~15) tail samples at the end of each batch (PR #1941). The M&M loop exits wheninterp_idx >= len(samples_interpolated) - 1, leaving unconsumed samples that, when discarded, slipped the symbol boundary by ~1 sample per 250 ms batch — enough to misalign block framing and cause continuous CRC failures. Fixed by adding_rbds_mm_leftoveras a persistent per-worker carry buffer: unconsumed samples are prepended to the next call's input rather than dropped.
[2.71.64] - 2026-04-27 - Tolerate ±2-bit jitter in RBDS presync spacing check
Fixed
app_core/radio/demodulation.py— Presync spacing check relaxed from an exact match to a ±2-bit window (PR #1940). Sub-bit drift in the streaming M&M clock produced frequent near-misses (e.g. expected 26, got 25) that repeatedly reset the presync anchor and delayed sync acquisition. Valid spacings {26, 52, 78, 104} cannot overlap at ±2 bits, so no false positives are introduced.
[2.71.63] - 2026-04-27 - RBDS: buffer pre-decimation to keep the bit clock continuous
Fixed
app_core/radio/demodulation.py— Per-chunkx[::decim]decimation andscipy.signal.resample_polywere stateless (PR #1939), so each of the ~31 sub-chunks in a 250 ms batch contributed its own decimation phase reset and polyphase filter transient. The stitched bit stream fed M&M with 31 separate transients per batch, producing random block spacing even on stations with a strong, fully-locked pilot and a -1 dBFS signal. Fixed by accumulating raw samples in a pre-decimation buffer and processing a full window's worth at once; the bandpass+mix+lowpass chain (which already preservedzistate) now feeds one contiguous block to decimation per batch, reducing transients from 31 per batch to one.
[2.71.62] - 2026-04-22 - Redesign About page with modern hero section and component styling
Changed
templates/about.html— Complete visual redesign (PR #1912). Added a full-width gradient hero section with animated background glow and rainbow accent bar; restructured the four compliance warnings into a responsive grid of colour-coded items; converted all sections to a unified.about-cardcomponent with consistent shadows, borders and spacing; replaced the list-based tech stack with a responsive.stack-itemgrid; added an alert-pipeline step visualisation and maintainer identity card.- ~400 lines of scoped CSS (inside
{% block extra_css %}) using CSS custom properties,color-mix(),clamp()fluid typography, backdrop filters, andheaderGlow/gradientShiftkeyframe animations. Mobile-first breakpoints preserve responsive layout on tablets and phones.
[2.71.61] - 2026-04-22 - Add RF RSSI meter for FM/SDR sources
Added
templates/audio_monitoring.html— Real-time RF signal-strength indicator (RSSI meter) for FM and SDR sources (PR #1911). Introduces.rssi-meter,.rssi-track,.rssi-fill,.rssi-labelsCSS classes (red-to-green gradient) and helper functionsgetRfSignalStrength(),hasRfSignalStrength(),rssiToDbfs(),formatRssiLabel(),calculateRssiFillWidth(),updateRssiMeter(). The meter renders only when RF signal data is present and updates via both the polling fallback and the WebSocket broadcast path.app_core/radio/demodulation.py—DemodulatorStatusgains asignal_strengthfield carrying the mean IQ magnitude (linear 0.0–1.0) computed on input samples before phase-continuity processing, so the value reflects actual RF signal strength.app_core/audio/sources.py— Extractssignal_strengthfrom the demodulator status, stores it in metadata asrf_signal_strength, and tracks an update timestamp.
Changed
- RBDS decode now uses an adaptive sliding window: a 3-second window during initial acquisition, collapsing to 1 second once the decoder is locked so PS / radiotext updates stream faster.
- UI converts the linear magnitude to dBFS via
20 * log10(value)and maps the −100 to −20 dBFS range to quality ratings (Excellent > −50, Good > −70, Fair > −85, Poor > −100) with a 0.2 s CSS transition.
[2.71.60] - 2026-04-22 - Replace deprecated datetime.utcnow() with timezone-aware alternatives
Fixed
app_core/websocket_push.py—_emit_analytics_update()now usesdatetime.now(timezone.utc)instead of the deprecateddatetime.utcnow()when computing the 24-hour activity window (PR #1910).scripts/screen_manager.py—_has_active_alerts()filters active alerts by expiration using a timezone-aware UTC timestamp.app_core/auth/audit.py—cleanup_old_logs()switched to the project-wideutc_now()helper instead ofdatetime.utcnow().
Changed
app_core/gps/gps_manager.pyandtools/download_nws_gis_data.pymovefrom __future__ import annotationsabove the module docstring to follow PEP 563 convention.- All datetime comparisons in the touched modules are now timezone-aware,
eliminating naive/aware mix-ups and preparing the codebase for
Python 3.12+ where
datetime.utcnow()is formally deprecated.
[2.71.59] - 2026-04-14 - Add description, instructions, VTEC, and sender sections to alert image export
Added
app_utils/image_export.py— Fills the right-hand info panel of the exported social image with additional data sections (PR #1909):- Enables the previously-defined-but-unused
_draw_vtac()VTEC block. - DESCRIPTION — word-wrapped alert description text.
- INSTRUCTIONS — yellow accent bar highlighting safety / action guidance.
- ISSUING OFFICE — sender name, response type, and category.
- Enables the previously-defined-but-unused
- All new sections respect the vertical panel boundary and stop rendering gracefully once the panel is full, so undersized alerts do not overflow.
[2.71.58] - 2026-04-14 - Add county boundaries, town labels, and OSM attribution to social image map
Added
app_utils/image_export.py— The OpenStreetMap background in the social-image export now overlays county boundaries and town labels derived from the bundled TIGER data, and carries the required "© OpenStreetMap contributors" attribution in the map footer (PR #1908).
[2.71.57] - 2026-04-14 - Restore compass rose to social image; keep VTAC strings removed
Changed
app_utils/image_export.py— Re-adds the compass-rose decoration to the map panel of the exported alert image after it was removed during the panel redesign, while leaving the raw VTAC strings (which duplicated decoded VTAC labels) out of the image (PR #1907).
[2.71.56] - 2026-04-14 - Enhance social image export: storm track, threat cards, alert text
Added
app_utils/image_export.py— Expands the exported 1200×630 social image with a storm-track arrow overlay on the map, a restructured set of severity-coloured threat cards (tornado / wind / hail), and wrapped alert-text sections in the right-hand panel (PR #1906).
[2.71.55] - 2026-04-14 - Add social media image export for alert details
Added
app_utils/image_export.py(new) — Image composition engine built on Pillow + requests that renders a Facebook-ready 1200×630 PNG for anyCAPAlert(PR #1905). The image contains:- OpenStreetMap tile background with the alert polygon overlaid (severity-coloured semi-transparent fill plus solid outline).
- Storm-threat card: tornado detection, wind gust, hail size / descriptor.
- County-coverage percentage with a progress bar and service-boundary counts.
- VTAC decoded labels and raw strings; storm-motion direction / speed.
- Affected-area description wrapped across rows; severity-coloured header with event name and branding; footer with issued / expires times.
webapp/admin/api.py— New/alerts/<id>/export-image.pngroute wired to the new engine.templates/alert_detail.html— "Export Social Image" button added to the Actions card.
Changed
- Map tiles are fetched live from OSM; a plain dark fallback is rendered when tiles are unavailable (offline / timeout) so all data cards still render correctly.
[2.71.54] - 2026-04-13 - Simplify ENDEC fingerprint to 3 × 0xBB
Changed
app_utils/eas_fsk.py,app_utils/eas_demod.py,app_utils/eas.py— Replaced the station-identifier trill introduced in 2.71.51 (three0xAAbytes) with three0xBBbytes (PR #1904).0xBB(10111011) is not a valid SAME terminator on any commercial ENDEC and therefore produces a consistent, unique, run-based fingerprint that cannot be confused with a malformed SAME framing byte, while the KR8MER decoder still reportsENDEC_MODE_EAS_STATIONwhen it sees the sequence.
[2.71.53] - 2026-04-13 - Rewrite commercial licence, add trademark policy, revise Terms of Use
Added
docs/policies/TRADEMARK_POLICY.md(new) — Separates trademark and branding rights from the software copyright / licensing rules (PR #1903).
Changed
LICENSE-COMMERCIAL— Replaced with a full Commercial Software Licence Agreement covering definitions, licence grant, restrictions, fees, audit, support, warranties, indemnities, termination, governing law (Ohio), and contact details.NOTICE— Simplified and clarified to explain dual licensing, AGPL obligations, and trademark / branding rules; updated the last-updated date.README.md— Clarifies AGPL availability, links toLICENSE-COMMERCIAL, references the new trademark policy, and adjusts attribution / branding guidance.docs/policies/TERMS_OF_USE.mdandtemplates/terms.html— Revised to align the site Terms of Use with the new commercial licence, trademark policy, governing-law clauses, and liability / indemnity language.
Tests
- Documentation site builds cleanly with
mkdocs build; repository pre-commit markdown / template checks pass.
[2.71.52] - 2026-04-13 - Auto-apply endec_fingerprint column on first request if migration is pending
Fixed
webapp/__init__.py(before-request hook) — When the 2.71.51 migration addingeas_settings.endec_fingerprinthad not yet been applied, every request that readEASSettingscrashed with anUndefinedColumnerror (PR #1902). The before-request hook now detects the missing column on the first request, issues an idempotentALTER TABLE ... ADD COLUMN IF NOT EXISTS endec_fingerprint BOOLEANwith the correct default, and continues serving — the same "schema-catch-up" pattern used for previous column-addition migrations.
[2.71.51] - 2026-04-13 - Add KR8MER EAS Station™ FSK trill fingerprint (3 × 0xAA)
Added
app_utils/eas_fsk.py— Newencode_terminator_bits()helper, re-exported from__all__(PR #1901). Emits the raw FSK symbols for an arbitrary terminator byte so callers can append a post-SAME fingerprint.app_utils/eas_demod.py— NewENDEC_MODE_EAS_STATIONconstant;detect_endec_mode()voting extended with a 0xAA branch; post-message terminator capture now accepts0xAA.app_utils/eas.py—_generate_station_terminator_samples()injects three0xAAbytes (10101010 binary — a distinctive ~46 ms trill alternating mark/space every bit) after every burst ingenerate_alert_audio(),build_eom_file(), andbuild_manual_components(). Third-party ENDECs gracefully exit post-message mode on the first0xAA(message already decoded), while the KR8MER decoder captures the full run and reportsENDEC_MODE_EAS_STATION.- EAS settings — New "Station Fingerprint" toggle in the broadcast configuration UI (enabled by default).
Tests
test_eas_decode.py— Unit test plus DLL integration test for theEAS_STATIONmode.
[2.71.50] - 2026-04-13 - Show county names alongside FIPS codes on alert detail page
Added
webapp/received.py+templates/audio_received_detail.html— The audio-received detail template now receives afips_nameslookup dict so each FIPS / SAME code badge is rendered alongside its human-readable county name (e.g.039003→Ottawa County, OH) (PR #1900).
Changed
- Badge layout updated to stack the numeric code and county name vertically with improved spacing and formatting.
[2.71.49] - 2026-04-11 - Expose min and max built-in functions to Jinja2 templates
Added
webapp/__init__.py— Registered Python's built-inminandmaxas Jinja2 global functions (PR #1899). Templates can now do inline{{ min(a, b) }}/{{ max(a, b) }}without a custom filter wrapper.
[2.71.48] - 2026-04-10 - Capture all 3 ZCZC bursts in stored recording by back-tracking ring position
Fixed
eas_monitor_v3.py— The streaming decoder fires the ZCZC callback at the end of burst 1 (after the last header character is decoded), so snapshotting the ring-buffer position at that instant produced a post-ZCZC audio slice that began after burst 1 was already over. The stored recording therefore contained only bursts 2 and 3 of ZCZC, and only burst 1 of NNNN (since EOM fires on its first burst, before bursts 2 and 3 arrive) (PR #1898).- Fix subtracts 1.5 s from the ring position when burst 1 fires, so the
full first burst (preamble ≈ 0.25 s + message ≈ 1.0–1.2 s) is captured.
The
_find_narration_start()detector already skips past SAME bursts and the attention tone, so the extra head audio does not affect relay output. The adjustment is centralised in a new_zczc_ring_position()helper used in both the first-burst branch and the new-event-code replacement branch.
[2.71.47] - 2026-04-10 - Correct ZCZC confidence calculation across all 3 header bursts
Fixed
app_utils/eas_demod.py— After a burst completed,syncedstayedTruethrough the ~1 s inter-burst silence, appending ~520 zero- confidence samples tobit_confidences. The list was not cleared on the next preamble lock, so the silence entries averaged into the next burst's score (PR #1897).bit_confidencesis now reset when the next preamble is detected, so each burst is measured from scratch.eas_monitor_v3.py— The pending alert was unconditionally overwritten by each successive burst, so the stored record always reflected the last (lowest, silence-contaminated) confidence. Fix keeps the maximum confidence seen across the three bursts.app_core/audio/ingest.py/eas_monitor_v3.py— Headers injected byinject_eas_test_signal()are digitally synthesised clean FSK, so their health should be 100 %. The synthetic header string is now registered at injection time and the monitor overrides confidence to1.0when the matching alert fires.
[2.71.46] - 2026-04-10 - Fix stack light not responding correctly to alert states
Fixed
hardware_service.py—_update_alert_indicators()was a 2-state machine (idle / active) and never drove the yellow "incoming alert" light (PR #1896). Expanded to a proper 3-state machine:idle (green) → incoming (yellow) → active broadcast (red) → idle.health_check_loop()now tracks bothbroadcast_was_activeandincoming_was_activeacross iterations.app_utils/eas.py(TowerLightController) —start_incoming_alert()ignored theincoming_uses_yellowconfiguration flag and always lit yellow even when the operator had disabled it. Added an early return when the flag isFalse.app_utils/eas.py—start_incoming_alert()was never called from anywhere. Addedset_incoming_alert(),clear_incoming_alert(), andget_incoming_alert_state()backed by a neweas:incoming_alertRedis key (with automatic 5-minute expiration).set_incoming_alert()is called when an alert arrives pre-broadcast;set_broadcast_active()now also clears the incoming key so the yellow → red transition happens cleanly.
Tests
- Added
test_tower_light_start_incoming_alert_disabled_sends_nothingcovering theincoming_uses_yellow=Falsepath.
[2.71.45] - 2026-04-09 - Low-confidence alert audit, relay tone controls, and EOM recognition
Added
- EAS settings — Configurable relay tone duration and relay tone profile settings surface in the broadcast configuration UI (PR #1895).
- Enhanced EOM (
NNNN) message detection and recognition logic in the streaming decoder.
Fixed
- Low-confidence alerts are no longer silently discarded — they are logged and recorded for audit purposes while still being blocked from on-air forwarding.
- Improved audio tone end-detection so narration timing lines up with the actual end of the attention tone block.
[2.71.44] - 2026-04-09 - Fix alert storage fallback to handle multiple missing DB columns
Fixed
eas_monitor_v3.py—_store_received_alert()— When thereceived_eas_alertstable was missing the newerraw_audio_dataand / oralert_sourcecolumns (pending migration), the fallback retry path continued to generate INSERT statements that referenced them (setting them toNonestill emitsNULLin the SQL), so alerts were silently dropped (PR #1894). The retry now builds a freshReceivedEASAlertusing only the legacy column set, so the generated SQL omits the missing columns entirely. Error detection widened to recognise bothraw_audio_dataandalert_sourcecolumn errors.- Warning / info log messages now describe the degraded state explicitly and point operators at the pending migration.
[2.71.43] - 2026-04-09 - Fix EAS audio injection follow-ups (storage, dedup, narration timing)
Fixed
- Alert-storage serialisation failures surfaced during test-signal injection runs (PR #1893).
- Handling of repeated emergency alerts with different event codes — the second event code now replaces, rather than being dropped against, the pending alert record.
- Audio-timing synchronisation so the captured narration starts at the end of the attention tone rather than overlapping with it.
- Adjusted test-signal injection behaviour so the injected chunks no
longer race live audio publishing into
_eas_broadcast(follow-up to 2.71.41).
[2.71.42] - 2026-04-08 - Distinguish ZCZC detections from EOM-confirmed alert dispatches
Added
eas_monitor_v3.py/eas_monitoring_service.py— Separated raw ZCZC header counts from confirmed-dispatch counts (PR #1892):_total_alerts_detected— ZCZC header count (may be 3× per event)._total_alerts_dispatched— one per EOM-confirmed event._last_alert_dispatch_time— Unix timestamp of the most recent confirmed dispatch.
get_status()now exposes:alerts_detected— EOM-confirmed dispatch count (primary metric).alerts_detected_zczc— raw ZCZC-burst count (diagnostic metric).last_alert_time— Unix timestamp of the last dispatch (orNone).
Changed
_on_eom_received()increments_total_alerts_dispatchedand updates_last_alert_dispatch_timeon EOM confirmation.- Service stop log line now clearly distinguishes "alerts dispatched" from "ZCZC bursts decoded".
[2.71.41] - 2026-04-08 - Fix EAS inject test signal not detected on stream sources
Fixed
app_core/audio/ingest.py—_capture_loop— An injected SAME test signal was never detected on internet-radio stream sources because the capture loop published a live audio chunk to_eas_broadcastin the same iteration it drained_inject_pending, interleaving music with the FSK preamble and breaking the decoder's DLL lock (PR #1891). The subscriber queue saw[music_chunk][eas_fsk_1][eas_fsk_2]…and never recovered the ~3932 coherent 0xAB preamble samples needed to lock at 16 kHz. SDR sources were unaffected (no background audio to interleave).- Fix gates the live-audio publish to
_eas_broadcastwhen_inject_pendingis non-empty. OTA EAS detection is unaffected:_inject_pendingis always empty during normal monitoring, so live audio (which is the OTA EAS signal) still flows to the decoder unimpeded.
Tests
test_audio_pipeline_integration.py::TestStreamInjectEASGating:test_interleaved_live_and_inject_fails_detection— reproduces the pre-fix failure mode (interleaved audio → zero detections) to keep the success test meaningful.test_gated_inject_detects_eas_signal— verifies the gated path detects the SAME header.
[2.71.40] - 2026-04-03 - Fix stack light and on-air popup airchain timing
Fixed
- USB tower light (
TowerLightController) and NeoPixel controller were initialized inhardware_service.pybutstart_alert()/end_alert()were never called, so the stack light stayed on green standby throughout every broadcast.health_check_loop()now monitors theeas:broadcast_activeRedis key each second and drives both hardware indicator controllers on broadcast-state transitions. - On-air broadcast overlay (global countdown timer popup) could disappear
while the encoder still held the airchain. In all three broadcast
paths (
webapp/eas/workflow.py,webapp/eas/messages.py,app_utils/eas.py) thefinallyblocks previously calledclear_broadcast_active()before releasing the GPIO relay. With gevent cooperative multitasking the WebSocket push loop could fire between those two calls, deliveringactive: falseand hiding the popup before the encoder released the airchain. The order has been corrected: GPIO is released first, then the broadcast-active Redis key is cleared.
[2.71.39] - 2026-04-01 - Fix SSL cert recognition, update.sh cert preservation, and migration prompt
Fixed
ssl_utils.get_ssl_certificate_info()incorrectly reported a Let's Encrypt certificate as "needs installation" even when nginx was already configured to use it via thesnippets/ssl-letsencrypt.confinclude that_install_certificate_internalwrites. The function now reads the nginx config and only setsneeds_installationwhen the snippet include is absent.update.shnginx config refresh silently reverted a Let's Encrypt certificate back to the self-signed certificate on every update. The script now detects both installation forms (direct PEM paths and snippet include) before overwriting the template, and re-applies whichever form was active after the copy.update.shshowed the "Do you want to continue with the update?" welcome dialog a second time when the script restarted itself after pulling a newerupdate.sh. The dialog is now skipped on self-restart and a brief status message is shown instead.update.shbackup whiptail dialog did not callredraw_screenon the "No" path, leaving the terminal in a mixed TUI/whiptail state.redraw_screenis now called unconditionally after the backup dialog closes.update.shmigration-error prompt used a plainreadcommand whose text was buried in the TUI status bar and was easy to miss. When whiptail is available the error is now shown as a dedicated--msgboxdialog so the user gets a clear, dismissible notification before the update continues.
[2.71.38] - 2026-04-01 - Sortable columns, light-theme readability, footer whitespace, summary 500 fix
Added
- Alert History table now has server-side sortable columns: clicking any column header (Event, Severity, Status, Source, Sent, Expires, Headline, Area) sorts the results ascending or descending; sort state is preserved through pagination and filter changes.
Fixed
GET /eas_messages/<id>/summaryreturned HTTP 500 becauseEASMessagehas no.identifierattribute; corrected to use.same_header.- Light theme: table column headers were nearly invisible because the
table-lightBootstrap class made the header background almost identical to the page background; replaced with a clearly tinted primary-colour mix that works in all themes. - Removed two orphaned
</div>closing tags at the end ofalerts.htmlthat caused subtle layout shifts. - Added
flex-shrink: 0to the footer so it is never compressed by the flex layout, eliminating the excess white space that appeared below the footer on long pages.
[2.71.37] - 2026-04-01 - Add EAS-RF and EAS-STREAM ingest path tracking for received alerts
Added
app_utils/alert_sources.py— Two new canonical source-identifier constants:ALERT_SOURCE_EAS_RF("EAS-RF") for alerts captured via a physical RF receiver (SDR, ALSA, PulseAudio) andALERT_SOURCE_EAS_STREAM("EAS-STREAM") for alerts received over an internet audio stream.app_core/models.py+ migration —received_eas_alertstable gains analert_sourcecolumn (VARCHAR, nullable) that stores the canonical path label at decode time.eas_monitor.py— Resolves the canonical source when an alert is decoded: readsAudioSourceConfigDB.source_typefor the active Redis source, maps it toEAS-RForEAS-STREAM, and stores the result in the new column.templates/audio_received.html+ detail page — Ingest Path badge (RF / Stream) displayed next to the source name; new Ingest Path filter dropdown added to the search/filter toolbar.webapp/received.py— Wires up thealert_sourcequery filter to support the new dropdown.
[2.71.36] - 2026-04-01 - Strip state suffix from county_name before coverage lookup
Fixed
webapp/admin/coverage.py— SAME look-ups store county names as"Putnam County, OH"(with state abbreviation). The previous normalisation only called.replace(' county', ''), leaving", OH"appended and producing_cname = "putnam, oh". The CensusNAMEfield stores"Putnam", so theLIKE '%putnam, oh%'filter returned no rows, causing the fallback.first()to return Allen County (GEOID 39003) instead of Putnam County (39137) — re-introducing the wrong-county bug that had been fixed in 2.71.19. Fixed by splitting on comma before lowercasing so any", OH"-style state suffix is discarded at the normalisation step. Applied to both_cname(Boundary table lookup path) and_cname_census(Censusus_county_boundariesfallback path).
[2.71.35] - 2026-04-01 - Fix NameError 'configured_fips' crashing eas-station-audio
Fixed
eas_monitoring_service.py— The variable rename fromconfigured_fipsto_live_fipswas applied to the first usage but missed the second argument on theUnifiedEASMonitorServiceconstructor call. This causedNameError: name 'configured_fips' is not definedon every service startup, producing a crash loop. Single-line fix aligning both references to_live_fips.
[2.71.34] - 2026-04-01 - FIPS code validation, relay audio, and live-reload location config
Added
- Relay audio — OTA-received alerts that are forwarded now attach the original captured audio to the relayed message instead of re-synthesising TTS, preserving the authentic EAS tone sequence.
- Live location config reload — The EAS monitor service re-reads
LocationSettingsfrom the database on each alert cycle; a service restart is no longer required after changing the station's configured FIPS codes or broadcast area. - Alert metadata enrichment — Forwarded alert objects now carry
event_typeandoriginatorfields derived from the parsed SAME header.
Fixed
- SAME header forwarding now preserves statewide wildcard codes (e.g.,
039000) that were previously stripped during FIPS filtering. - FIPS code lists are validated at intake to reject malformed or out-of-range values before they reach the encoder.
Tests
- New unit-test coverage for location-code filtering, wildcard preservation, and statewide code handling in the SAME header builder.
[2.71.33] - 2026-03-31 - Reject low-confidence SAME decodes to prevent false positives
Fixed
eas_monitoring_service.py— A confidence threshold of 0.25 is now applied to every SAME decode result. Decode candidates whose confidence score falls below the threshold are silently discarded, preventing music bursts, noise transients, and other audio artifacts from triggering spurious EAS alert callbacks.
Changed
eas_monitoring_service.py— Audio resampling for hardware-controlled sources (SDR, ALSA, PULSE) now uses fast integer decimation, reducing CPU overhead compared to the previous rational-fraction resampler.- Waveform and spectrogram visualisations in the diagnostics panel are disabled;
the spectrogram API endpoint returns an empty dataset with a
disabledflag.
[2.71.32] - 2026-03-31 - Give each SourceWatcher its own SAME decoder to prevent multi-source audio interleaving
Fixed
eas_monitoring_service.py—UnifiedEASMonitorServicepreviously shared a singleStreamingSAMEDecoderinstance across all configured audio sources. With two sources active simultaneously (e.g., LP1 and LP2), the monitor loop fed audio in round-robin fashion: 100 ms of LP1 → shared decoder → 100 ms of LP2 → shared decoder → 100 ms of LP1 → … Because SAME headers are approximately 1 second of coherent 520.83-baud FSK, the internalSAMEDemodulatorCoreDLL PLL lost carrier lock every 100 ms when the audio source switched, preventing any SAME message from ever being decoded despite normal audio flow at 2 × 16 kHz.Each
SourceWatchernow creates and owns its ownStreamingSAMEDecoder. Every decoder sees only a continuous, coherent audio stream from its single source, so the PLL can acquire and hold lock across the full ~1-second header.
Additional improvements:
- Ring buffer is updated before
process_samples()so audio is captured in the diagnostics buffer before the alert callback fires synchronously. get_status()now aggregatesdecoder_synced,in_message, andbytes_decodedacross all per-watcher decoders; each per-source status dict exposes these fields.- The
_current_source_contextmutable field is removed; source identity is carried in a per-source closure, eliminating a potential race condition.
[2.71.31] - 2026-03-31 - Refine TTS narration text selection and normalization
Fixed
app_utils/eas.py—_extract_text_from_payload(): removed"headline"from the candidate-key list. NOAA alert headlines are terse VTEC-style strings (e.g.,"SEVERE THUNDERSTORM WARNING") that provide no listener value and TTS reads poorly.descriptionandinstructionare now the only narration source candidates.app_utils/eas.py— Improved punctuation, whitespace, and special-character normalisation applied before TTS synthesis (slash-notation timezone removal, expanded military facility and weather acronyms).
[2.71.30] - 2026-03-31 - Fix UndefinedColumn crash on eas_settings.forwarded_event_codes
Fixed
app_core/eas_storage.py— Addedensure_eas_settings_columns()following the establishedensure_eas_audio_columnsguard pattern: queriesinformation_schema.columnsand issuesALTER TABLE eas_settings ADD COLUMN forwarded_event_codes JSONB NOT NULL DEFAULT '[]'::jsonbif the column is absent. Deployments that upgraded the codebase without running Alembic migrations previously crashed immediately withpsycopg2.errors.UndefinedColumnon any ORM query touchingeas_settings.app.py— Imports and callsensure_eas_settings_columns(logger)as step 5b in the DB init sequence (immediately afterensure_eas_audio_columns), guaranteeing the column exists before any ORM access.
[2.71.29] - 2026-03-31 - Filter FIPS codes in SAME header to broadcast area only; add auto-forward event filter
Added
app_utils/eas_encoding.py— When building the SAME header for a forwarded alert, FIPS location codes are now filtered so only codes within the station's configured broadcast area are included. Statewide wildcard codes (e.g.,039000) are preserved through the filter. If no codes survive filtering, the existing fallback to the station's configured FIPS codes applies, ensuring the header is always valid.- Admin dashboard — New Auto-Forward Event Filter section with grouped event-type categories and Select All / Clear All controls, giving operators fine-grained control over which EAS event codes are automatically relayed from CAP and OTA sources.
[2.71.28] - 2026-03-31 - Improve TTS text normalization for NWS watch descriptions
Fixed
app_utils/eas.py—_normalize_text_for_tts(): added Layer 2 NWS-specific normalizations that run before the acronym table:- Alternate-timezone slash notation (
/5 PM CDT/) is stripped to plain5 PM CDT; the timezone abbreviation is then expanded by Layer 3 (e.g.CDT→ "Central Daylight Time") so TTS does not read literal slash characters. ST.abbreviation is expanded to "Saint" (e.g. "ST. JOSEPH" → "Saint JOSEPH") so TTS does not say "Street Joseph".- Indiana county-name disambiguation:
INis replaced with "Indiana" when it is immediately preceded by a recognised Indiana county name (all 92 counties checked) AND not followed by a directional word, state name, or common English function word that would indicateINis a preposition. This correctly expandsALLEN IN BLACKFORD→ "ALLEN Indiana BLACKFORD" while leavingIN EFFECT,IN MICHIGAN, andGRANT IN NORTHERN INDIANAuntouched.
- Alternate-timezone slash notation (
app_utils/eas.py— Extended_ACRONYM_MAP(Layer 3) with:MI→ "Michigan" — NWS county-disambiguation state code; TTS mispronounces bareMIas "my" (e.g. "CASS MI" → "CASS Michigan").OH→ "Ohio" — NWS county-disambiguation state code; TTS reads bareOHas the interjection "oh" (e.g. "ALLEN OH" → "ALLEN Ohio").AFD→ "Air Force Depot" — facility abbreviation used in SAME area names (e.g. "GRISSOM AFD").
app_utils/eas.py— Aligned inline Layer comment numbering (0–3 → 1–4) with the docstring.
Added
docs/guides/TTS_NORMALIZATION.md— New reference guide documenting the full four-layer normalization pipeline, the complete built-in acronym table, and how to use the Pronunciation Preview and custom dictionary.tests/test_tts_text_normalization.py— 26 tests covering all normalization layers including Indiana county disambiguation edge cases.
Changed
templates/admin/tts_pronunciation.html— Info banner now explains all four normalization layers instead of only the pronunciation dictionary.templates/admin/tts.html— Pronunciation Preview panel now shows a concise summary of all four pipeline layers.templates/help.html— New "Text-to-Speech Normalization & Pronunciation" accordion item in Routine Operations explaining the full pipeline and how to access the tools.
[2.71.27] - 2026-03-30 - Reword Section 4b fragility callout; clarify regulatory status and intended audience
Changed
templates/terms.html— Replaced the "Jenga tower" fragility callout with three-paragraphalert-dangerbox that leads with the lack of FCC certification / regulatory approval, explicitly prohibits installation in any commercial broadcast air-chain, identifies licensed amateur radio operators (47 C.F.R. Part 97) as the intended audience, and then explains the EAS cascade relay mechanism as the reason those boundaries are critical.docs/policies/TERMS_OF_USE.md— Markdown source updated to match.
[2.71.26] - 2026-03-30 - Add EAS architectural fragility callout to Section 4b
Added
templates/terms.html— Newalert-dangercallout in Section 4b explaining that EAS was designed for relay reliability, not security: no authentication, no sender verification, no human gate; a single conforming SAME signal cascades unstoppably to every downstream participant; PEP activation can reach hundreds of broadcasters statewide in seconds. References Montana Case 3 as direct proof.docs/policies/TERMS_OF_USE.md— Mirrored callout added to markdown source.
[2.71.25] - 2026-03-30 - Remove inapplicable ORC §2921.13 from legal consequences
Changed
templates/terms.html— Removed ORC § 2921.13 (Falsification) from the Ohio-specific legal sub-list in Section 4a; statute does not directly apply to EAS misuse.docs/policies/TERMS_OF_USE.md— Updated markdown source to match.
[2.71.24] - 2026-03-30 - Add Ohio ORC §§2917.32, 2921.13, 2921.31 to legal consequences
Changed
templates/terms.html— Added three additional Ohio-specific statutes to the Section 4a sub-list: ORC § 2917.32 (Making False Alarms, 1st-degree misdemeanor / 4th-degree felony), ORC § 2921.13 (Falsification, 1st-degree misdemeanor / 4th-degree felony), and ORC § 2921.31 (Obstructing Official Business, 2nd-degree misdemeanor / 5th-degree felony).docs/policies/TERMS_OF_USE.md— Updated markdown source to match.
[2.71.23] - 2026-03-30 - Add Ohio ORC §2909.04 to legal consequences
Changed
templates/terms.html— Added ORC § 2909.04 (Disrupting Public Services, 4th-degree felony) to the Ohio-specific legal sub-list in Section 4a, alongside the previously added ORC §§ 2917.31 and 2913.04.docs/policies/TERMS_OF_USE.md— Updated markdown source to match.
[2.71.22] - 2026-03-30 - Add Ohio ORC §2917.31 and §2913.04 to legal consequences
Changed
templates/terms.html— Expanded Section 4a "State and local laws" bullet to add an Ohio-specific sub-list citing ORC § 2917.31 (Inducing Panic, 4th-degree felony) and ORC § 2913.04 (Unauthorized Use of Computer/Cable/Telecommunication Property, 5th-degree felony, elevatable to 3rd-degree if emergency communications are disrupted) as additional potential criminal consequences for misuse in Ohio.docs/policies/TERMS_OF_USE.md— Updated markdown source to match the above changes.
[2.71.21] - 2026-03-27 - Log client IP for every manual alert generation and send
Added
app_core/models.py—ManualEASActivationgains two new nullable columns:created_by_ip(VARCHAR 45, IPv4/IPv6) andtriggered_by_ip(VARCHAR 45). Both are exposed into_dict().webapp/eas/workflow.pymanual_eas_generate()— Captures the client IP at package-generation time usingX-Forwarded-For(first value) withrequest.remote_addras fallback. Stores it inManualEASActivation.created_by_ip, addsgenerated_by_ipto theSystemLogdetails entry, and includes the IP in theworkflow_logger.infoline.webapp/eas/workflow.pymanual_eas_send()— Same IP capture at broadcast time. Stores it inManualEASActivation.triggered_by_ip, addstriggered_by_ipto theSystemLogdetails entry, and includes the IP inworkflow_logger.info.app_core/migrations/versions/20260327_add_ip_to_manual_eas_activations.py— Alembic migration that adds the two new columns with guard checks (safe to run on existing deployments).down_revisionchains from20260327_widen_cap_alerts_geom_type.
[2.71.20] - 2026-03-27 - Strengthen terms of use with criminal liability language
Changed
templates/terms.html— Strengthened Section 3 (Disclaimer of Liability & Indemnification) to explicitly state that the developer and contributors bear absolutely no criminal liability for any criminal activity conducted using this software, and expanded the indemnification clause to cover attorneys' fees and criminal defense costs.templates/terms.html— Added new Section 4a (Criminal Liability & Federal Law Violations) with a danger-level alert banner and a detailed list of applicable federal statutes: 18 U.S.C. § 1038 (false emergency communications, up to 5 years per offense), 47 U.S.C. §§ 325, 333, 501, 503(b) (Communications Act violations, up to $100,000/day), and 47 U.S.C. § 325(a) (false distress signals). Explicitly notes that state/local felony charges from multiple jurisdictions may be pursued simultaneously, and that international law enforcement cooperation may extend liability across borders.docs/policies/TERMS_OF_USE.md— Updated markdown source to match all changes above.
[2.71.19] - 2026-03-27 - Fix wrong-county 99.4% coverage bug
Fixed
webapp/admin/coverage.py— Census TIGER fallback for county coverage now prefers the county whose name matches the configuredcounty_namebefore falling back to.first(). Previously, when a station'sfips_codeslist contained multiple counties (e.g. Allen + Putnam + Van Wert from an alert's SAME codes),.first()returned Allen County (GEOID39003, lowest value, loaded first from the Census shapefile). For the Severe Thunderstorm Warning covering "Allen, OH; Putnam, OH; Van Wert, OH", the real NWS polygon intersects Allen County at 99.4% but Putnam County at only 15.7% — selecting Allen County was the root cause of the persistent 99.4% / county-wide false positive.webapp/admin/coverage.py— Step 3 Boundary-table fallback (Boundary.query .filter_by(type='county').first()) is now skipped when acounty_nameis configured. Previously it fired whenever no exact-name match was found, which could silently swap in a neighbouring county's boundary (same bug vector as above).webapp/admin/api.py_detect_county_wide()—short_with_listheuristic no longer fires when thearea_desclists multiple counties from the same state (e.g. "Allen, OH; Putnam, OH; Van Wert, OH"). Detected by counting occurrences of, <state_code>in the description; more than one signals a multi-county polygon alert, not a single-county-wide alert.
[2.71.18] - 2026-03-27 - Fix SQLAlchemy crash in debug boundary endpoints
Fixed
webapp/routes_debug.py—.cast("geography")called directly on a SQLAlchemyFunctionobject crashed with'str' object has no attribute '_static_cache_key'for every boundary (all 197), causing the/debug/boundaries/<id>and/debug/alert/<id>endpoints to return an emptyintersection_resultsarray and a list of 197 errors. Fixed by replacing.cast("geography")with the correct GeoAlchemy2 patterncast(..., Geography()). Addedfrom geoalchemy2 import Geographyandfrom sqlalchemy import castimports.templates/alert_detail.html— The debug panel rendered the full errors array with no height constraint, so 197 errors expanded the page to ~19 000 px. Added amax-height: 200px; overflow-y: autowrapper around the error list as a defensive guard against future error floods.
[2.71.17] - 2026-03-27 - Show affected sq mi per boundary in debug panel; fix area unit consistency
Fixed
webapp/routes_debug.py— Both/debug/alert/<id>and/debug/boundaries/<id>computedST_Area(ST_Intersection(...))in square degrees (no::geographycast), producing meaningless scientific-notation values. Switched toST_Area(ST_Intersection(...)::geography)so the result is in square meters and the response now includesintersection_area_sqmandintersection_area_sqmifields.templates/alert_detail.html— Debug panel "Boundary Intersection Results" table was labelled "Area (sq°)" and showed raw exponential sq-degree values. Updated to display "~Area (sq mi)" using the newintersection_area_sqmifield (with sq-meter fallback conversion for backwards compatibility). Non-intersecting rows now show "—" instead of "0" for clarity.webapp/admin/intersections.py—fix_county_intersectionswas computingST_Area(ST_Intersection(...))in sq degrees via ORM calls. Replaced that logic with a delegation tocalculate_alert_intersections(), which already uses the::geographycast (sq meters) andST_MakeValid. This makes storedintersection_areavalues consistent with whatcalculate_coverage_percentagesand therecalculate_intersectionsendpoint produce.
[2.71.16] - 2026-03-27 - Fix "Fix Intersections" storing only partial results for expired alerts
Fixed
app_core/alerts.py—_fetch_bulk_intersectionsfiltered boundaries withAND ST_IsValid(geom), silently excluding any boundary whose geometry PostGIS considers invalid. With 197 boundaries this caused stored intersection counts to be far lower than the live count shown in the debug panel (e.g. 18 stored vs 48 live). Changed toST_MakeValid(geom)so invalid geometries are repaired in-place rather than dropped. Same fix applied to_fetch_intersections_per_boundaryfallback path.webapp/admin/intersections.py—fix_county_intersections(the backend for the Fix Intersections button on the Admin → Operations tab) only processed active alerts viaget_active_alerts_query(). When all alerts were expired the button reported "success" but updated 0 records. Changed to query all alerts that have geometry (CAPAlert.geom IS NOT NULL) so the fix runs regardless of alert status. Also appliedST_MakeValid()to all per-boundary intersection queries incalculate_single_alert,calculate_intersections_for_alert, andcalculate_all_intersectionsfor consistency.
[2.71.15] - 2026-03-27 - Fix ModuleNotFoundError crash that broke Alembic migrations and made site inaccessible
Fixed
webapp/admin/intersections.py— Wrong import pathfrom app_core.coverage import try_build_geometry_from_same_codesreferenced a module that does not exist. The function lives inwebapp/admin/coverage.py. Corrected to the relative importfrom .coverage import try_build_geometry_from_same_codes. This import error was raised at module load time, causing everyalembic upgrade headrun (and everyfrom app import app, dbfallback) to crash withModuleNotFoundError: No module named 'app_core.coverage', leaving the site returning 502 Bad Gateway.
[2.71.14] - 2026-03-27 - Restore debug info as hidden panel; add IPAWS Poller Debug to navbar
Fixed
templates/alert_detail.html— ThedebugBoundaries()JS function existed but had no button to call it, making boundary debug data completely inaccessible from the UI. Added a "Show Debug Info" button to the Actions card (sidebar). Clicking it reveals a collapsible panel that fetches/debug/boundaries/<id>and renders a readable table showing: geometry type, SRID, rawST_Areavalue (with approx sq-mile conversion for sanity-checking), stored vs live intersection counts, per-boundary intersect results, and any errors. A "Raw JSON" link opens the full JSON in a new tab. Panel is hidden by default and lazily loaded on first open.templates/components/navbar.html— The/debug/ipawsIPAWS Poller Debug page existed as a full template (ipaws_debug.html) but was never linked from anywhere in the navigation. Added "IPAWS Poller Debug" under Settings → Observability so the page is reachable without manually typing the URL.
[2.71.13] - 2026-03-27 - Fix county coverage percentage, wrong county selection, and square miles display
Fixed
webapp/admin/coverage.py—calculate_coverage_percentages: Three separate bugs caused the county coverage to show a wildly wrong "99.4%" figure for a multi-county Severe Thunderstorm Warning in Putnam County, Ohio (actual polygon coverage ~69%).Wrong county boundary selected — the code fell back to
county_intersections[0]when the configured county name was not found in the stored intersection list. For this alert the real NWS polygon intersected Allen County's boundary, so Allen County was silently used for the calculation (ST_Intersection(union, Allen) / Allen ≈ 99.4%). Fixed: the fallback now only accepts a county boundary whose name matches the configured county; it never silently substitutes a neighbour.SAME-code union used as alert geometry — when
alert.geomwas built from SAME broadcast codes (union of Allen + Putnam + Van Wert), intersecting that three-county blob against any of those counties always returns ~100%. Fixed:geom_from_same_codesis detected by checking whetherraw_json['geometry']contains real polygon coordinates. County coverage derived from a SAME union is now flaggedis_estimated=Trueand never triggers the "COUNTY-WIDE ALERT" banner.ST_Areareturned square degrees, not square metres — allST_Areacalls operated on EPSG:4326 geometry, returning square degrees. Putnam County (~484 sq mi) produced ~0.15 sq°; dividing by 2,589,988 gave ≈ 5.8 × 10⁻⁸ sq mi. Fixed: all area calculations now use::geographycast (orcast(geom, Geography())in SQLAlchemy), which returns accurate square metres.
Also re-computes boundary-type intersection areas live with
::geographyso the Electric/Village percentages are also accurate.webapp/admin/api.py—alert_detail:is_actually_county_widenow requires bothcounty_coverage >= 95 %andnot is_estimated, preventing a SAME-derived 100 % reading from suppressing boundary details or showing the county-wide banner.templates/alert_detail.html:- "COUNTY-WIDE ALERT" banner no longer fires for SAME-estimated coverage.
- "Exact Coverage" label changes to "Estimated Coverage" when
is_estimated=True, with an explanatory note. - Square miles are now displayed next to the coverage percentage in both the Technical Details section and the sidebar coverage card (e.g. "69.2% — approx. 335 sq mi").
- Coverage badge in the Alert Information header no longer shows the county-wide style when coverage is estimated.
[2.71.12] - 2026-03-27 - Fix county coverage and auto-serve county boundary from bundled TIGER data
Fixed
webapp/admin/coverage.py—calculate_coverage_percentages: Added fallback that uses theus_county_boundaries(Census TIGER) table to compute county coverage percentage when noBoundaryrecord withtype='county'exists in the database. Most installations only upload Electric, Fire, and Village boundaries, socoverage_data['county']was never set and the sidebar stayed on "Coverage Pending" even after clicking Calculate Coverage Percentage. The fix looks up the configured station county viaLocationSettings.fips_codes(SAME code → 5-digit Census GEOID), then executes a raw SQLST_Intersection / ST_Areajoin againstcap_alertsandus_county_boundariesto produce the exact square-mileage coverage percentage.
Changed
webapp/admin/api.py—get_boundaries: When/api/boundaries?type=countyreturns no results (no county-type boundary GeoJSON has been manually uploaded), the endpoint now automatically falls back to the bundled Census TIGERus_county_boundariestable and serves the configured station county as a standard GeoJSON FeatureCollection. This means the alert coverage map draws the correct county outline on every deployment without requiring any manual file upload — the samecb_2024_us_county_500kshapefile that is already bundled indata/shapefiles/and auto-loaded at startup provides the geometry.
[2.71.11] - 2026-03-27 - Add alert geometry and coverage Mermaid documentation
Documentation
docs/architecture/ALERT_GEOMETRY_COVERAGE.md(new) — Comprehensive Mermaid documentation for the alert geometry and coverage calculation subsystem. Contains five diagrams:- Geometry Resolution Priority Chain — Flowchart showing Priority 1 (raw
polygon), Priority 2 (stored geometry), Priority 3 (SAME/FIPS county union),
and why Priority 3 is intentionally blocked when a polygon is present in
raw_jsonbut failed to parse. - Alert Type Routing — Table and flowchart mapping common NWS product types (Tornado Warning, High Wind Warning, Tornado Watch, Winter Weather Advisory, etc.) to the geometry source normally used.
- Poll-Cycle Geometry Preservation — Flowchart showing the fix for SAME-derived geometry being erased on each polygon-less feed update.
- Calculate Coverage Button Flow — Full browser → API → PostGIS → toast → reload flowchart for the "Calculate Affected Boundaries" button.
- End-to-End Coverage Calculation Sequence — Sequence diagram tracing a county-wide alert (High Wind Warning) with FIPS codes only through geometry build, intersection calculation, and final coverage display.
- Geometry Resolution Priority Chain — Flowchart showing Priority 1 (raw
polygon), Priority 2 (stored geometry), Priority 3 (SAME/FIPS county union),
and why Priority 3 is intentionally blocked when a polygon is present in
docs/reference/DIAGRAMS.md— Added index entry for new file; updated diagram count (79 → 84) and last-updated date.docs/architecture/DATA_FLOW_SEQUENCES.md— Added cross-reference link to the new geometry coverage document in the Related Documentation section.
[2.71.10] - 2026-03-27 - Fix geometry preservation and SAME-code fallback gating
Fixed
poller/cap_poller.py—_update_existing_alert: No longer clearsalert.geomwhen a feed update carries no polygon (geometry_data is None). Previously, every poll cycle for a county-wide alert (watch, advisory, or any alert without a specific polygon) would silently erase SAME-derived geometry that the admin had just calculated — causing "Coverage Pending" to reappear on the very next page load after clicking "Calculate Coverage Percentage". Existing geometry (whether polygon-derived or SAME-derived) is now preserved across polygon-less updates; geometry is only replaced when the feed provides new data.webapp/admin/coverage.py—try_build_geometry_from_same_codes: Added a guard at Priority 3 (SAME codes) that stops substitution of a full-county union whenraw_json['geometry']is present but failed to parse. Previously, a localized alert (e.g. severe thunderstorm warning with a narrow polygon that couldn't be stored) would fall through to SAME codes and produce inflated county-level coverage. Now the function returns False in that case so the UI correctly shows an error rather than incorrect data.
[2.71.9] - 2026-03-27 - Fix coverage calculation feedback and calculation bugs
Fixed
templates/alert_detail.html— Replaced misleading "COVERAGE CALCULATING" / "Coverage Calculating..." / "Coverage Calculating" labels (which appeared even before any calculation was triggered) with accurate "COVERAGE PENDING" / "Coverage Pending" wording that correctly indicates the user needs to click the button.templates/alert_detail.html—triggerIntersectionFix(): Added immediate loading feedback (spinner on all coverage buttons, disabled state, instant "Calculating coverage boundaries…" toast) so the user knows the calculation is running. Success toast now reports the number of intersections found; failure re-enables buttons so the user can retry.webapp/admin/intersections.py—calculate_single_alert: Always callstry_build_geometry_from_same_codesregardless of whether geometry is already stored, so the more-accurate raw_json polygon (added by PR 1833) is applied even for alerts that previously had SAME-derived geometry. Also skips boundaries with NULL geometry to avoid PostGIS errors.webapp/admin/coverage.py—calculate_coverage_percentages: County coverage query now usesST_Intersectsas a filter guard before computingST_Intersection, returns 0 % gracefully when geometries don't overlap, and is wrapped in a try/except so a single bad geometry cannot abort the entire coverage calculation. Boundary area sum query now excludes boundaries with NULL geometry.
[2.71.8] - 2026-03-26 - Update Ohio EAS docs for WAKS-FM LP-1A designation
Documentation
docs/reference/OHIO_EAS_DOCUMENTATION.md— Updated to reflect the January 12, 2026 SECC memorandum from Chairman Greg Savoldi:- Added WAKS-FM 96.5 FM (Brecksville) as LP-1A alternative for the Central & East Lakeshore EAS Operational Area. Operators may monitor either WTAM 1100 AM or WAKS-FM 96.5 FM to satisfy the LP-1 requirement; no FCC or SECC filing is required.
- Corrected LP-2 call sign from
WCPN 90.3 FMtoWCLV 90.3 FMper the official memo. - Updated Northern Ohio mermaid diagram to include the LP-1A node (teal styling distinct from LP-1/LP-2) with connections to LP-2 and LP-3.
- Added an Amendments sub-section under Updates & Revisions with full memo summary.
- Updated Document Information block with
Last Amended: 2026-01-12. - Added amendment note to the top-level document version header.
[2.71.7] - 2026-03-26 - Correct stale tech stack badge versions
Fixed
templates/base.html— Python badge updated from3.11to3.13to reflect the actual runtime in use.templates/partials/footer.html— Python badge updated from3.11.14to3.13; Redis badge corrected from7.0 Alpineto7.1(matchingrequirements.txtredis==7.1.0and the README.md badge which already carried the correct7.1value).
[2.71.6] - 2026-03-26 - Pretty up headers, footers and tech stack badges
Changed
templates/base.html— Updated copyright year 2025 → 2026. Wrapped tech-stack badge row in a newtech-stack-cardglass panel for a polished look.templates/partials/footer.html— Updated both copyright year references 2025 → 2026 (unused partial kept in sync).static/css/styles.css— Multiple visual improvements:- Added the previously missing
page-header-gradientCSS class (referenced incomponents/page_header.htmlbut undefined); styled as a vibrant multi-color gradient header variant with animated bottom accent line. - Added animated rainbow bottom accent line (
::after) to.navbar. - Added
page-header::aftersubtle bottom highlight line. - Enlarged
.footer-logo-markicon box (60 → 64 px) with a blue glow shadow. - Made
.footer-divideran animated rainbow gradient stripe instead of a plain semi-transparent white line. - Updated
.footer-column-title::afterunderline to teal-to-blue gradient. - Added
.tech-stack-cardglass-morphism container for the badge row. - Increased default badge height from 24 px to 26 px; improved hover animation (spring easing, stronger lift) and box-shadow.
- Improved
.footer-disclaimerborder-radius and subtle inset shadow. - Made
.tech-stack-titleicon emit a teal drop-shadow glow.
- Added the previously missing
static/css/admin.css— Enhancedadmin-page-headerwith animated rainbow top accent line (::after) and radial glow overlay (::before).
[2.71.5] - 2026-03-26 - Document valid base.html block names in AGENTS.md
Documentation
docs/development/AGENTS.md— Added a reference table of the six valid{% block %}names defined inbase.html(title,nav_title,meta,extra_css,content,scripts) to the Template Standards section, with an explicit ❌/✅ example showing that{% block extra_js %}does not exist and{% block scripts %}is the correct name for page-level JavaScript. Extended the pre-commit template-validation script to also flag unknown block names in child templates, producing a targeted error message. Removed a stray duplicate of the validation script that had accumulated below the Pre-Commit Checklist.
[2.71.4] - 2026-03-26 - Fix TTS Pronunciation Dictionary entries not saving
Fixed
templates/admin/tts_pronunciation.html— The JavaScript block was declared as{% block extra_js %}which does not exist inbase.html(the correct block is{% block scripts %}). This caused the entire JS section to be silently dropped, so no event handlers were ever attached to the Add/Edit/Delete/Toggle controls. Forms submitted as plain HTML POSTs to a GET-only route and nothing was saved. Renamed the block to{% block scripts %}to matchbase.html.
[2.71.3] - 2026-03-26 - Eliminate redundant file reads in multi-rate SAME decoder
Fixed
app_utils/eas_decode.py(_try_multiple_sample_rates) — Audio file was read and (when scipy is unavailable) anffmpegsubprocess was spawned for each of the 7 rate candidates, adding hundreds of milliseconds per attempt. Fixed by:- Reading the file once at native rate and caching the samples.
- Resampling the cached samples in-memory (scipy
resample_poly) for every alternative rate candidate — no additional disk I/O or subprocess overhead. - Falling back to per-rate file reads only when the in-memory resample fails.
- Broadening the early-exit condition: previously only triggered when the native rate decoded with confidence ≥ 0.9; now any rate that achieves ≥ 0.9 confidence triggers early exit, avoiding further unnecessary decode attempts.
app_utils/eas_decode.py(_decode_from_samples) — Extracted the decode body of_decode_at_sample_rateinto a new_decode_from_samples(samples, pcm_bytes, rate)helper so both the per-rate file-read path and the cached-sample path share identical decode logic.
[2.71.2] - 2026-03-26 - Fix IPAWS XML digital signature C14N verification
Fixed
app_utils/ipaws_enrichment.py(_canonicalize_signed_info) —with_comments=Falsewas passed tolxml.etree.tostring(method='c14n')but is not a valid parameter for that call in lxml 6.x; this silently raised aTypeErrorcaught by the bareexcept Exception:block, causing the function to always returnNoneand every IPAWS alert to show "Signature Unverified". Fixed by switching toElementTree.write_c14n()as the primary C14N approach (which does acceptwith_comments), withetree.tostring(method='c14n')as a version-safe fallback. Addedlogger.warninginstead oflogger.debugso failures are visible in logs.poller/cap_poller.py(_convert_cap_alert) — Alert XML is now serialized using lxml directly (new_serialize_alert_for_sig()helper) rather than through the genericETmodule abstraction. When stdlib ElementTree is used, it rewrites namespace prefixes tons0:,ns1:etc., causing C14N to produce bytes that differ from the original signed bytes. lxml preserves the original prefixes (e.g.ds:,capsig:), ensuring C14N output matches what FEMA signed.
[2.71.1] - 2026-03-26 - Fix statewide FIPS map and IPAWS audio forwarding
Fixed
templates/alert_detail.html(loadCountiesFromSameCodes) — SAME codes ending in000(e.g.039000= entire Ohio) now render all counties for that state instead of falling back to the generic circle. The county-portion000flag is detected and all GeoJSON features whose state FIPS prefix matches are included in the map layer.app_utils/eas.py(_convert_audio_to_samples) — Added directffmpegsubprocess fallback for MP3 decoding when pydub fails (ImportError or decode error). Pipes the raw MP3 bytes intoffmpeg -i pipe:0 -ar <rate> -ac 1 -f s16le pipe:1so IPAWS embedded audio is decoded to PCM samples and forwarded through the airchain even if pydub's Python bindings are unavailable or ffmpeg is not on pydub's search path.
[2.71.0] - 2026-03-26 - TTS pronunciation dictionary + Alembic migration guardrails
Added
app_core/models.py— NewTTSPronunciationRulemodel andTTS_BUILTIN_PRONUNCIATIONSseed list. Stores user-configurable word-to-phonetic-spelling rules applied to all TTS narration text before synthesis (e.g. Lima → "Lye-mah", Cairo → "Kay-roh").app_utils/eas.py—_normalize_text_for_tts()function: two-layer substitution applied to every TTS message — (1) hard-coded acronym expansions (EAS, NWS, FEMA, RWT, RMT, EOM, IPAWS, EBS) so TTS engines spell them out correctly; (2) database-driven pronunciation rules loaded fromtts_pronunciation_rules._compose_message_text()now runs this normalization before returning text.app_utils/eas.py—_load_pronunciation_rules()helper loads enabled rules ordered longest-first so multi-word patterns are matched before shorter prefixes.webapp/admin/tts_pronunciation.py— Full CRUD admin routes under/admin/tts/pronunciationand/admin/api/tts/pronunciation. Built-in rules can be disabled/edited but not deleted.app_core/migrations/versions/20260326_add_tts_pronunciation_rules.py— Alembic migration createstts_pronunciation_rulestable and seeds ten built-in Ohio place-name corrections.docs/development/AGENTS.md— New "Alembic Migration Rules" section under Database Guidelines; updated Pre-Commit Checklist with head-check script; updated "Create Database Migration" step with critical revision-ID warning.
Fixed
app_core/migrations/versions/20260326_add_tts_pronunciation_rules.py—down_revisionnow uses the actual revision ID (20260325_received_alert_audio) instead of the filename prefix, keeping the migration chain at exactly one head.
[2.70.3] - 2026-03-25 - Fix EAS audio inaudible on Icecast and OTA trigger recording
Fixed
app_utils/eas.py(EASBroadcaster.handle_alert) —inject_eas_audio()was called after_play_audio_or_bytes()returned. Because_play_audio_or_bytesblocks for the entire playback duration (30–60 s), Icecast listeners heard live source audio throughout the alert and only received the EAS audio once the alert was already over. Fixed by moving the injection call to before_play_audio_or_bytes(). The injection is non-blocking (it merely enqueues chunks into the BroadcastQueue); the IcecastStreamer then drains those chunks to FFmpeg in real time while local playback proceeds concurrently.app_core/audio/eas_stream_injector.py(inject_eas_audio) — Before publishing EAS chunks, the injector now incrementsadapter._eas_inject_seq(a new monotonic counter onAudioSourceAdapter) and flushes stale live-audio chunks from every subscriber queue of_source_broadcast. This removes pre-buffered live audio that would otherwise delay EAS audio delivery.app_core/audio/ingest.py(AudioSourceAdapter) — Added_eas_inject_seqinteger counter to the adapter. Incremented by the injector before each injection so consumers can detect a new injection event reliably without racing against the short-lived_eas_injection_activegate.app_core/audio/icecast_output.py(IcecastStreamer._feed_loop) — Each streamer now tracks_last_eas_inject_seq. When it sees a new sequence number it clears the local 150-chunk pre-buffer (≈7.5 s of live audio) that would otherwise delay EAS audio reaching FFmpeg. The buffer is replenished immediately with EAS chunks that arrive from the subscription queue, so the stream remains continuous.app_core/audio/eas_monitor.py(_store_received_alert) — Ifdb.session.commit()fails because theraw_audio_datacolumn does not exist (migration not yet applied), the code now retries the commit withraw_audio_data=Noneso the alert record is not lost entirely. A warning is logged directing the operator to runalembic upgrade head.eas_monitoring_service.py(_ensure_raw_audio_column) — At startup the service now checks whetherraw_audio_dataexists onreceived_eas_alertsand adds it (viaALTER TABLE … ADD COLUMN IF NOT EXISTS) if absent. This closes the race between new deployments and operators who have not yet runalembic upgrade headafter upgrading to 2.70.1+.
[2.70.2] - 2026-03-25 - Fix test audio injection not reaching Icecast streams
Fixed
app_core/audio/ingest.py(AudioIngestController.inject_eas_test_signal) — Test audio was queued viaschedule_inject()which feeds the capture loop. The capture loop drained the queue to_source_broadcastwithout gating live source audio, so the FSK tones were mixed with (and buried under) the live programme audio on every Icecast mount.inject_eas_audio()was documented as being called for the Icecast path but was never actually invoked. Fixed by encoding the generated test audio as a WAV file in-memory and callinginject_eas_audio()— mirroring the path used byEASBroadcaster.handle_alert()for real alerts — which gates live audio and publishes a clean, uninterrupted test signal to the Icecast broadcast queue. The existingschedule_inject()path is retained so the EAS decoder continues to receive the signal via the capture-loop health-check path.
[2.70.1] - 2026-03-25 - Fix EAS audio interleaving and OTA audio JSONB serialization crash
Fixed
app_core/audio/ingest.py(AudioSourceAdapter) — Added_eas_injection_activethreading.Event gate. When set by the injector, the capture loop skips publishing live source audio to_source_broadcast, preventing it from interleaving with EAS alert chunks. The result is a clean, uninterrupted EAS alert sequence in the Icecast stream rather than garbled audio mixing the EAS signal with live program content.app_core/audio/eas_stream_injector.py(inject_eas_audio) — Sets_eas_injection_activeon each source adapter before publishing EAS chunks and clears it in afinallyblock afterward, ensuring the gate is always released even on error.app_core/audio/eas_monitor.py(_store_received_alert) —full_alert_data=alertwas passed directly to the JSONB column while the alert dict containedraw_audio_wav(Pythonbytes).bytesis not JSON-serializable, causing everydb.session.commit()to raiseTypeErrorand roll back — meaning noReceivedEASAlertrecord was ever written and OTA audio was never persisted. The dict is now copied withraw_audio_wavexcluded before being stored infull_alert_data; the binary itself is stored in the dedicatedraw_audio_data(LargeBinary) column as intended.
[2.70.0] - 2026-03-25 - Fix EAS stream injection, OTA audio storage, and test pipeline
Fixed
eas_monitoring_service.py—eas_stream_injector.set_controller()was never called in the audio-service process, soinject_eas_audio()always found_controller = Noneand silently no-opped. Generated EAS broadcast audio now correctly reaches the Icecast broadcast queues and is heard by listeners on the mount point (e.g.wnci.mp3).app_core/audio/ingest.py(inject_eas_test_signal) — The test signal was injected directly into_eas_broadcast, bypassing the capture loop entirely. The decoder always fired regardless of whether the real pipeline was alive, making the test meaningless as a system health check. The signal is now scheduled via the newschedule_inject()inlet and processed by the live capture loop — if the capture loop is dead, the test correctly fails.webapp/documentation.py—/docs/DIAGRAMS(and/docs/CHANGELOG,/docs/ABOUT) returned 404 because the files live underdocs/reference/. Redirects now route bare top-level names to their correct subdirectory paths.
Added
app_core/audio/ingest.py(AudioSourceAdapter.schedule_inject) — New public method that enqueues float32 audio at the source's native sample rate for processing by the capture loop. Injected chunks travel through_source_broadcast(Icecast) and_resample_for_eas()→_eas_broadcast(SAME decoder), identical to real source audio.app_core/models.py(ReceivedEASAlert.raw_audio_data) — NewLargeBinarycolumn that stores the raw WAV audio (16 kHz mono) captured from the monitoring stream at the moment an OTA EAS alert is detected.app_core/audio/eas_monitor_v3.py(UnifiedEASMonitorService) — Per-source audio ring buffer (90 s at 16 kHz) that is snapshotted and encoded as WAV when_handle_alertfires, then attached to the alert dict for database storage.webapp/admin/audio/received.py— New/audio/received/<id>/audioroute that streams the stored WAV to the browser.templates/audio_received_detail.html— Audio player card showing the raw received OTA audio with a download button.app_core/migrations/versions/20260325_add_raw_audio_to_received_alerts.py— Migration addingraw_audio_datacolumn toreceived_eas_alerts.
[2.69.6] - 2026-03-24 - Fix sources failing silently and tight FFmpeg crash loop
Fixed
app_core/audio/redis_commands.py(_execute_command/source_start) — The return value ofaudio_controller.start_source()was silently discarded. The handler now checks the boolean result and returns{'success': False, 'message': '…'}(including the adapter'serror_message) when the source fails to start. Previously every start attempt reportedsuccess: Trueto the UI even if the source ended up in ERROR state.app_core/audio/sources.py(StreamSourceAdapter._restart_ffmpeg_process) — When_launch_ffmpeg_process()raised an exception (e.g. FFmpeg not in PATH, URL unresolvable),_last_restartwas never updated. Because the initial value of_last_restartis 0, the backoff guard (now - _last_restart < restart_delay) was always bypassed and every subsequent call to_read_audio_chunk()immediately retried — producing a tight CPU-burning crash loop and flooding the log._last_restartis now stamped on failure so the 2-second backoff applies between retries.eas_service.py(publish_eas_metrics_to_redis) — Wheneas_monitoring_service.py(audio-service) was down, its stale V3eas_monitordata (withmode: "unified-streaming"andmonitor_count > 0) remained in Redis.eas_service.pywas deferring to this stale data without checking whether the audio-service heartbeat (_heartbeat) was still fresh. The result was that the webapp showed "Running (No Audio)" instead of "No Sources Running" becauseno_sources_runningevaluated to False from the stalemonitor_count. The defer check now also verifies that_heartbeatis less than 30 seconds old before yielding.
[2.69.5] - 2026-03-24 - Fix HTTP stream delete and audio-service crash on bad receiver config
Fixed
webapp/admin/audio_ingest.py(api_delete_audio_source) — Replace the remaining_get_audio_controller()call (which created a webapp-side controller and unnecessary background threads) with direct DB-only logic. The endpoint now sends a fire-and-forgetsource_deletecommand (previouslysource_stop) so the audio-service also removes the source from its in-memory controller and stops any associated Icecast stream.webapp/admin/audio_ingest.py(api_delete_audio_source) — Deleting a radio-managed (SDR) audio source now also setsRadioReceiver.audio_output = Falseon the corresponding receiver row so thatsync_radio_receiver_audio_sources()does not silently recreate the source the next time the audio service starts.app_core/audio/redis_commands.py(delete_source) — Addedwait_for_responseparameter (defaultTrue) so callers can send a fire-and-forget delete command without blocking on an audio-service response.eas_monitoring_service.py(initialize_audio_controller) — Wrappedsync_radio_receiver_audio_sources()in a try/except so that a database error or bad receiver config during startup degrades gracefully (logs the error and continues) instead of propagating an unhandled exception that crashed the audio service withexit-code.eas_monitoring_service.py(main) — Wrapped theinitialize_eas_monitor()call in a try/except with a clear error log so that unexpected failures are surfaced in the journal rather than silently collapsing into a genericreturn 1.
[2.69.4] - 2026-03-24 - Fix delete blocked by dead audio-service; fix "Stopped" badge on failed sources; fix update-script restart
Fixed
webapp/admin/audio_ingest.py(api_delete_audio_source) — Delete no longer calls_get_controller_and_adapter, which tried to restore/start the source (hitting the 5 s Redis timeout × 3 retries while audio-service is dead). The endpoint now queries the database directly, sends a fire-and-forget stop command to the audio-service (never blocking on a response), and then deletes from the database regardless of audio-service state. Sources can now be deleted even wheneas-station-audio.serviceis down or unreachable.webapp/admin/audio_ingest.py(api_get_audio_sources) — Sources that haveauto_start=Truenow report statuserror(red badge) instead of the misleading greystoppedbadge when the audio-service is dead (Redis metrics absent). The error message is updated to "Audio service is not running – source failed to start".update.sh— Addedsystemctl reset-failedfor all EAS Station™ service units before thesystemctl restart eas-station.targetcall. A service that exceeded systemd's start-limit burst enters thefailedstate and will not be restarted by a target restart until it is reset; this caused the audio service to silently stay dead after updates.systemd/eas-station-audio.service— AddedStartLimitBurst=0to disable systemd's default start-limit burst (5 failures / 10 s). The existingRestartSec=10salready prevents tight restart loops; without the burst limit the service would enter a permanentfailedstate after five rapid crashes and stop retrying until manually reset.
[2.69.3] - 2026-03-24 - Rock-solid audio service, live VU meters, reduced CPU burn
Fixed
eas_monitoring_service.py(publish_metrics_to_redis) — Replaced the fragileDELETE+HSETpipeline with a simpleHSETmerge so the Redis key is never momentarily absent between the two steps. Added deep sanitisation of every metric value before JSON serialisation so thatinf/nan/ numpy scalars can no longer cause a silent exception that leaves the key absent and makes the web-app falsely report "audio service not running". Extended the key TTL from 60 s to 120 s to give headroom for transient Redis hiccups.eas_monitoring_service.py(main loop) — Reduced metrics publish interval from 5 s → 1 s so VU meters in the web UI reflect live audio levels instead of 5-second-old snapshots.eas_monitoring_service.py(source watchdog) — Watchdog now also restarts STOPPED sources that haveauto_start=True, not just ERROR sources. Previously a source that dropped from RUNNING to STOPPED (e.g. after a network hiccup that didn't set ERROR) would stay offline until manually restarted.app_core/audio/worker_coordinator_redis.py(read_shared_metrics) — Replaced the hard 60-second stale threshold (which caused false "service not running" reports on any transient Redis blip) with a two-tier policy: warn at 60 s but continue returning data up to 5 minutes, only returningNone(hard failure) after 5 minutes of silence.app_core/audio/auto_streaming.py(_get_eas_monitor_settings) — Reverted the PR #2.69.2 change that defaulted EAS ingest streams to enabled. Each ingest stream consumes an Icecast source slot; with 3 audio sources the default-on behaviour silently saturated the server's source limit and broke normal streaming. The feature now defaults to disabled as it was before and must be explicitly enabled via the admin UI.app_core/audio/auto_streaming.py(health-check step) — Dead streamers (those that stopped unexpectedly) are now removed immediately so they are recreated on the next monitor-loop cycle, giving automatic Icecast reconnection without operator intervention.app_core/websocket_push.py— Reduced the WebSocket push loop from 10 Hz (100 ms) to 4 Hz (250 ms) and added a proper rate-limit timer for the audio-monitoring emit. The previous code called_emit_audio_monitoring_updateunconditionally on every iteration, resulting in 10 Redis reads per second and 10 identical WebSocket broadcasts per second per connected client — burning significant CPU while delivering the same stale value 50 times per 5-second window. At 4 Hz with 1-second metric freshness the meters are still visually smooth and CPU usage drops dramatically.
[2.69.2] - 2026-03-24 - Fix EAS signal injection, ingest mounting, and OTA/stream decoding
Fixed
app_core/audio/auto_streaming.py—_get_eas_monitor_settings()now defaults to enabled (returnsTrue) when noEASDecoderMonitorSettingsrow exists in the database. Previously it returnedFalse, so theeas-ingest-<source>Icecast monitoring stream was never mounted on fresh installs or when settings had never been saved — making it impossible to verify what the EAS decoder was hearing.app_core/audio/redis_commands.py—inject_test_signalhandler now callseas_monitor._discover_sources()before publishing chunks to the EAS broadcast queue. Without this, theUnifiedEASMonitorServicewatcher may not have subscribed yet (it runs discovery on a 5-second timer), causing every injected chunk to be delivered to zero subscribers and silently lost.eas_service.py—initialize_eas_monitor()now wraps the FIPS filtering callback withapp.app_context(), exactly aseas_monitoring_service.pyalready does. Without the context, every detected OTA alert caused_store_received_alert()to exit early (no context check) andforward_alert_to_api()to fail, so no alert was ever stored or forwarded to the air chain.eas_monitoring_service.py— Added_redis_publisher_monitor_loop()and wired it as a daemon thread. It starts aRedisAudioPublisherfor each running audio source (using the pre-resampled 16 kHz EAS broadcast queue) and publishes toaudio:samples:<source_name>on Redis.eas_service.pysubscribes to exactly these channels viaRedisAudioAdapter, but nothing ever published there — so the standalone EAS service received no audio and detected nothing. The loop also tracks source lifecycle: it automatically starts publishers for newly-started sources and stops them when sources are removed or shut down.
[2.69.1] - 2026-03-24 - Fix false "No audio flowing" warning and test-signal injection
Fixed
eas_monitor_v3.py—HealthTracker.update_no_audio()no longer resetsconsecutive_successful_readson every brief queue-empty return. The counter is now only reset when audio has been genuinely absent for more than 1 second, which prevents the 10-consecutive-read threshold from being interrupted by normal inter-chunk polling gaps and eliminates the false "⚠️ No audio flowing" warning shown even when audio sources are actively streaming.redis_commands.py— Addedinject_test_signalcommand toAudioCommandPublisherand the corresponding handler inAudioCommandSubscriber._execute_command. The command is routed to the audio-service process (which owns the runningAudioIngestController) via Redis so the EAS decoder test signal can actually reach a live audio source.eas_decoder_monitor.py— The/api/admin/eas_decoder_monitor/test_signalendpoint now sends theinject_test_signalRedis command to the audio-service instead of calling_get_audio_controller()on the webapp process, which always returned an empty (unstarted) controller and caused the misleading "No running audio source found to inject into" failure.
[2.69.0] - 2026-03-23 - Political subdivisions, NWS GIS data, and test-suite remediation
Fixed
- Removed redundant
import osinside_collect_smart_healththat causedUnboundLocalErrorin production. _restart_ffmpeginicecast_output.pynow sleeps forICECAST_RESTART_DELAYseconds before relaunching FFmpeg to prevent rapid restart loops.build_database_url()now falls back toPOSTGRES_*environment variables whenDATABASE_URLis not set.- SOAPY_SDR error code −7 description now includes "not locked" so the PLL lock hint is surfaced correctly.
Added
EASMonitor._streaming_decoderalias,_restart_counttracker,_restart_monitor_thread(), and_resample_if_needed()to support watchdog restarts and stereo audio handling.EASMonitor.get_status()now includesrestart_countand computes runtime metrics even when the monitor is stopped._SoapySDRReceiver._calculate_buffer_size()dynamically sizes the IQ read buffer based on the configured sample rate.- Setup wizard now includes a Core section (SECRET_KEY and PostgreSQL credentials) that is validated on form submission.
_is_valid_partition_code()inlocation_settings.py—sanitize_fips_codes()now accepts SAME partition-digit codes (e.g.627137) whose whole-county equivalent is known.tools/download_nws_gis_data.py— standalone CLI that downloads NWS Public Forecast Zones and NWR Political Subdivisions (partial-county) shapefiles from weather.gov intoassets/.- NWS partial-county shapefile
assets/cs16ap26.dbf(April 2026 vintage) bundled;_load_county_subdivision_indexnow auto-detects the newestcs*.dbfinassets/and logs a download hint when absent. install.shnow runstools/download_nws_gis_data.pyafter database setup to fetch the latest GIS data.
[2.68.0] - 2026-03-23 - Technical debt remediation
Changed
broadcast_adapter.py— Replaced bareexcept:clause withexcept queue.Empty:so only actual queue-timeout errors are swallowed; other exceptions propagate normally.radio/discovery.py— Silentexcept Exception: passblocks in SoapySDR capability queries now log atDEBUGlevel instead of discarding the error entirely, making it possible to diagnose device-support issues without polluting normal logs.routes_settings_radio.py— Replaced three genericraise Exception(error)calls withraise ValueError(error)so the exception type accurately reflects an unexpected value returned from the SDR command bus.- Migration scripts — Replaced
print()calls in five Alembic migration files withlogger.info()/logger.warning()using thealembic.envlogger so migration output flows through the standard logging stack rather than straight to stdout.
Removed
- Dead commented-out route
system_logs_pageinwebapp/routes_logs.py(previously marked DEPRECATED; the route was never registered and the template it referenced no longer exists). - Unreachable legacy fallback function
generate_wav_streamand its surrounding comment block inwebapp/admin/audio_ingest.py(the code afterreturn …, 503could never be reached).
[2.67.0] - 2026-03-23 - Per-source EAS decoder monitor streams + test signal injection
Added
- Per-source EAS ingest Icecast streams — The auto-streaming service now creates a
dedicated 16 kHz monitoring mount point for each running audio source (e.g.
/eas-decoder-monitor-my-source.mp3) instead of a single shared/eas-ingest.mp3. When you have two receivers both streams are immediately visible in Icecast and both can be listened to independently to verify what each decoder channel is hearing. - EAS decoder monitor respects database settings —
AutoStreamingServicenow readsEASDecoderMonitorSettings(enabled flag and mount-name prefix) at runtime. The ingest streams are only created when the monitor is enabled in Admin → EAS Decoder Monitor, and the mount names follow the configured prefix. - Test signal injection — New
POST /api/admin/eas_decoder_monitor/test_signalendpoint and matching "Send Test Signal" button in the admin UI. Clicking the button generates a standards-compliant SAME RWT (Required Weekly Test) signal at 16 kHz and injects it directly into the chosen source's EAS broadcast queue, exercising the full decoder pipeline without needing an external transmitter or real broadcast. - Navbar link — EAS Decoder Monitor is now listed under Monitor → Radio Monitoring for administrators.
- Updated nginx proxy rule — The single
/eas-ingest.mp3location block is replaced with a regex rule (~* ^/(eas-[a-z0-9_-]+\.mp3)$) that transparently proxies any EAS decoder monitor mount point through to Icecast on port 8000.
Changed
AutoStreamingService.__init__accepts an optionalflask_appparameter so the background monitor thread can queryEASDecoderMonitorSettingswith a proper app context.AudioIngestControllergainsinject_eas_test_signal(source_name)method.
[2.66.2] - 2026-03-23 - Fix TTS permanently disabled for all CAP/IPAWS alerts
Fixed
- TTS "No TTS provider configured" for every IPAWS/CAP alert —
load_eas_config()was callingget_tts_settings()first even when the CAP poller provided adb_sessionand had no Flask app context.get_tts_settings()catches all exceptions internally and returns a fakeTTSSettings(id=1)withenabled=Falserather than raising. That non-Nonefake object meant the guardif tts_settings is None and db_session is not Nonewas permanentlyFalse— thedb_sessionfallback (which reads the real settings) was never reached, andtts_providerwas always''for every alert the CAP poller processed. The fix restructures the TTS loading inload_eas_configto mirror the existingEASSettingsfix (Bug 3 intest_airchain_fringe_cases.py): whendb_sessionis supplied, query it directly first and skipget_tts_settings()entirely; only fall back toget_tts_settings()(the Flask-SQLAlchemy path) when nodb_sessionis provided. Four regression tests added totests/test_airchain_fringe_cases.pyunderTestLoadEasConfigTTSDbSession.
[2.66.1] - 2026-03-23 - Consolidate Tools menu into Settings dropdown
Fixed
- Navbar Tools menu overflow — The standalone "Tools" dropdown was too long to fit on
screen with no way to scroll. All Tools sections (Observability, Analytics & Reporting,
Testing & Validation, Data Continuity) have been moved under a new "Settings" dropdown
that also contains the link to System Settings. The combined dropdown uses
max-height: 80vh; overflow-y: autoso it always scrolls on short viewports.
[2.66.0] - 2026-03-23 - EAS ingest Icecast stream, Listen button fix, working test suite
Added
- EAS ingest Icecast stream (
/eas-ingest.mp3) — a 3rd Icecast mountpoint that streams the 16 kHz mono audio fed directly to the EAS decoder. Implemented via a newEASIngestShimclass inauto_streaming.pythat routesget_broadcast_queue()to the source's pre-resampled EAS queue. The stream auto-starts as soon as any audio source is running, auto-follows if the active source changes, and shows in Icecast as a 3rd active source alongside the two native-rate streams. Nginx config updated to proxy/eas-ingest.mp3directly from Icecast (port 8000). - Three working audio pipeline test files —
tests/test_audio_playout_queue.py(24 tests),tests/test_audio_output_service.py(13 tests), andtests/test_audio_pipeline_integration.py(19 tests) — replacing the previously missing stubs that caused the Audio Pipeline Test Suite to report "No summary available / FAILED". All 56 tests pass. - Robust test-runner logging —
routes_audio_tests.pynow scans output from the bottom up for the real pytest summary, synthesises a descriptive fallback message when pytest exits with no test lines (missing files, import errors, etc.), and logs the full stdout/stderr to the application log on any failure so operators can diagnose without needing the web UI.
Fixed
- Listen button — root cause was
audio.play()being called inside an asyncfetch().then()callback, which caused Chrome to revoke the user-gesture token and block playback withNotAllowedErroreven when audio was flowing. Fixed by callingaudio.srcandaudio.play()synchronously on click (preserving the gesture), then usingaudio.addEventListener('playing' / 'error')for state updates and a follow-up diagnosticfetch()only when an error occurs. The staticsrcattribute has been removed from the<audio>element so the browser no longer attempts to connect to the stream on page load. - Error messages now actionable — the error alert distinguishes between "no audio sources running" (guidance: start a source), "service down" (guidance: check System Diagnostics), and browser-level play failures.
[2.65.9] - 2026-03-23 - Log the operator who generates or sends a manual EAS alert
Added
- Operator audit trail for manual EAS alerts —
manual_eas_activationsnow storescreated_by(the user who generated the alert package) andtriggered_by(the user who broadcast it). Both fields are populated from the authenticated session at the moment of the action. - Application log entries —
workflow_logger.infonow emits a line such as"Manual EAS alert generated by user 'admin': id=7 event_code=RWT identifier=MANUAL-…"and"Manual EAS activation 7 (RWT) sent by user 'admin'"so the operator name appears in the EAS log file alongside every manual alert action. generated_byin SystemLog — theadmincode path also records the operator in thedetailsJSON of thesystem_logrow (was previously missing from that path).- Alert self-test log —
route_logger.infoforrun_alert_self_testnow includes the authenticated username so self-test runs are attributable in the log. - Database migration
20260323_add_created_triggered_by_to_activationsadds the two nullableString(100)columns idempotently.
[2.65.8] - 2026-03-21 - Fix blank OLED screen previews on Custom Display Screens page
Fixed
- OLED screen previews no longer blank — the Custom Display Screens management page now
renders a pixel-accurate canvas preview for screens using the modern
elementsformat (bar graphs, analog clocks, gauges, icons, dividers). Previously every screen that usedelementsshowed "OLED layout will appear here" regardless of its content. - Bar graphs visible in previews —
barelements are drawn as filled progress bars on the canvas at their template-defined position, using 65 % as a representative sample value when the actual value is a live{variable}. - VFD element previews improved — VFD screens that use the
elementsformat now render a green-on-black canvas preview instead of a meaningless "type (x,y)" list. - Legacy
lines-format OLED screens unaffected — the previous text-based renderer is still used for screens that definetemplate_data.lines(e.g.oled_gpio_status).
[2.65.7] - 2026-03-21 - Surface ENDEC hardware fingerprint in Alert Verification UI
Added
- ENDEC hardware shown in Alert Verification — the detected ENDEC type (
endec_mode) is now displayed as a coloured badge in the Decode Summary when analysing an audio file and in the "Recent Stored Decodes" table so operators can quickly identify the originating hardware (SAGE Digital 3644, NWS BMH, etc.). endec_modepersisted in stored decode records —record_audio_decode_result()now savesendec_modeinsidequality_metrics, making the value available for historical decode records.
Fixed
_deserialize_decode_resultin the alert-verification route now correctly restoresendec_modefrom stored JSON, preventing it from reverting toUNKNOWNwhen the async decode payload is reloaded from the progress store.
[2.65.6] - 2026-03-21 - EAS-Tools-compatible ENDEC fingerprinting via terminator bytes
Added
- ENDEC hardware detection via null/FF terminator bytes —
detect_endec_mode()now uses a voting system matching EAS-Tools to fingerprint the originating ENDEC from bytes appended after each SAME burst:- NWS Legacy / EAS.js: 2 × 0x00 →
NWS - NWS Broadcast Message Handler: 3 × 0x00 →
NWS_BMH - NWS Console Replacement System: 3 × 0x00 with CRS scoring →
NWS_CRS - SAGE ANALOG 1822: 1 × 0xFF →
SAGE_ANALOG_1822 - SAGE DIGITAL 3644: 3 × 0xFF →
SAGE_DIGITAL_3644 - SAGE DIGITAL 3644 (first burst leading byte): 0x00 before preamble → strong
SAGE_DIGITAL_3644vote - DEFAULT / DASDEC / TRILITHIC: identified by inter-burst gap timing (existing logic retained)
- NWS Legacy / EAS.js: 2 × 0x00 →
- Post-message terminator capture in
SAMEDemodulatorCore— after a SAME message is decoded, the DLL stays in "post-message mode" (keepingsynced=True) to collect the ENDEC-appended null/FF bytes before the inter-burst silence. CR/LF bytes (FCC §11.31 header terminator) are transparently skipped during this window. - Leading null byte detection — a 0x00 byte decoded just before a burst's preamble
run sets
_leading_null_detected = True, providing an additional vote forSAGE_DIGITAL_3644.
[2.65.5] - 2026-03-21 - Fix 32-bit WAV decode and trailing-space padding in SAME headers
Fixed
- 32-bit PCM WAV files fail to decode —
_read_audio_samplesonly handled 16-bit (sampwidth == 2) WAV files; 32-bit files (sampwidth == 4) fell through to ffmpeg which is not always installed, causingAudioDecodeError. Extended to read 32-bit signed PCM frames directly vianumpy.int32and normalise to[-1, 1]. - Goertzel decoder overrides correct DLL result with garbled partial header — for
some recordings (e.g. 32-bit PCM) the Goertzel bit decoder produces an incomplete
header (missing trailing
-) while the IQ-correlator/DLL produces a complete, valid one. The result-merge logic now prefers the DLL when it has a complete header and the Goertzel does not. - SAME headers generated with trailing spaces —
build_same_headerpadded the station identifier to 8 characters with.ljust(8), emitting e.g.KR8MER -instead ofKR8MER-. Removed.ljust(8)frombuild_same_header,load_eas_config,workflow.py, andaudio.py; the callsign is now written verbatim (stripped, up to 8 characters) with no trailing spaces.
[2.65.4] - 2026-03-20 - Fix /admin, /admin/notifications, /admin/application returning fallback HTML
Fixed
/adminreturning fallback HTML —get_same_lookup()returns aMappingProxyTypewhich Python'sjson.dumpscannot serialize.admin.htmluses{{ eas_fips_lookup|tojson }}which threwTypeError: Object of type mappingproxy is not JSON serializable, caught by the broadexcept Exceptionindashboard.admin(), returning the static fallback string. Fixed by converting todictindashboard.py,webapp/eas/workflow.py, andwebapp/routes_rwt_schedule.pybefore passing to templates./admin/notificationsand/admin/applicationreturning fallback HTML — both pages redirected todashboard.adminonSQLAlchemyError(e.g. missing migration columns), which then also failed to render. Both pages now render their own templates with safe in-memory defaults and a flash warning instead of redirecting away.- Setup-mode first-run access —
before_requestendpoint allowlist for setup mode only included old endpoint names'admin'/'admin_users'; updated to also accept'dashboard.admin'/'dashboard.admin_users'after blueprint refactor.
[2.65.3] - 2026-03-20 - Fix NameError crashing /api/system_status and system_logs template block
Fixed
/api/system_status500 error —_CPU_SAMPLE_INTERVAL_SECONDSconstant was referenced in_get_cpu_usage_percent()(webapp/admin/api.py) but never defined, causing aNameErroron every request; added the missing constant (5.0seconds)./logspage (system_logs.html) — template used{% block head %}which is not defined inbase.html; renamed to{% block extra_css %}so the page-level CSS is correctly injected.- Test correctness — updated
test_admin_dashboard_fixes.pyto reflect the active navbar component file (navbar.html, not the deletednavbar_new.html) and to accept the standard license-header docstring that precedes the__future__import.
[2.65.2] - 2026-03-20 - Fix missing route endpoints causing 500 errors
Fixed
admin/notifications/500 error — error-handler innotifications.pyreferenced non-existent endpointadmin_page; corrected todashboard.admin.admin/poller/500 error — sameadmin_pagetypo inpoller.py; corrected.admin/application-settings/500 error — sameadmin_pagetypo inapplication_settings.py; corrected.admin/hardware/,admin/icecast/,admin/tts/,admin/certbot/,admin/tailscale/500 errors — error-handlers referenced non-existent endpointadmin.index(no such blueprint); corrected todashboard.admin.
[2.65.1] - 2026-03-20 - Settings hub: added missing pages, fixed notifications description
Added
- Application Settings, Alert Poller, Text-to-Speech, SSL Certificates, and Backups
cards added to the Settings Hub (
/settings) so every admin page is reachable from one place. - New System category in the Settings Hub for Backups.
- Certbot (SSL) card added to the Network category.
Fixed
- Notifications card description in the Settings Hub now correctly reads "Email, SMS, and SNMP trap alert notification settings" (was "Push, SMS, and email").
[2.65.0] - 2026-03-20 - SNMP trap notifications and email notification fixes
Added
- SNMP v2c trap notifications — EAS Station™ can now send SNMP traps to NMS targets
when system health issues are detected. Configure targets, community string, and enable/
disable via the Notification Settings admin page (
/admin/notifications). pysnmpadded torequirements.txt— previously the SNMP library was an undocumented optional dependency; it is now listed as a proper dependency.test-snmpendpoint —/admin/notifications/test-snmp(POST) sends a test SNMP trap to all configured targets to verify connectivity.- SNMP fields in
NotificationSettingsmodel —snmp_enabled,snmp_targets(JSONB),snmp_communityare now stored in the database like all other settings. - Database migration
20260320_add_snmp_to_notifications— upgrades existing installs automatically on next startup.
Fixed
- Compliance email alerts now use database SMTP settings —
system_health.pywas still readingMAIL_SERVER/MAIL_PORT/MAIL_USE_TLSenvironment variables (which have been removed) instead ofNotificationSettingsfrom the database. Health alert emails now honour the SMTP configuration saved via the Notification Settings page. - SNMP health monitor uses database targets —
system_health.pynow reads SNMP targets and community string fromNotificationSettingswith fallback to legacy env vars.
[2.64.0] - 2026-03-20 - EAS decode speed improvements and raw SAME header parser
Added
- Raw SAME Header Parser on
/admin/alert-verification— paste anyZCZC-…string and instantly see all parsed fields (originator, event, locations, station, purge, issue time, plain-language summary) without uploading audio. Endpoint:POST /api/decode-same-header.
Performance
- Skip baud-rate offset variants when DLL confidence ≥ 0.85 — the Goertzel bit-scan now runs a single pass at the nominal baud rate instead of 17 passes (±0.5 – ±4%) when the DLL correlation decoder has already produced a high-confidence header decode. EOM detection and segment-boundary extraction are fully preserved; only the off-rate search variants are skipped for already-clean signals.
- Early-exit in multi-rate sample-rate selection —
_try_multiple_sample_ratesstops after the native rate when a structurally valid header is decoded with ≥ 0.9 bit confidence, avoiding up to six redundant full-file decode passes. - Vectorized Goertzel filter for tone detection —
_goertzel_powerineas_tone_detection.pynow usesnumpydot-product (BLAS) instead of a Pythonforloop over each sample. Mathematically identical; 20-50× faster per call on typical 100 ms windows. - Eliminated double audio load in
detect_eas_from_file— tone and narration detection now reuses the PCM already present in the SAME decode result's buffer segment instead of re-reading the audio file. The slow path (file re-read) is retained as a fallback when the buffer segment is unavailable. - Polyphase audio resampler —
_resample_with_scipynow usesscipy.signal.resample_poly(polyphase FIR, standard for audio) instead ofsignal.resample(FFT-based). Better frequency response and typically 10× faster for common sample-rate conversion ratios. - FIPS lookup singleton —
get_same_lookup()returns the module-levelUS_FIPS_LOOKUPdict directly instead of copying it on every call, eliminating repeated 4000-entry dict allocation during decode. - DB indexes on alert analytics columns — added
idx_cap_alerts_sent,idx_eas_messages_created_at, andidx_eas_decoded_audio_created_atto eliminate full table scans on the/admin/alert-verificationanalytics page. Migration:20260320_add_alert_verification_indexes.
[2.63.3] - 2026-03-20 - Repository root cleanup and documentation hygiene
Removed
- 10 root-level debug/scratch scripts —
check_log_crc.py,check_rbds_signal.py,check_tts_db.sh,debug_tts.py,enable_tts.py,trace_config_flow.py,verify_bit_order.py,test_tts_api.py,fastapi_app.py,fastapi_app_minimal.py. These were one-off diagnostic tools and hypothetical alternate app implementations with no place in a production codebase. bugs/directory (11 files) — screenshots, an MP3 audio sample, an IPAWS log, and an RBDS diagnostics archive. Already excluded from ISO builds via.gitignore; removed from git tracking entirely.scripts/README.md.old— stale backup file superseded by the current README.scripts/run_fastapi.sh— startup script for the never-deployed FastAPI alternate app.- 18 RBDS debugging tools from
tools/—README_RBDS.md,README_RBDS_DIAGNOSTIC.md,README_RBDS_STEREO.md,analyze_rbds_failure.py,analyze_rbds_stereo_code.py,audio_debug.py,collect-rbds-diagnostics.sh,demo_rbds_fix.py,rbds_auto_diagnostic.py,rbds_bit_permutations_test.py,rbds_diagnostic.py,test_block_reversal.py,test_rbds_bit_order.py,test_rbds_comprehensive.py,test_rbds_standalone.py,test_stream_capture.py,trace_rbds_stereo_path.py,validate_rbds_stereo_config.py. These are debug scaffolding from the RBDS fix campaign; the permanent fix is inapp_core/radio/.
Changed
docs/hardware/ALPHA_*.mdrenamed — removed "Phase X" development numbering from filenames andmkdocs.ymltitles:ALPHA_DIAGNOSTICS_PHASE1→ALPHA_LED_DIAGNOSTICS,ALPHA_TIMEDATE_PHASE2→ALPHA_LED_TIMEDATE,ALPHA_ADVANCED_PHASES3-5→ALPHA_LED_ADVANCED,ALPHA_WEB_UI_PHASE9→ALPHA_LED_WEB_UI.docs/troubleshooting/AUDIO_STREAMING_SETUP.md— rewrote from scratch. Previous version was a Docker-era skeleton full of empty code blocks and container references. Replaced with a complete bare-metal troubleshooting guide covering systemd services, Redis, Icecast, SDR hardware, and SQL configuration examples.docs/guides/HELP.md— fixed Reference Commands table (all entries were Docker Compose syntax:sudo systemd up -d --buildetc.); replaced with correct bare-metalsystemctlcommands. Updated Getting Started step to point to the Installation Guide instead of manual.envediting.docs/troubleshooting/TTS_TROUBLESHOOTING.md— replaced two references to the deletedtest_tts_api.pyscript with instructions to use the Test TTS button in the Admin UI.docs/guides/MANUAL_EAS_EVENTS.md— replaced reference todebug_tts.pywith pointer to the Admin UI TTS test button.mkdocs.yml— removed all nav entries pointing to previously deleted files; updated Alpha LED Sign titles to remove "Phase X" language; addedrun_fastapi.shremoval.docs/INDEX.md— added Alpha LED Sign documentation to the Hardware section.scripts/README.md— rewrote to reflect current bare-metal scripts inventory.
[2.63.2] - 2026-03-20 - Documentation cleanup and broken image fix
Fixed
- Missing image beside maintainer bio on About page –
ham-radio-icon.svgwas a PNG file with a wrong extension. Flask served it withContent-Type: image/svg+xml, causing browsers to fail silently when parsing binary PNG data as SVG XML. Renamed toham-radio-icon.pngand updated theabout.htmltemplate reference.
Removed
- Development artifact documentation purge – 42 files totalling ~800 KB of
development-era scratch notes, migration guides for completed migrations, one-off
diagnostic fix write-ups, and IDE-specific tooling docs have been removed. These were
internal working documents that had no place in a finished-product documentation set:
docs/archive/— entire directory (25 RBDS fix iteration files + SDR audio cutout fix)docs/development/ADMIN_PAGE_REFACTORING.md— internal refactoring roadmapdocs/development/CSS_VARIABLES_MIGRATION.md— December 2024 CSS migration notesdocs/architecture/MIGRATION.md— hypothetical FastAPI rewrite (never started)docs/guides/POLLER_MIGRATION_GUIDE.md— migration from legacy poller (completed)docs/guides/CONFIGURATION_MIGRATION.md— env-var merge utility (env vars removed)docs/guides/PYCHARM_DEBUGGING.md— 141 KB IDE-specific debug guide for developersdocs/troubleshooting/ENV_FILE_MIGRATION.md— systemd JSON env-file fix (old system)docs/troubleshooting/ENVIRONMENT_CONFIG_ISSUES.md— env-var config issues (old system)docs/troubleshooting/DATABASE_AUTH_FIX.md— one-off database auth fix notesdocs/troubleshooting/AUDIO_SQUEAL_FIX.md— Docker-era legacy squeal fixdocs/troubleshooting/DATABASE_CONSISTENCY_FIXES.md— internal code audit/fix notesdocs/troubleshooting/PGADMIN_APACHE2_CONFLICT.md— historical pgAdmin port conflictdocs/installation/Installation-Changes.md— install script improvement notesdocs/installation/PostgreSQL-15-Fix.md— one-off PostgreSQL 15 permission fixdocs/reference/FIPS_CODES_UPDATE.md— internal developer note on updating FIPS datadocs/reference/CFR-2010-title47-vol1-sec11-31.xml— raw regulatory XML dump
- Stale cross-references cleaned up –
docs/INDEX.md,docs/README.md,docs/troubleshooting/FIREWALL_REQUIREMENTS.md, anddocs/troubleshooting/POLLING_NOT_WORKING.mdupdated to remove broken links.
[2.63.1] - 2026-03-20 - Audio monitor false-Disconnected and missing Audio logs
Fixed
- EAS monitor showing false "Disconnected/Unavailable" status – The
/api/eas-monitor/statusendpoint had a@cache.cached(timeout=2)decorator that cached error responses for 2 seconds. When Redis metrics were momentarily stale the error response was served from cache on every subsequent poll during that window, even after metrics recovered. The decorator has been removed so the endpoint always reads live data directly from Redis, which is already fast. - Audio System Logs tab always empty – The
AudioAlertdatabase model existed and was queried by the Logs → Audio tab, but nothing ever wrote records to it. A new_make_audio_alert_log_callbackhelper ineas_monitoring_service.pynow persists stall, error, and disconnect events to theaudio_alertstable. The callback is registered via the newAudioIngestController.set_source_alert_callback()method and de-duplicates rapid-fire events (one record per source/type per 30 seconds) to avoid flooding the log.
[2.63.0] - 2026-03-19 - Coverage calculation fix and XML signature C14N verification
Fixed
- Coverage percentage calculation – The denominator in
calculate_coverage_percentagesnow uses the total area of only the boundaries that intersect with the alert, instead of the total area of all boundaries of that type in the entire database. The old formula produced misleadingly low percentages (e.g. 6 %) when the system contained many more boundaries than those actually affected by the alert. The new formula correctly reports 100 % when the alert fully covers every boundary it touches. - County-wide fallback producing wrong 100 % coverage – The alert detail view had a
fallback that set 100 % estimated coverage for all boundaries in the database whenever
is_county_wide=Trueand no intersection records existed. This caused alerts for other counties (e.g. a Henry County alert on a station configured for Putnam County) to show 100 % for the wrong county's boundaries. The fallback now only fires when the boundaries table is completely empty (station not yet configured); when boundaries exist but none intersect with the alert, coverage is correctly reported as 0 %. - "Calculate Coverage Percentage" button failing with missing geometry – The
/admin/calculate_single_alert/<id>endpoint returned 400 when the alert had no geometry yet. It now callstry_build_geometry_from_same_codes()first (matching what the alert detail page does), so the button works even before geometry has been derived from SAME geocodes. - XML digital signature verification – Added
_canonicalize_signed_info()helper inapp_utils/ipaws_enrichment.pythat uses lxml's C14N serialization to produce the canonical form of theSignedInfoelement before attempting signature verification. Both thecryptography-library path and theopenssl-CLI fallback path now use the canonicalized bytes. Previously all verification attempts failed with "Could not verify (C14N canonicalization required)" because the raw XML text bytes were used instead of the canonical form the signature was computed over.
[2.62.2] - 2026-03-19 - Comprehensive unauthenticated route access fix
Fixed
- Unauthenticated access to VFD control – All VFD routes (
/vfd_control,/vfd, and all/api/vfd/*endpoints, 12 total) now require@require_auth+@require_role("Admin", "Operator"). - Unauthenticated access to Displays dashboard –
/displaysnow requires@require_auth+@require_role("Admin", "Operator"). - Unauthenticated access to Screen management – All screen and rotation routes (
/screens,/screens/new,/screens/editor/<id>,/displays/preview, and all/api/screens/*,/api/rotations/*,/api/displays/current-stateendpoints, 17 total) now require@require_auth+@require_role("Admin", "Operator"). - Unauthenticated access to Alert Verification – All alert verification routes
(
/admin/alert-verification,/admin/alert-verification/operations,/admin/alert-verification/progress/<id>,/api/alert-self-test/run,/admin/alert-verification/export.csv, and the decode audio endpoint, 6 total) now require@require_auth+@require_role("Admin", "Operator"). The export endpoint additionally allows theAnalystrole. - Unauthenticated access to EAS Compliance dashboard – All compliance routes
(
/admin/compliance,/admin/compliance/export.csv,/admin/compliance/export.pdf, 3 total) now require@require_auth+@require_role("Admin", "Operator", "Analyst").
[2.62.1] - 2026-03-19 - LED control authentication and preview fixes
Fixed
- Unauthenticated access to LED control – All LED routes (
/led_control,/led, and all/api/led/*endpoints) now require@require_auth+@require_role("Admin", "Operator"), preventing access by unauthenticated or insufficiently-privileged users. - Message history stuck on "Loading message history..." –
loadMessageHistory()now updates the#message-historycontainer with an appropriate message when the API call fails or returns no data, instead of leaving the loading spinner indefinitely. - Live sign preview (canvas simulator) not working – Fixed a JavaScript bug where a duplicate
function initLEDControl()declaration caused infinite recursion (stack overflow) on page load, preventing all LED control initialization. The extra init code is now correctly placed inside theDOMContentLoadedhandler. - Search/filter history did nothing – Implemented the previously empty
displayFilteredHistory()stub so that the message history search and type filter actually update the displayed list.
[2.62.0] - 2026-03-19 - Full Alpha LED sign controller: Dots, RSS feeds, WYSIWYG simulator
Added
- WYSIWYG LED Sign Simulator – Live CSS-animated sign panel in the Custom Message tab; all 20 M-Protocol display modes animate in real time (scroll, roll-left/right/up/down, wipe-*, flash, explode, compressed-rotate, auto, clock)
- Mixed-mode multi-line preview – each of the 4 lines independently shows its selected effect/color/speed in the simulator panel
- Layout Preset buttons – one-click configurations: Static 4, Header+Scroll, Alert, Ticker
- Per-line effect pills – colour-coded badges on each line editor card showing the active display mode
- Speed modifier CSS classes – speed-1 through speed-5 control animation playback rate
- Dots / Pixel-Art tab – 20×7 (up to 160×16) interactive pixel-art canvas; click/drag to paint, shift/invert/fill tools, text-to-dots generator (5×7 bitmap font for A/E/S), five quick patterns (checkerboard, border, diagonal, heart, arrow), live canvas preview; sends via new M-Protocol Picture File (Type I) command
- RSS Feeds tab – add/remove RSS feed sources with name, URL, interval, color, effect, max items; per-feed fetch/refresh button; item viewer with click-to-select (up to 4 lines); "Send Selected" and "Send All Enabled Feeds" buttons
send_dots_graphic()method onAlpha9120CController– encodes a 2-D pixel grid as an M-Protocol Type I (Picture File) frameLEDRSSFeedandLEDRSSItemdatabase models with full CRUD API (/api/led/rss/feeds,/api/led/rss/feeds/<id>/fetch,/api/led/rss/feeds/<id>/items,/api/led/rss/send)- Dots API (
POST /api/led/dots) accepts a JSON dot-grid and sends it to the sign - Alembic migration
20260319_add_led_rss_feedsfor new tables feedparser==6.0.11added torequirements.txt
[2.61.2] - 2026-03-18 - Mermaid diagram fixes and documentation update
Fixed
- 7 broken Mermaid diagrams — Fixed parse and lexical errors in
docs/architecture/DISPLAY_SYSTEM_ARCHITECTURE.md(6 diagrams) anddocs/architecture/SYSTEM_ARCHITECTURE.md(1 diagram). Issues included: slash-starting node labels (/api/...), square brackets inside unquoted labels (elements: []), curly braces inside unquoted labels ({vars}), regex pattern text in labels, and a comma in a sequence diagram message (data:image/png;base64,...). All affected labels are now properly quoted with Mermaid's["..."]syntax. All 78 Mermaid diagrams across 11 documentation files now validate cleanly.
Added
- EAS decoding architecture diagram in
docs/architecture/EAS_DECODING_SUMMARY.md— Visual diagram showing how both the streaming decoder (StreamingSAMEDecoder) and the file decoder (decode_same_audio) share the singleSAMEDemodulatorCoreDSP engine inapp_utils/eas_demod.py, including bandpass filter, ENDEC mode detection, and burst timing components. - Notification delivery flow diagram in
docs/guides/notifications.md— Sequence diagram showing the post-broadcast pipeline from EAS broadcast through database, notification service, and out to SMTP (email) and Twilio (SMS) recipients.
Documentation
- Updated
docs/reference/DIAGRAMS.md— Added index entries for 5 previously unlisted documentation files that contain Mermaid diagrams:DISPLAY_SYSTEM_ARCHITECTURE.md(15 diagrams),HARDWARE_ISOLATION.md(3),SDR_TROUBLESHOOTING_FLOWCHART.md(1),SDR_SETUP.md(1), andOHIO_EAS_DOCUMENTATION.md(18). Also added entries for the 2 newly added diagrams. Updated total counts and related-links section. Updated "Last Updated" date.
[2.61.1] - 2026-03-18 - Theme readability fixes
Fixed
- Dark theme: invisible text on cards and Bootstrap components — Bootstrap 5.3
sets
--bs-body-color: #212529(dark gray) in:root. Because EAS Station™ usesdata-themeinstead ofdata-bs-theme, Bootstrap's own dark-mode palette was never activated, causing nearly all Bootstrap components (cards, tables, accordions, alerts, badges, etc.) to render dark gray text on dark theme backgrounds. Fixed by overriding--bs-body-colorand ~40 related Bootstrap CSS variables for all 8 dark themes (dark,coffee,aurora,nebula,midnight,charcoal,obsidian,slate) so the Bootstrap component layer uses our theme-aware palette variables. .cardmissing explicit text color — Addedcolor: var(--text-color)directly to the.cardrule instyles.cssso all card content inherits the correct text color even without relying solely on Bootstrap variable inheritance.bg-*-subtle/text-*-emphasisBootstrap utilities — Overrode--bs-*-bg-subtleand--bs-*-text-emphasisvariables for dark themes so badges and highlights using these classes display readable, theme-appropriate colors.alert-light/alert-secondaryin dark themes — These alerts previously rendered with Bootstrap's hard-coded light-mode colors (#fcfcfd background, #495057 text). Added dark-theme overrides to use surface and theme text colors instead.- Severity badge text contrast (
index.html) —.severity-severeusedcolor: whiteon a warning (amber/pale-yellow) background which is near-invisible in dark themes..severity-minorusedcolor: whiteon an info (light blue) background. Both changed tocolor: #1a1a1afor consistent readability across all themes.
[2.61.0] - 2026-03-18 - Airchain flow fringe-case fixes
Fixed
- OTA broadcast silently skipped — The EAS monitor daemon thread had no
Flask application context.
_auto_forward_to_air_chaincheckedhas_app_context()→ False and returned None on every OTA alert, so no over-the-air alerts ever reached the airchain.initialize_eas_monitornow wraps the entire alert callback (FIPS filtering + forwarding +_store_received_alertDB write) inwith app.app_context(). handle_alert()false-positive success on DB failure —same_triggeredwas set to True inresult.update()before the database commit. If the commit raised, the function returned withsame_triggered=Trueeven though no EASMessage record was saved and no audio was played.same_triggeredis now set only after a successful commit.- EASSettings not loaded from database in CAP poller —
load_eas_config()usedEASSettings.query.get(1)which requires a Flask context. The CAP poller runs outside Flask context, sobroadcast_enabledwas always None and fell back to theEAS_BROADCAST_ENABLEDenv-var default (false), silently disabling auto-forwarding even when enabled in the web UI. Adb_sessionfallback path (matching the existing TTS settings pattern) is now used when Flask-SQLAlchemy is unavailable. - Deprecated
datetime.utcnow()inalert_forwarding.py— Redis payload timestamps were built withdatetime.utcnow()(produces a naive datetime, deprecated in Python 3.12). Replaced withdatetime.now(timezone.utc). - OTA auto-forward attempted broadcast for UNKNOWN event codes — When the
SAME decoder could not identify the event code,
auto_forward_ota_alert()skipped deduplication but still proceeded to build an EASBroadcaster and callhandle_alert(), which would then fail insidebuild_same_header(). An explicit early-return is now added for empty orUNKNOWNevent codes. build_files()exceptions propagated uncaught fromhandle_alert()— Unexpected exceptions (I/O errors, TTS failures not caught internally) fromEASAudioGenerator.build_files()propagated out ofhandle_alert(), bypassing the caller's error handling. The call is now wrapped intry/exceptand returns a clean error result.test_eom_segment_duration_is_reasonableused wrong lower bound — The EOM segment is 3 short NNNN FSK bursts (~0.32 s each at 16 kHz) plus 3 seconds of silence, totalling ~3.97 s. The test asserted>= 4.0which always failed due to sample-count rounding. Corrected to>= 3.5with an upper-bound guard.
[2.60.4] - 2026-03-18 - IPAWS embedded audio used instead of TTS
Fixed
- IPAWS alerts with embedded audio fall back to TTS instead of using the pre-recorded
narration —
_fetch_embedded_audio()only accepted resources that had an externalurifield, silently ignoring resources whose audio was carried inline as a base64-encodedderefUriwith no separate URI. Many IPAWS originators (including the Ohio statewide EAS test seen in the bug report) embed the audio directly in the alert and omitmimeType/resourceDescentirely. The function now accepts any resource that has aderefUrias long as its MIME type (if present) does not indicate a non-audio format, and decodes the base64 content locally instead of making a network request. save_ipaws_audio()skipsderefUriresources with missingmimeType— The sameis_audioguard that blocked_fetch_embedded_audio()also preventedsave_ipaws_audio()(called during polling) from writing the embedded audio file to disk. This is now fixed with the same relaxed detection logic.- MPEG audio format detection too narrow —
_convert_audio_to_samples()checked only for the0xFF 0xFBMPEG-1 Layer-3 sync word. Valid MPEG-2 and MPEG-2.5 frames use different sync bytes (0xFF 0xEx/0xFF 0xFx). The check is now a general MPEG sync-word test (first byte0xFF, second byte high-nibble0xEor0xF). A pydubfrom_file()auto-detection fallback is also added for any format not matched by the explicit checks.
Fixed
- EAS audio sources stuck in ERROR state after network disruption — The
AudioSourceAdapter.start()method previously refused to restart a source that was inERRORstate (the guard only allowedSTOPPED). Streams that hit 50 consecutive read errors would exit their capture loop and stay permanently offline until the audio service was manually restarted.start()now detectsERRORstate, performs a clean reset (signals stop-event, joins the capture thread, calls_stop_capture()), and then relaunches the source normally. - No automatic recovery of failed audio sources — Added a source error-recovery
watchdog to the
eas_monitoring_servicemain loop. Every 30 seconds it scans all configured audio sources; any source inERRORstate is automatically stopped and restarted. This ensures that temporary network failures (dropped stream, DNS hiccup, etc.) heal without operator intervention. - "Listen to EAS audio feed" button always fails when EAS monitor has no active
watchers — The
/api/eas/decoder-streamendpoint required the EAS monitor's discovery loop to have already run and registered watchers before the stream could start. On a fresh service startup the discovery loop runs every 5 seconds, meaning audio sources could be running and streaming to Icecast but the Listen button would return 503 "EAS monitor has no active monitors." The endpoint now falls back to finding any RUNNING source directly from the audio controller, so the decoder audio feed is immediately available as soon as any source is running. - Misleading "audio-service may be starting up" error message — The EAS monitor status API returned the same generic string whether the audio service was unreachable, still initializing its first metrics snapshot, or simply had no running sources. The three cases now produce distinct, actionable messages.
- EAS monitor badge showed no guidance when sources are stopped — Added a "No Sources Running" warning badge and an inline message directing users to start an audio source. Previously the monitor appeared broken with no indication of what to do.
- Listen button error showed no actionable guidance — When the decoder stream endpoint returned "No running audio sources available", the error alert now includes "Start one of the audio sources in the section below, then try again." The alert timeout was also extended from 8 seconds to 12 seconds so users have time to read it.
[2.60.2] - 2026-03-17 - Alert modal interactivity and delete-expired fixes
Fixed
- Edit Alert modal and Confirmation modal unclickable — Both Bootstrap modals were
rendered inside
<main class="page-shell">, whose sticky navbar carries abackdrop-filterCSS property that creates a new stacking context. This caused the navbar to paint over the open modal, making all form fields and the close button unreachable. Fixed by appending both modal elements todocument.bodybefore constructing theirbootstrap.Modalinstances, placing them outside any problematic stacking context. - "Delete Expired Alerts" button always failed — The JavaScript
clearExpiredAlerts()function POSTed to/admin/clear_expired, but that route was never implemented on the backend. Added the/admin/clear_expiredPOST route tomaintenance.py. It returns a confirmation prompt with the count of alerts to be deleted on the first call, then permanently removes all alerts whoseexpirestimestamp is in the past or whose status is already"Expired"when called again with{ "confirmed": true }.
Fixed
- "View Alert" button on Audio Archive — The button was incorrectly linking to the
audio detail page (
/audio/<id>) instead of the CAP alert detail page. Users who clicked "View Alert" received a flash error "Unable to load audio detail at this time." because the audio detail page was being accessed with unrelated message IDs. Fixedhistory.pyto generatealert_urlusingapi.alert_detailso the button correctly navigates to the linked CAP alert. - "Edit Alert" modal not opening on Admin Panel — The Bootstrap Modal instance for
editAlertModalwas never created, soeditAlertModal.show()silently did nothing. Addednew bootstrap.Modal(element)initialization insideinitializeAlertManagement()inalert-management.js. - Confirmation modal not opening on Admin Panel —
window.confirmationModalwas likewise never initialized as a Bootstrap Modal instance, causing alert delete confirmations to fail. Added initialization inside theDOMContentLoadedhandler incore.js.
[2.59.0] - 2026-03-17 - Per-source polling logs and log viewer fixes
Added
- NOAA vs IPAWS polling differentiation — The CAP poller now writes a separate
PollHistoryrecord for each source type (NOAA, IPAWS, CUSTOM) per poll cycle. The Polling log viewer shows individual "Alert Polling (NOAA)" and "Alert Polling (IPAWS)" rows with per-source alert counts (fetched, new, updated, filtered, accepted), so it is immediately clear which source provided alerts and whether each source had errors. - Per-source error attribution — Fetch errors (SSL, timeout, request failures) are now
attributed to the specific source type that caused them and surfaced in the corresponding
PollHistoryrecord'serror_messagefield and status (ERROR/PARTIAL_SUCCESS).
Fixed
AudioAlert.clearedAttributeError — Theaudiolog-viewer category referenced a non-existentclearedattribute onAudioAlert(which usesresolved). Accessing this attribute when theaudio_alertstable contained rows would raise anAttributeError, suppressed by the outer exception handler and returned as an HTML error page rather than the log view. Changed tolog.resolved.PollHistory.poll_timeAttributeError —websocket_push.pyreferencedPollHistory.poll_time(non-existent) instead ofPollHistory.timestampandPollHistory.alerts_countinstead ofPollHistory.alerts_fetched, causing a silent exception when the IPAWS status WebSocket push ran. Both corrected.- IPAWS-STAGING endpoints now grouped with IPAWS — The FEMA TDL staging domain
(
tdl.apps.fema.gov) is now classified as"IPAWS"instead of the previous"IPAWS-STAGING"label, keeping it in the samePollHistoryrecord as production IPAWS and matching thenormalize_alert_sourcecanonical values.
[2.58.0] - 2026-03-14 - Documentation cleanup and navigation overhaul
Changed
- Documentation cleanup — Removed one-off development artifacts from the docs directory:
docs/GPIO_ENHANCEMENT_SUMMARY.md(PR summary, superseded by the individual feature docs it references) anddocs/development/timing_fix_explanation.py(Python demonstration code that is not documentation). - CSS Variables Migration doc relocated — Moved
CSS_VARIABLES_MIGRATION.mdfrom the repository root todocs/development/CSS_VARIABLES_MIGRATION.mdso it lives alongside other developer-facing documentation. - mkdocs.yml copyright corrected — Changed "MIT License" to the accurate dual-license statement (AGPL-3.0 for open-source use, Commercial License for proprietary use).
- mkdocs.yml navigation rebuilt — Removed 29 navigation entries pointing to files that do
not exist (orphaned references from previous development cycles). Added comprehensive sections
for all existing documentation:
- Installation section (7 guides, previously absent from nav)
- Troubleshooting section (21 guides, previously entirely absent from nav)
- Security section (3 guides, previously absent from nav)
- Architecture section expanded from 3 to 11 entries
- Hardware section expanded from 4 to 15 entries (including Alpha LED sign docs)
- Guides section expanded with all orphaned user guides
- Frontend, Reference, Roadmap, Maintenance, and Development sections updated with missing entries
- docs/INDEX.md updated — Added entry for the relocated CSS Variables Migration document.
[2.57.3] - Fix RBDS C' block sync bug
Fixed
- RBDS unreliable for stations broadcasting Group 2B (C' blocks) — When the presync state
machine achieved synchronisation and the triggering (second) valid block was a C' (C-prime) block
(
j=4in the syndrome table), the next expected block number was calculated as(j+1)%4 = 1(Block B) instead of the correct(offset_pos[j]+1)%4 = 3(Block D).offset_pos[4] = 2, which is the same frame slot as a normal C block, so the next expected block after C' is always D (block 3). Setting the block number to 1 caused every subsequent block in synced mode to fail its CRC check, exhausting the 35/50 bad-block threshold in seconds and forcing continuous re-synchronisation. WBKS and other stations that transmit Radio Text via Group 2B were particularly affected. Fix: compute initial block number as(offset_pos[j]+1)%4so C' is treated identically to C for the purpose of advancing to the next block. - RBDS polarity not updated at sync achievement — After presync achieved sync,
_rbds_inverted_polaritywas not updated to reflect the triggering block's polarity. If a spacing-mismatch reset had previously stored a different polarity, synced-mode CRC checks could silently apply the wrong bit inversion, causing all blocks to fail. Fix:_rbds_inverted_polarityis now explicitly set topolarityat the point of sync achievement.
[2.57.2] - Fix audio monitor not reporting metrics
Fixed
- Audio monitor shows "No metrics available from audio-service" —
_sanitize_value()ineas_monitoring_service.pyconverted numpy-inf(the defaultpeak_level_db/rms_level_dbfor stopped sources) to Pythonfloat('-inf')but returned it unchanged. Python'sjson.dumps()produces the non-standard literal-Infinityfor infinite floats (instead of raising an error), which is not valid JSON. Whenread_shared_metrics()later calledjson.loads()on the stored value it raisedJSONDecodeError, the audio-controller metrics fell back to the raw unparseable string, and source metrics were empty. After 60 seconds the Redis key expired, leaving the web app with no metrics to read and showing the "No metrics available from audio-service" banner. Fix: the function now converts any Pythonfloatinf/nan to-120.0before returning, matching the behaviour already applied to numpy types byworker_coordinator_redis._sanitize_for_json(). broadcast_queuestats never populated —collect_metrics()stored broadcast queue data under the key"broadcast_queues"(plural) while the web app and WebSocket emitter read"broadcast_queue"(singular). Fixed by using the consistent singular key.- EAS monitor status stored as string
"None"in Redis — when_eas_monitor.get_status()raised an exception,metrics["eas_monitor"]remainedNoneand was serialised as the literal string"None"(viastr(None)). Downstream readers then saw an unexpectedstrtype instead of adictand returned a confusing "invalid type" error. Fix:Nonevalues are now skipped entirely during serialisation, and exception fallback stores{"running": False, "error": "..."}. routes_eas_monitor_status.py"invalid type" error — the non-dict check now returns the same user-friendly "No metrics available" message instead of an internal type-error string, since both cases represent the same condition (EAS monitor not yet initialised).- Audio monitor VU meter warning hides when sources are running but silent — the warning banner now distinguishes between "no metrics from service" and "sources running but no audio detected", providing a clearer diagnostic message when the service is healthy but streams are silent.
- EAS Continuous Monitor badge stays "Loading…" on error — the status badge is now updated to "Unavailable" when the API returns an error and there is no cached valid state, instead of remaining permanently stuck on the initial "Loading…" placeholder.
- Source cards show "STOPPED" for unknown status — when the audio-service is not running,
sources had an
unknownstatus that was silently mapped to the "stopped" badge. A dedicated "Unknown" badge (slightly dimmed) is now shown so users can tell the difference between a source that is truly stopped and one whose status cannot be determined.
[2.57.1] - Fix RBDS phase drift from dropped queue samples
Fixed
- RBDS crystal-locked carrier phase drift —
RBDSWorker._pilot_sample_counteronly advanced when a chunk was actually processed, but when the RBDS queue was full the audio thread silently dropped chunks. Each dropped chunk caused the worker's local counter to lag further behind real stream time, producing a wrong 57 kHz mixing reference for every subsequent chunk. The extracted baseband signal was therefore pure noise, explaining why the decoder generated bits indefinitely but never decoded a single RBDS group. Fix: the absolute sample offset of each chunk is now tracked inFMDemodulator._sample_indexand passed tosubmit_samples();_generate_pilot_reference()uses this caller-supplied offset instead of a local counter so the phase is always correct regardless of how many chunks were dropped. - Stale RBDS unit tests — Updated
tests/test_rbds_demodulation.pyto test the current code architecture (RBDSWorkerworker-thread model) rather than methods that were removed in a prior refactor (_rbds_symbol_to_bit,_rbds_process_interval).
[2.57.0] - Enhanced Logs and Statistics
Added
- Received EAS Alerts log tab — New "Received EAS" tab on the Logs page shows EAS alerts received from audio monitoring sources (radio receivers), including event codes, forwarding decisions, SAME header details, and decode confidence scores.
- EAS activity stat cards — The Statistics dashboard now shows four new metric cards: EAS Received (from audio monitoring), EAS Forwarded (CAP alerts that triggered an EAS broadcast), Manual Activations, and Audio Forwarded count.
- Urgency and Certainty distribution charts — New "By Urgency" and "By Certainty" bar/doughnut charts on the Statistics page show how alerts are classified, helping identify the most common alert characterizations in your area.
- Received EAS stats in backend — Stats route now queries
ReceivedEASAlertandManualEASActivationtables and exposes forwarding rates and counts to the template. - Received EAS category in All Logs — The "All Logs" view now includes a "Received EAS" category aggregating audio-monitored EAS alert reception events.
Fixed
- Duration chart
avg_hoursfield mismatch —createDurationChart()was readingi.avg_hourswhich does not exist; it now correctly readsi.average(withi.avg_hoursas a fallback) so the Average Alert Duration chart renders properly.
[2.56.2] - Fix Gunicorn 504 / I2C Deadlock on Raspberry Pi
Fixed
504 Gateway Timeout / Gunicorn worker hung in I2C on Raspberry Pi — Three co-operating bugs caused the web service to deadlock on any Pi with an OLED display attached:
app.py— screen manager started inside Gunicorn worker (app.py).screen_manager.start()was called at module-import time inside every Gunicorn gevent worker. This spawned a 60 fps background thread that continuously issued blockingioctl()calls to the I2C bus.geventcannot monkey-patchioctl(), so those calls blocked the entire event loop. Simultaneouslyeas-station-hardware.serviceheld the kerneli2c_designwaremutex for its own 30 fps OLED scroll, creating a classic priority-inversion deadlock (rt_mutex_schedulevisible in/proc/<pid>/stack). Fix: removed thescreen_manager.start()call entirely from the web service. Display hardware is now owned exclusively byeas-station-hardware.service.routes_screens.py— web worker accessed I2C directly on push requests./api/screens/<id>/displaycalledinitialise_oled_display()(opens/dev/i2c-*) and drove the OLED directly from within a gevent request handler. Fix: the route now proxies toPOST http://127.0.0.1:5001/api/hardware/display/push— a new endpoint on the hardware service that executes the display push in the correct process.routes_screens.py—/api/displays/current-statedirect hardware fallback. When Redis was unavailable the fallback path opened the OLED controller from the web process. Fix: the fallback now returns a safe "hardware service unavailable" stub instead of touching hardware.
Session key inconsistency across Gunicorn workers (
app.py). Without--preload, each Gunicorn worker importsapp.pyindependently and callssecrets.token_hex(32)at module-import time, giving every worker a different Flasksecret_key. Sessions signed by Worker 1 were rejected by Worker 2, randomly logging users out mid-session. Fix: a new_load_or_generate_secret_key()helper persists the generated key to.secret_key(mode 0600, excluded from git) so all workers and restarts share the same key until a realSECRET_KEYis configured in.env.Gunicorn workers crashing on startup when PostgreSQL is not yet ready (
wsgi.py). The eagerinitialize_database()call raisedRuntimeErroron any DB failure, killing the worker before it could serve a single request. On a Raspberry Pi PostgreSQL often isn't fully reachable when the web service starts (boot ordering), resulting in a permanent 503/504 loop. Fix: DB failures at worker startup are now logged as warnings; the worker starts in a degraded state and thebefore_requesthook retriesinitialize_database()on every incoming request until the DB is available. Genuine non-DB exceptions (import errors, etc.) still propagate and kill the worker as before.
[2.56.1] - Web Stream Stall and RBDS Decoding Fixes
Fixed
- Web stream stall after extended runtime (
icecast_output.py) — The source-timeout restart check was gated on the internal buffer being non-empty (and buffer). When the audio source stopped supplying data the buffer drained to zero, causing the check to silently skip and leaving a stalled FFmpeg process running indefinitely. The erroneous guard has been removed so the timeout fires correctly regardless of buffer state. - RBDS decoding never locking (
demodulation.py) — Two related bugs prevented reliable RBDS carrier lock:- The M&M symbol-timing estimator (
_rbds_mm_mu) and the Costas carrier-phase/frequency registers (_rbds_costas_phase,_rbds_costas_freq) were reset to zero at the start of every 10-second processing batch. This forced both loops to re-converge from scratch every batch, making stable lock impossible in a continuous stream. _costas_pysdr()hardcodedalpha=4.25andbeta=0.0008(values tuned for single-pass offline recording processing) instead of using the carefully tuned streaming parametersself._rbds_costas_alpha=0.026/self._rbds_costas_beta=0.00035that were already present in__init__. The aggressive offline values caused loop oscillation in the streaming context.
- The M&M symbol-timing estimator (
[2.56.0] - UI Visual Modernization
Changed
- Ambient background gradient — All pages now display a subtle two-orb radial-gradient overlay fixed to the viewport. The gradient is derived from the active theme's
--primary-colorand--secondary-colorvariables, so it automatically adapts across all 20 built-in themes. - Admin card headers — Replaced the flat
var(--bg-color)fill with a theme-aware gradient tint (color-mixat low opacity against--surface-color), giving every section card a subtle accent without obscuring form content. - Admin header banner — Replaced hardcoded
#667eea / #764ba2hex values withvar(--primary-color)/var(--secondary-color)so the banner matches the chosen theme. Added a shimmer highlight overlay and a stronger box-shadow for depth. - Admin stat cards — Replaced hardcoded indigo/purple gradient with theme-aware
var(--primary-color)→var(--secondary-color)gradient. Hover shadow also now usescolor-mixon the theme primary rather than a hardcoded RGBA. - Admin modal headers — Replaced the hardcoded red gradient with the theme primary→secondary gradient to align with the rest of the UI.
- Manage-card headers — Applied the same subtle gradient tint treatment as the main card headers for visual consistency.
- Form focus glow — Replaced hardcoded
rgba(102, 126, 234, 0.2)focus ring withcolor-mix(in srgb, var(--primary-color) 20%, transparent)so the focus state reflects the active theme color.
[2.55.0] - Unified Settings Hub
Added
- Unified Settings hub page (
/settings) — All settings sections (Configuration, Network, Hardware, Security & Access) are now presented as a single card-based overview page, making it much easier to discover and navigate to any setting without hunting through nested dropdown menus.
Changed
- Settings navbar entry simplified — The Settings dropdown (which previously contained 15+ nested links across four sections) is replaced by a single "Settings" link that navigates directly to the new unified
/settingshub page, reducing navbar visual complexity.
[2.54.1] - Navigation Consolidation
Changed
- Merged Hardware dropdown into Settings - The Hardware navigation item has been removed as a standalone top-level dropdown. All hardware-related links (SDR Receivers, Audio Streams, Audio Archives, Hardware Settings, GPIO & Relays, Zigbee) are now organized under a new "Hardware" section within the Settings dropdown, reducing top-level navigation from 7 to 6 items.
- Moved Audio Health to Monitor - Audio Health dashboard link moved from Tools > Observability to Monitor > Radio Monitoring, where it logically belongs alongside other audio/radio monitoring links.
- Removed duplicate Alert Statistics from Tools - The
/statslink in Tools > Analytics & Reporting has been removed since Statistics is already accessible from the Monitor dropdown.
[2.54.0] - LED Time and Date Display Endpoints
Added
POST /api/led/set_time_formatendpoint (v2.54.0)- Accepts
time_format("TIME_12H" or "TIME_24H"),color, andfontparameters. - Calls the LED sign controller to apply the selected 12-hour or 24-hour time format, then sends the current time as a two-line message ("CURRENT TIME" / formatted time string) to the sign.
- Records the sent message in the
led_messagesdatabase table. - Files:
webapp/routes_led.py
- Accepts
POST /api/led/set_date_formatendpoint (v2.54.0)- Accepts
date_format(one of MMDDYY, DDMMYY, MMDDYYYY, DDMMYYYY, YYMMDD, YYYYMMDD),color, andfontparameters. - Formats the current date using the requested layout and sends it as a two-line message ("TODAY'S DATE" / formatted date string) to the sign.
- Records the sent message in the
led_messagesdatabase table. - Files:
webapp/routes_led.py
- Accepts
Changed
- LED control frontend buttons now fully functional (v2.54.0)
- Removed the "Time/date display feature coming soon" stub and disabled early-returns from
sendTimeDisplay()andsendDateDisplay()intemplates/led_control.html. sendDateDisplay()corrected to call/api/led/set_date_formatwith thedate_formatkey instead of the old copy-paste bug that called/api/led/set_time_formatwithtime_format.- Files:
templates/led_control.html
- Removed the "Time/date display feature coming soon" stub and disabled early-returns from
[2.53.2] - Twilio Toll-Free Verification Compliance
Added
CTIA-required opt-out footer in all outgoing EAS alert SMS messages (v2.53.2)
app_core/notifications/sms.pynow appendsReply STOP to stop msgsto every alert message body, satisfying CTIA messaging guidelines that Twilio enforces during toll-free number verification. This footer is required for carrier delivery.- Test SMS messages also include
Reply STOP to stop msgs, HELP for helpso test submissions to Twilio reviewers demonstrate compliance. - Files:
app_core/notifications/sms.py
Expanded
/sms-complianceopt-in disclosure page (v2.53.2)- Added "Sample Message Format" section with an exact mock-up of what EAS alert messages look like (including the new STOP footer), satisfying Twilio's requirement to show a representative message sample on the opt-in page.
- Added verbatim "Consent Disclosure Language" block (the exact text shown to recipients at opt-in) so Twilio reviewers can verify the opt-in flow.
- Expanded opt-out keyword table to include all Twilio-standard keywords: STOP, STOP ALL, CANCEL, END, QUIT, UNSUBSCRIBE.
- Removed Sprint (now T-Mobile) from the carrier list; list now reflects current major carriers.
- Files:
templates/sms_compliance.html
Twilio Toll-Free Verification help card in admin Notification Settings (v2.53.2)
- Added a new "Toll-Free Verification" card in the sidebar of
/admin/notificationsthat contains a ready-to-use field reference table: use case, opt-in type, opt-in page URL, privacy policy URL, terms of service URL, and exact message sample — all pre-filled for EAS Station™. Operators can copy values directly into the Twilio console form. - Files:
templates/admin/notifications.html
- Added a new "Toll-Free Verification" card in the sidebar of
Complete Twilio verification form field-by-field guide in
docs/guides/notifications.md(v2.53.2)- Replaced the short verification table with a full guide covering business information, contact information, use case, opt-in information, and message content sections. Each section provides exact copy-paste values for an EAS Station™ deployment.
- Added CTIA message content requirements section explaining the mandatory STOP footer.
- Files:
docs/guides/notifications.md
Updated SMS Messaging Policy to reflect new message format and full keyword list (v2.53.2)
- Message content sample updated to include the
Reply STOP to stop msgsfooter. - Opt-out keyword table expanded to include CANCEL, END, QUIT, UNSUBSCRIBE (Twilio standard).
- Files:
docs/policies/SMS_MESSAGING.md
- Message content sample updated to include the
[2.53.1] - Documentation & Compliance Update
Added
- AMPR 44.0.0.0/8 Non-Commercial Network Disclaimer (v2.53.1)
- Added a prominent non-commercial network notice to
templates/about.htmlandtemplates/terms.htmlfor deployments accessible via the AMPRNet (44.0.0.0/8) address block. - Added the same notice as Section 13 to
docs/policies/TERMS_OF_USE.md. - Explains FCC Part 97 non-commercial requirements, ARDC allocation policy, and that this service is operated strictly for non-commercial amateur radio research and emergency communications training.
- Files:
templates/about.html,templates/terms.html,docs/policies/TERMS_OF_USE.md
- Added a prominent non-commercial network notice to
Fixed
Created missing
docs/javascripts/mermaid-init.js(v2.53.1)mkdocs.ymlreferencedjavascripts/mermaid-init.jsas an extra JavaScript file, but the file and its parent directory did not exist, causing a 404 error when building the MkDocs documentation site.- Created
docs/javascripts/mermaid-init.jswith proper Mermaid initialization configuration (startOnLoad, theme variables, flowchart and ER diagram options). - Files:
docs/javascripts/mermaid-init.js
Fixed
.bg-lighttext readability in dark and coffee themes (v2.53.1)- The
.bg-lightCSS rule hard-codedcolor: #212121(near-black text), which became illegible when the--light-colorvariable resolves to a dark background colour (#455169in the dark theme,#5b4333in the coffee theme). Added theme-scoped overrides to usevar(--text-color)andvar(--text-secondary)for those two dark themes. - Files:
static/css/styles.css
- The
Updated SMS Messaging Policy date (v2.53.1)
- Updated the "Last updated" field in
docs/policies/SMS_MESSAGING.mdfrom a placeholder to the current revision date. - Files:
docs/policies/SMS_MESSAGING.md
- Updated the "Last updated" field in
Fixed
Fixed: Alert detail page shows "Coverage N/A" even when alert has embedded polygon geometry (v2.53.1)
try_build_geometry_from_same_codes()incoverage.pyreturnedFalseimmediately when theus_county_boundariestable was absent or empty, before ever checking whether the alert'sraw_json['geometry']already contained a usable polygon (NOAA GeoJSON feature body).- Fix: The function now tries three sources in order – (1) existing
alert.geom, (2) polygon inraw_json['geometry'], (3) SAME geocode lookup againstus_county_boundaries. Alerts with explicit geometry will always get their coverage calculated. - Files:
webapp/admin/coverage.py
Fixed: Audio stream ingest EAS detection delay after extended operation (v2.53.0)
- Root cause:
UnifiedEASMonitorService._monitor_loop()applied an unconditional 50ms sleep after each processing cycle regardless of whether audio was flowing. This caused the EAS broadcast queue consumer to fall behind the producer by ~15%, filling the 10,000-chunk buffer in ~7 hours and introducing up to 15+ minutes of real-time EAS detection latency. - Fix: Sleep is now skipped when audio was processed in the current iteration; the natural blocking in
BroadcastAudioAdapter.read_audio()(queue.get timeout) already rate-limits the consumer to the production rate. - Files:
app_core/audio/eas_monitor_v3.py
- Root cause:
Fixed: StreamingSAMEDecoder stuck in
in_message=Truestate (v2.53.0)- A false 'Z' byte detection could start a message assembly that never received ZCZC/NNNN. When the message grew past
MAX_MSG_LEN(268 chars) without a valid header,_is_message_complete()returned False but no reset occurred, permanently blocking preamble detection via thenot self.in_messageguard. - Fix: When
len(current_msg) > MAX_MSG_LENand no valid header is found, the decoder state is explicitly reset so preamble detection can resume. - Files:
app_core/audio/streaming_same_decoder.py
- A false 'Z' byte detection could start a message assembly that never received ZCZC/NNNN. When the message grew past
Fixed: Coverage "Coverage Calculating..." badge shown indefinitely when no boundaries configured (v2.53.0)
- After coverage calculation completes with empty results (no boundaries in local database), the alert detail page displayed "Coverage Calculating..." in the header badge and Technical Details panel, suggesting ongoing computation that would never complete.
- Fix: Badge and coverage type now show "Coverage N/A" when calculation has completed with no results.
- Files:
templates/alert_detail.html
Fixed:
_detect_county_wide()hardcoded to "Putnam County, Ohio" (v2.53.0)- The county-wide coverage detection logic in
api.pyand the inline Jinja2 fallback blocks inalert_detail.htmlwere hardcoded to "putnam county" and "ohio", making the feature non-functional for any deployment outside Putnam County, OH. - Fix: Both the Python function and all template inline checks now use the configured county name and state code from
location_settings. - Files:
webapp/admin/api.py,templates/alert_detail.html
- The county-wide coverage detection logic in
Added
- Consistent visual theming across all pages (v2.52.0)
- Added the standard
admin-page-headergradient banner to all 22 admin pages that previously lacked a consistent page header (application_settings, backups, county_boundaries, eas_decoder_monitor, mail_server, notifications, poller, zones, sessions, audio_archives, audio_sdr_fix, audio_sources, radio, radio_diagnostics, certbot, icecast, tailscale, tts, alert_feeds, environment, network, zigbee). Old ad-hoc h1/h2 heading rows removed. - Migrated
hardware_settings.htmlfrom the non-admin.page-headerto.admin-page-headerfor consistent admin section styling. - Fixed
index.html(dashboard): removed the large inline<style>block that overrode the global.page-headerCSS with conflicting padding, border-radius, and child element structure. Updated dashboard page-header HTML to use the canonical standard pattern (matching alerts.html, etc.). - Replaced hardcoded hex colors (
#6610f2,#6f42c1) in.admin-page-header.header-purpleinstatic/css/admin.csswith theme-aware CSS variables (var(--vibrant-indigo),var(--secondary-color)) so the purple header variant respects the active theme. - Files: all 22
templates/admin/*.htmlpages,templates/index.html,static/css/admin.css
- Added the standard
Fixed
Fixed: floating orbs invisible due to compounded opacity (v2.51.5)
- Root cause: two opacity reductions were being compounded —
color-mix(..., transparent)already reduces the gradient stop to ~10–25% opacity, and then the elementopacityproperty was additionally set to0.06–0.12, resulting in an effective visibility of under 2% (invisible). mix-blend-mode: soft-lightwas also ineffective on dark backgrounds with dark-coloured sources; changed toscreenso orbs produce a visible glow on all themes.- Page-header orbs updated to use white/light source colours that work correctly with
soft-lighton the vibrant gradient header. - All per-orb
opacityvalues raised to0.38–0.75;color-mixpercentages raised to 40–60% so the net visual effect is a subtle but perceptible coloured glow. - Added global
.page-shell > *:not(.orb) { position: relative; z-index: 1; }rule so page content always renders above the fixed background orbs. This rule previously existed only intemplates/index.html; moved tostatic/css/styles.cssand removed the duplicate. - Files:
static/css/styles.css,templates/index.html
- Root cause: two opacity reductions were being compounded —
SSL certificate overwritten with self-signed cert on every upgrade (v2.51.5)
update.shunconditionally copied the nginx template (which always references the self-signed certificate) over the live config. Any Let's Encrypt certificate paths configured by the admin were silently reverted on each./update.shrun.- The script now reads the active
ssl_certificateandssl_certificate_keypaths before overwriting the config file. If those paths point to a non-default (e.g. Let's Encrypt) certificate that still exists on disk, they are re-applied to the freshly copied template before nginx is reloaded. - Files:
update.sh
Added
Automatic alert forwarding to air chain for IPAWS, NOAA, and OTA sources (v2.52.0)
- Received alerts are now automatically forwarded for broadcast with zero operator intervention
- Station originator is substituted into outgoing SAME headers via
build_same_header() - Cross-source deduplication prevents the same alert from being broadcast multiple times when received via IPAWS + NOAA + OTA simultaneously (15-minute window, event code + overlapping FIPS)
- CAP poller now calls
auto_forward_cap_alert()after saving each new alert, triggeringEASBroadcaster.handle_alert()for full broadcast (SAME audio + GPIO + playback) - OTA alerts forwarded via
auto_forward_ota_alert()through the same broadcast pipeline CAPAlert.eas_forwardedtracking flag is now properly updated (was alwaysFalsepreviously)- Files:
app_core/audio/auto_forward.py(new),app_core/audio/alert_forwarding.py,poller/cap_poller.py,eas_service.py,eas_monitoring_service.py
Tailscale VPN integration with admin UI (PR #1671)
- New
/admin/tailscalepage for managing Tailscale VPN connections - Backend settings management via
app_core/tailscale_settings.py - Database model for persisting Tailscale configuration
- Navigation entry added to admin menu
- Files:
app_core/tailscale_settings.py,webapp/admin/tailscale.py,templates/admin/tailscale.html,app_core/models.py
- New
Redesigned OLED screens with graphical elements and new display types (PR #1669)
- New graphical OLED rendering engine with
screen_renderer.py - Database migration for improved OLED screen configuration
- New OLED driver module in
app_core/oled.py - Files:
app_core/oled.py,scripts/screen_renderer.py, migration20260216_improve_oled_screens.py
- New graphical OLED rendering engine with
NOAA alerts support in display data extraction and UI (PR #1666)
- Extended display data extraction API to handle NOAA weather alerts
- Alert detail UI updated to render NOAA-specific fields
- Files:
templates/alert_detail.html,webapp/admin/api.py
Enhanced S.M.A.R.T. diagnostics API with multiple NVMe strategies (PR #1657)
/api/smart_diagendpoint now tries multiple NVMe query strategies (different paths, different tools)- Surfaces meaningful error messages when diagnostics fail
- Files:
webapp/admin/api.py
Changed
Display preview styling with type-specific themes (PR #1670)
- Applied type-specific visual themes to display preview and screens templates
- Files:
templates/displays_preview.html,templates/screens.html
Compact SAME codes and geocodes in multi-column grid layout (PR #1668)
- Alert detail page compresses SAME codes and geocodes into a readable multi-column grid
- Files:
templates/alert_detail.html
Refactored alert detail layout (PR #1667)
- Moved timing and technical information cards from sidebar into main content flow
- Files:
templates/alert_detail.html
GPIO configuration UI aligned with Hardware Settings (PR #1653)
- Refactored GPIO configuration templates to use the Hardware Settings admin interface
- Files:
templates/gpio_pin_map.html,templates/gpio_control.html,app_utils/gpio.py
Fixed
Fixed SSL/TLS certificate management for easstation.com (PR #1672)
- Enhanced certbot admin module with better certificate handling
- Improved SSL utility functions for robust certificate management
- Files:
app_core/ssl_utils.py,webapp/admin/certbot.py
Fixed EAS decoder stream error handling and validation (PR #1665)
- Hardened EAS decoder with better stream error handling and validation logic
- Improved audio monitoring UI with additional status information
- Files:
eas_monitoring_service.py,templates/audio_monitoring.html
Reduced CPU usage in EAS monitor loop and WAV streaming (PR #1664)
- Adjusted timing/sleep intervals in EAS monitoring loop to reduce unnecessary CPU cycles
- Files:
app_core/audio/eas_monitor_v3.py,eas_monitoring_service.py
Reduced VU meter CPU usage and improved audio playback (PR #1663)
- Frontend VU meter animation CPU consumption significantly reduced
- Enhanced EAS decoder audio playback functionality
- Files:
static/js/realtime-vu-meters.js,templates/audio_monitoring.html
Fixed audio playback speed by syncing sample rate from source (PR #1662)
- Icecast output now correctly syncs sample rate from the audio source
- Fixes playback that was too fast or too slow
- Files:
app_core/audio/icecast_output.py,eas_monitoring_service.py
Fixed NVMe S.M.A.R.T. monitoring across multiple PRs (PRs #1654-#1661)
- Use NVMe controller path (
/dev/nvme0) instead of namespace path (/dev/nvme0n1) for SMART queries - Added
CAP_SYS_ADMINcapability to systemd service for NVMe SMART ioctls - Fixed
DeviceAllowto use device group names instead of path wildcards - Added NVMe and SATA device allow rules to systemd service
- Fixed
/api/smart_diagreturning 401 for unauthenticated requests - Files:
systemd/eas-station-web.service,app_utils/system.py,webapp/admin/api.py
- Use NVMe controller path (
Fixed EAS monitor status oscillation between idle and active (PR #1655)
- Resolved rapid flipping between idle and active states in EAS monitor
- Files:
eas_service.py
Enhanced
- Enhanced visual appearance of install and update scripts (v2.51.3)
- Added animated celebration with sparkles on successful completion
- Added elapsed time tracking and display for installation/update operations
- Added time estimate display function for long-running operations
- Added enhanced section separators with color options
- Improved visual consistency between install.sh and update.sh
- Scripts now provide better user feedback with more engaging visual elements
- Files:
install.sh,update.sh
Fixed
Fixed System Health memory usage display error (v2.51.2)
- Fixed memory usage display: Changed
memory.percenttomemory.percentageto match backend data structure - Error message "'dict object' has no attribute 'percent'" is now resolved
- File:
templates/system_health.html
- Fixed memory usage display: Changed
Fixed System Health page template errors (v2.51.1)
- Fixed CPU usage display: Changed
cpu.overall_percenttocpu.cpu_usage_percentto match backend data structure - Fixed Storage display: Changed
partitionstodiskandpartition.percent_usedtopartition.percentageto match backend data structure - Error message "'dict object' has no attribute 'overall_percent'" is now resolved
- File:
templates/system_health.html
- Fixed CPU usage display: Changed
Fixed NVMe S.M.A.R.T. data display issue (v2.51.0)
- Changed template variable access from
.get()method to dot notation for consistency - NVMe I/O statistics (data units read/written, host commands, controller busy time) now display correctly
- File:
templates/system_health.html
- Changed template variable access from
Improved S.M.A.R.T. error visibility and diagnostics (v2.51.0)
- Changed status badge from "Unknown" to "Error" (red) when device query fails
- Added warning banner when devices have collection errors
- Provides troubleshooting guidance for common issues (sudo permissions, device access, driver problems)
- Added device type badges (NVMe/SSD/HDD) to table view for quick identification
- Error messages now prominently displayed in Health Details column
- File:
templates/system_health.html
Added
- Modernized System Health page with new features (v2.51.0)
- Added quick stats summary cards at top showing CPU, Memory, Storage, and S.M.A.R.T. status at a glance
- Added visual progress bars for each metric with color-coded thresholds (green/yellow/red)
- Added export system data feature with floating action button to download health snapshot as JSON
- Added NVMe device badge to clearly identify NVMe drives in S.M.A.R.T. section
- Added device type badges (NVMe/SSD/HDD) for all storage devices in both card and table views
- Improved visual hierarchy and organization of health information
- Better mobile responsiveness for summary cards
- Files:
templates/system_health.html
Changed
Modernized footer design with enhanced visual appeal (v2.50.0)
- Multi-column layout with organized sections (Brand, Quick Access, Resources, Legal, Status)
- Animated gradient background with floating logo icon
- Status widgets showing real-time clock, version, and health
- Larger tech stack badges with for-the-badge style
- Improved mobile responsiveness with centered layouts
- Animated gradient accent line at top
- Enhanced hover effects on all interactive elements
- Better visual hierarchy and spacing throughout
- Files:
templates/base.html,static/css/styles.css
Fixed code review issues in display routes (v2.50.0)
- Replaced f-strings with lazy % formatting in logging calls for better performance
- File:
webapp/routes_displays.py
Added
Site Navigation page for quick access to all features (v2.49.0)
- Created
/navigationpage organizing all features by category - Quick access buttons to 40+ pages grouped into 8 logical sections
- Added to Help dropdown menu for easy discovery
- Helps new users understand the full feature set
- Reduces need to hunt through navigation menus
- Files:
templates/site_navigation.html,webapp/routes_public.py,templates/components/navbar.html
- Created
Unified Display Controls page (v2.48.0)
- Created consolidated
/displayspage combining LED, VFD, and OLED display controls - Single navigation entry replaces three separate display control links
- Quick access tabs for each display type with status indicators
- Quick action buttons to access full control pages when needed
- Recent activity feed showing display output history across all display types
- Reduces navigation menu complexity while maintaining full functionality
- Files:
templates/displays_control.html,webapp/routes_displays.py,webapp/__init__.py,templates/components/navbar.html,templates/help.html
- Created consolidated
Changed
- Replace hardcoded colors with CSS variables across frontend (v2.47.0)
- Added comprehensive CSS variables for alert severity, hardware displays, boundaries, charts, and overlays
- Replaced inline style attributes with CSS classes using theme variables
- Updated LED control page to use CSS variables for terminal colors and LED hardware colors
- Updated index page hero section and custom boundaries to use theme-aware colors
- Updated display preview pages (OLED/VFD) to use CSS variables for canvas rendering
- Updated alert detail maps with severity and boundary color functions using CSS variables
- Updated security settings page badges and headers to use theme colors
- Updated analytics dashboard charts (stats/_scripts.html) to dynamically use CSS variables
- Updated analytics dashboard styles (stats/_styles.html) to use chart color variables
- Benefits: Automatic theme support, centralized color management, dark mode compatibility, easier customization
- Files:
static/css/styles.css,templates/admin.html,templates/index.html,templates/alert_detail.html,templates/led_control.html,templates/displays_preview.html,templates/security_settings.html,templates/stats/_scripts.html,templates/stats/_styles.html
Fixed
Fix update.sh silently failing without notifying user on git errors (v2.46.8)
- Added
set +e/set -earound git fetch and git reset commands to prevent silent exit - Script now properly displays error messages when git operations fail instead of quitting silently
- Fixes issue where script would stop at Step 5 without showing error details or reaching completion
- Users now see helpful error messages with troubleshooting steps when git commands fail
- File:
update.sh
- Added
Fix update.sh failing to fetch updates in shallow clones (v2.46.7)
- Changed
git fetch originto explicitly fetch current branch with refspec - Fixes issue where update.sh would quit after displaying error in environments with limited git refspecs
- Now works correctly with shallow clones and GitHub Copilot agent environments
- File:
update.sh
- Changed
Fix update.sh showing same version after update (v2.46.6)
- Added EAS_SKIP_PULL check to prevent redundant git operations on script restart
- Added helpful message when branch is already up-to-date with instructions to switch branches
- Improved indentation and code structure in git update section
- File:
update.sh
Added
- Add IPAWS enrichment data display to alerts list page (v2.46.5)
- Display IPAWS certificate "Signed" badge in Source column for digitally signed alerts
- Add IPAWS audio play button in Audio column when original IPAWS audio is available
- Users can now see and access IPAWS enrichment features from main /alerts page
- File:
templates/alerts.html
Security
CRITICAL: Fix path traversal vulnerability in IPAWS audio serving (v2.46.4)
- Added filename sanitization using
os.path.basename()to prevent directory traversal - Added path validation to ensure resolved path is within output directory
- Changed to use Flask's
send_file()instead of reading entire file into memory - File:
webapp/admin/api.py-ipaws_original_audio()endpoint
- Added filename sanitization using
CRITICAL: Fix XSS vulnerability in IPAWS web resource URLs (v2.46.4)
- Added URL scheme validation to only allow http:// and https:// protocols
- Prevents javascript: URIs and other malicious schemes from being rendered
- File:
webapp/admin/api.py-_extract_ipaws_display_data()function
MAJOR: Fix DoS vulnerability in IPAWS audio handling (v2.46.4)
- Added configurable size limit (10MB default) via
IPAWS_AUDIO_MAX_BYTESenv var - Validates size hint from resource metadata before decoding
- Estimates decoded size before base64 decode to prevent memory exhaustion
- Uses strict base64 validation to catch malformed payloads
- Verifies actual decoded size before writing to disk
- File:
app_utils/ipaws_enrichment.py-save_ipaws_audio()function
- Added configurable size limit (10MB default) via
Fixed
- Fix missing database columns in IPAWS enrichment migration (v2.46.4)
- Added
signature_verifiedandsignature_statuscolumns to migration - These fields were defined in CAPAlert model but missing from migration
- File:
app_core/migrations/versions/20260210_add_ipaws_enrichment.py
- Added
Fixed
CRITICAL: Fix M&M Symbol Rate Bug - Wrong SPS in Interpolated Space (v2.44.22)
- ROOT CAUSE FOUND: M&M was ALWAYS running at 15.625 sps regardless of loop gain!
- The bug:
sps = 16but after 16x upsampling, should besps = 16 * 16 = 256 - Evidence: Changing loop gain from 0.2 → 0.075 → 0.03 had NO effect on symbol rate
- All versions showed 15.625 sps (250 samples → 16 symbols)
- This proves sps was fundamentally wrong, not just gain tuning issue
- The Math:
- At 19 kHz: 16 samples/symbol (correct in original space)
- After 16x upsample: 256 interpolated samples/symbol (was using 16!)
- With sps=16, mu advances by ~16 per symbol → skips 16 interpolated samples
- But should skip 256 interpolated samples = 16 original samples
- Result: M&M runs 16x too fast in interpolated space, locks to wrong timing
- Impact: M&M timing locked to 15.625 sps instead of 16.0 sps
- Symbol extraction 2.34% too fast
- Bits extracted at wrong phase → random errors → all blocks fail CRC
- Solution: Changed
sps = 16tosps = 16 * 16andmuto interpolated space - Files:
app_core/radio/demodulation.py:626-662 - Expected: M&M should now lock at EXACTLY 16.0 sps, all blocks should pass CRC!
- This was THE bug preventing RBDS from working all along!
CRITICAL: Fix Off-By-One Error in Sync Transition (v2.44.21)
- BREAKTHROUGH: First sync achieved! "RBDS SYNCHRONIZED at bit 605" ✅
- First block PASSED CRC with inverted polarity ✅
- But ALL subsequent blocks failed CRC immediately after sync
- Root cause: Off-by-one error when transitioning from presync to synced mode
- The Bug: After processing sync block, code reset register but continued processing current bit in main while loop. This caused next block to start 1 bit off.
- Sequence:
- Presync finds sync, processes sync block (bit N through N+25) ✅
- Sets
_rbds_synced = True, resets register ✅ - Main loop continues with bit N+26 (first bit of next block)
- Enters synced mode, starts counting from 0
- Problem: Already processed bit N+26 as part of current iteration!
- Next block accumulates bits N+26 (already in reg) through N+51
- Off by 1 bit → all CRCs fail ❌
- Solution: Set
_rbds_block_bit_counter = -1at sync so current bit becomes bit 0 - File:
app_core/radio/demodulation.py:1107 - Impact: Subsequent blocks should now pass CRC and decode station data!
- This was the LAST bug preventing RBDS from working!
CRITICAL: Further Reduce M&M Loop Gain for Exact Symbol Lock (v2.44.20)
- Problem: M&M still running 2.6% fast - 15.59 sps instead of 16.0 sps
- Evidence from production logs: "499 samples -> 32 symbols" = 15.59 sps
- Progress: Much better than v2.44.18 (15.625 sps) but still not exact
- Impact of timing error:
- Syndromes VERY close now (385 vs 383, off by only 2!)
- Presync finding blocks but spacing still wrong
- Timing error accumulates: "expected 78, got 30" spacing mismatches
- Solution: Further reduced loop gain 0.075 → 0.03 for exact 16.0 sps lock
- File:
app_core/radio/demodulation.py:680 - Expected: M&M should produce exactly 16.0 samples/symbol, perfect syndrome matches
CRITICAL: Tune M&M Loop Gain to Fix Symbol Rate (v2.44.19)
- Problem: M&M running 2.4% too fast - extracting symbols at 15.625 sps instead of 16 sps
- Evidence from logs: "250 samples -> 16 symbols" = 15.625 samples/symbol (should be 16)
- Impact: Wrong symbol rate causes bit slippage and presync spacing errors
- Presync found blocks 70 bits apart instead of expected 26 bits
- Random syndromes due to symbols at wrong timing phase
- Root cause: M&M loop gain of 0.2 too aggressive, causing oscillation/wrong phase lock
- Solution: Reduced loop gain from 0.2 to 0.075 for stable convergence
- File:
app_core/radio/demodulation.py:679 - Testing on Class B FM at 8 miles (strong signal) - should now lock correctly
- Expected: M&M should produce exactly 16 samples/symbol, syndromes should match targets
CRITICAL: Fix Multiple Fundamental RBDS DSP Bugs (v2.44.18)
- After 35+ failed PR attempts, deep analysis revealed MULTIPLE critical DSP bugs preventing RBDS from ever working
- BUG #1: M&M Timing Error Formula Completely Wrong
- Old formula:
error = real((sample[n]-sample[n-1])*conj(decision[n-1]) - (decision[n]-decision[n-2])*conj(sample[n-1])) - This is neither Mueller & Müller nor Gardner - it's a broken hybrid!
- Correct formula:
error = real((sample[n] - sample[n-2]) * conj(decision[n-1])) - Impact: Symbols extracted at wrong timing offset, causing random bit errors
- Fixed: Lines 667-671, now uses standard M&M formula
- Old formula:
- BUG #2: M&M Loop Gain 10-20x Too Small
- Old value: 0.01 (would need 100+ symbols to converge)
- New value: 0.2 (converges in 5-10 symbols)
- Impact: Timing recovery could never lock before signal ended
- Fixed: Line 674
- BUG #3: Costas Loop Bandwidth 20x Too Wide
- Old values: alpha=0.132, beta=0.00932 (20% loop bandwidth!)
- New values: alpha=0.026, beta=0.00035 (1% loop bandwidth)
- Impact: Tracked noise instead of signal, unstable on weak signals
- Fixed: Lines 348-349
- BUG #4: M&M Hard Decisions Wrong for BPSK
- Old code treated BPSK like QPSK with independent I/Q decisions
- BPSK uses only real axis; imaginary should be 0
- Impact: Wrong decision regions for timing error calculation
- Fixed: Lines 660-665
- BUG #5: Bandpass Filter Normalization Wrong
- Used
sum(abs(h))normalization, invalid for bandpass (has negative coefficients) - Changed to
max(abs(h))for proper peak normalization - Impact: Filter had wrong gain, attenuating RBDS signal
- Fixed: Line 386
- Used
- BUG #6: Differential Decoding Type Inconsistency
- Previous symbol stored as float, used as int - type confusion
- Changed to consistent int type (0 or 1)
- Impact: Minor, but cleaner code
- Fixed: Lines 357, 590, 598
- Files:
app_core/radio/demodulation.py- complete DSP chain overhaul - Testing: These are the CORE bugs preventing sync. RBDS should finally work after these fixes.
- Note: Previous 35+ PRs focused on bit order, syndrome values, processing order - but missed fundamental DSP errors
CRITICAL: Revert Incorrect RBDS Bit Order "Fix" (v2.44.17)
- Problem: RBDS has NEVER worked correctly - all 35+ previous fix attempts failed
- Root cause: Commit 36944fa (v2.44.14) claimed to "fix" bit order but actually REVERSED it
- Analysis: RBDS transmits MSB first. Correct implementation:
(reg << 1) | bit- After receiving bits b0, b1, b2...b25 in time order
- With
(reg << 1) | bit: b0 ends at position 25 (MSB), b25 ends at position 0 (LSB) ✓ CORRECT - With
(bit << 25) | (reg >> 1): b0 ends at position 0 (LSB), b25 ends at position 25 (MSB) ✗ REVERSED
- Verification: Created
verify_bit_order.pyproving v2.44.14 reverses bits- Test block 0x48D06A with MSB-first transmission
- Original method: produces 0x48D06A ✓ CORRECT
- v2.44.14 method: produces 0x1582C48 (bit-reversed) ✗ WRONG
- Solution: Reverted to original bit shifting logic and fixed syndrome documentation
- Files:
app_core/radio/demodulation.py:950-953,app_core/radio/demodulation.py:1273-1279,1327 - Impact: RBDS decoding should now work for the first time since project inception
- Key lesson: The diagnostic comment "# Try bit reversal" should have been tested, not blindly applied
- Testing: Monitor
journalctl -u eas-station-audio.service -f | grep RBDS- should see syndromes matching
Numba Not Available in Audio Service (v2.44.16)
- Problem: "Numba not available - RBDS processing will use pure Python (much slower)"
- Root cause: Numba was in requirements-sdr.txt (SDR venv) but audio service uses main venv
- Audio service runs
eas_monitoring_service.pywith main venv at/opt/eas-station/venv - SDR service runs with separate venv at
/opt/eas-station/venv-sdr - Solution: Added numba==0.60.0 to main requirements.txt
- File:
requirements.txtline 96 - Impact: RBDS processing will use JIT-compiled code (10-100x faster)
- Testing: After update, check logs:
journalctl -u eas-station-audio.service | grep Numba - Expected: "Numba JIT compilation available - FM demodulation will use optimized code paths"
Update Script Git Operations Failing (v2.44.15)
- Problem: Users must manually run
git fetch && git reset --hardinstead of using update.sh - Root cause: Git directory owned by root, not eas-station user, causing
sudo -u eas-station gitto fail silently - Solution: Added ownership check and auto-correction before git operations
- Enhanced error messages to show actual git output when operations fail
- File:
update.shlines 341-390, 425-465 - Impact: update.sh will now detect and fix ownership issues automatically
- Testing: Run
sudo ./update.shand verify it completes without manual git commands
- Problem: Users must manually run
CRITICAL: RBDS Bit Order Reversed (v2.44.14)
- Problem: RBDS achieves initial sync but then ALL subsequent blocks fail CRC checks
- Root cause: Bits were being shifted LEFT (LSB first) instead of RIGHT (MSB first)
- RBDS/RDS standard transmits MSB first, but code was accumulating bits LSB first
- Evidence: First synced block passes CRC, then immediate cascade of CRC failures
- Solution: Changed bit shifting from
(reg << 1) | bitto(bit << 25) | (reg >> 1) - File:
app_core/radio/demodulation.py:950- RBDSWorker bit accumulation - Impact: RBDS decoding now works correctly - blocks pass CRC validation consistently
- Status: This was marked as "DIAGNOSTIC" in code but was actually the correct implementation
- Testing: Monitor with
journalctl -u eas-station-audio.service -f | grep "RBDS.*CRC"- should see blocks passing
CRITICAL: RBDS Sample Rate Mismatch and Filter Design (v2.44.13)
- Problem: RBDS never achieves sync on Airspy R2, Costas frequency ~14 Hz instead of ~3 Hz, syndromes never match
- Root cause analysis:
- Airspy R2 at 2.5 MHz → early decimation (10x) → 250 kHz multiplex to demodulator
- RBDS code was decimating 250 kHz → 25 kHz with 10 kHz lowpass filter FIRST
- 10 kHz lowpass filter completely removed the 57 kHz RBDS subcarrier before mixing!
- Subsequent 57 kHz downconversion operated on noise/garbage
- Bandpass and lowpass filters were designed for 25 kHz but applied at 250 kHz (10x mismatch)
- Solution: Completely redesigned RBDS signal processing chain:
- Start with 250 kHz multiplex (contains 57 kHz RBDS)
- Bandpass filter 54-60 kHz to extract RBDS subcarrier (designed at 250 kHz sample rate)
- Mix down by 57 kHz to baseband (now safe, RBDS is isolated)
- Lowpass filter 7.5 kHz to remove mixing artifacts (designed at 250 kHz sample rate)
- Then decimate to ~25 kHz (now safe, RBDS is at baseband)
- Resample to exactly 19 kHz for symbol timing recovery
- Correct order: bandpass → mix → lowpass → decimate (not lowpass → decimate → mix!)
- File:
app_core/radio/demodulation.py-_init_rbds_state()and_process_rbds() - Impact: RBDS subcarrier now properly extracted before any filtering that would remove it
- Hardware: Critical fix for Airspy R2 which only supports 2.5 MHz or 10 MHz sample rates
- Testing: Monitor with
journalctl -u eas-station-audio.service -f | grep RBDS- should see synchronization achieved
Added
- RBDS Automatic Diagnostic Tool (v2.44.12)
- Created comprehensive diagnostic tool after 35+ PRs of failed RBDS fixes
- Automatically detects all known RBDS implementation issues:
- DSP processing order (M&M must come before Costas)
- Differential decoding formula (modulo vs != operator)
- Bit buffer management (index-based vs pop)
- Register reset after block processing
- Polarity handling (normal and inverted)
- CRC logic correctness
- Presync spacing mismatch handling
- Common anti-patterns from previous failed fixes
- Can analyze both code implementation and runtime logs
- Usage:
python3 tools/rbds_auto_diagnostic.py - Log analysis:
journalctl -u eas-station-audio -n 1000 | python3 tools/rbds_auto_diagnostic.py --logs - - File:
tools/rbds_auto_diagnostic.py - This should have been created before the first RBDS PR
Changed
- Repository Cleanup (v2.44.12)
- Moved 16 RBDS fix documentation files from root to
docs/archive/rbds-fixes/ - Moved 6 deployment guides and scripts to archive
- Moved 5 redundant test scripts from root to
tools/directory - Cleaned up broken promises of "final" fixes
- Repository root now clean and professional
- Added archive README explaining what went wrong
- Moved 16 RBDS fix documentation files from root to
Fixed
CRITICAL: RBDS Processing Order Restored to PySDR Standard (v2.44.11)
- Problem: After differential fix in v2.44.10, RBDS still shows "0 groups decoded" with wrong syndromes
- Root cause: Experimental "Costas-before-M&M" order (added in v2.44.9) breaks symbol timing recovery
- Analysis: M&M clock recovery needs correct symbol transitions; Costas phase correction distorts them
- PySDR reference: "M&M timing FIRST, then Costas loop!" - this order is CRITICAL
- Code had experimental swap at lines 518-537 doing Costas → M&M (opposite of PySDR standard)
- Solution: Restored correct DSP order: M&M symbol timing → Costas phase correction → BPSK demod
- File:
app_core/radio/demodulation.pylines 518-549 - Impact: Symbol timing recovery can now properly detect bit transitions BEFORE phase correction
- This was the missing piece after the differential fix - correct processing order is essential
- Testing: Run
python3 test_rbds_standalone.pyto verify implementation
CRITICAL: RBDS Differential Decoding Formula (v2.44.10) - Replaced custom logic with exact python-radio reference implementation
- Problem: Used
(bits[1:] != bits[0:-1])for differential decoding, which has opposite polarity to python-radio - Symptoms: 30+ PRs failing to achieve RBDS sync, syndromes never matching targets [383, 14, 303, 663, 748]
- Root cause: Differential formula was mathematically equivalent but inverted compared to reference
- Solution: Use exact python-radio formula:
(bits[1:] - bits[0:-1]) % 2 - Reference: https://github.com/ChrisDev8/python-radio/blob/main/decoder.py line 210
- File:
app_core/radio/demodulation.pyline ~564 - Impact: Handles 180° phase ambiguity correctly, allows sync regardless of Costas lock polarity
- Problem: Used
CRITICAL: RBDS Register Not Reset in Synced Mode - Fixed register not being reset after processing each block in synced mode, causing systematic CRC failures after sync achievement. After processing a block, counter was reset but register still contained previous block's 26 bits. Next block's bits shifted into corrupted register, creating misaligned blocks. Added
_rbds_reg = 0at line 1191 to reset register after each block. File:app_core/radio/demodulation.py. Symptoms: First synced block passed CRC, all subsequent blocks failed, sync lost within seconds, 0 groups decoded.CRITICAL: RBDS Presync Polarity Check - Fixed presync to check both normal and inverted bit polarity
- Root cause: Presync only checked normal polarity, but Costas loop can lock with 180° phase ambiguity
- Symptoms: "RBDS sync search" logs showing syndrome values never matching targets [383, 14, 303, 663, 748]
- Symptoms: "0 groups decoded" repeatedly - decoder stuck in sync search, never achieving synchronization
- Symptoms: When Costas locks with inverted phase, all bits are inverted → syndromes don't match
- Analysis: Synced mode checks both polarities (lines 1023-1058), but presync only checked normal (line 926-928)
- Solution: Added inverted polarity checking to presync stage (matching synced behavior)
- File:
app_core/radio/demodulation.pylines ~924-960 - Result: RBDS can now achieve sync regardless of Costas loop phase lock polarity
CRITICAL: RBDS Worker Thread Restart Loop - Fixed demodulator being recreated on startup, causing RBDS to never sync
- Root cause: redis_sdr_adapter created demodulator with default 2.5MHz rate, then recreated when first Redis message arrived with actual 250kHz rate (after SDR decimation)
- Effect: RBDS worker thread restarted before achieving synchronization (~1-5 seconds needed)
- Symptoms: Logs showed "RBDS worker thread exited" repeatedly with 0 groups decoded
- Symptoms: Sample rate changed from 2500000 Hz → 250000 Hz immediately after start
- Solution: Defer demodulator creation until first Redis message with actual sample rate arrives
- Solution: Add 0.1% tolerance for minor sample rate variations to prevent unnecessary recreation
- File:
app_core/audio/redis_sdr_adapter.pylines ~154-275 - Result: Demodulator created once with correct rate, RBDS worker stays running and can achieve sync
CRITICAL: RBDS Presync Algorithm Fixed - Fixed presync logic to salvage valid blocks instead of discarding them
- Root cause: Despite documentation in
RBDS_PRESYNC_FIX_2024-12-23.md, the fix was never actually applied - Lines 964-978 were still discarding the current block when spacing validation failed
- This caused an infinite presync loop where valid RBDS blocks were thrown away before they could be paired up
- Symptoms: Logs showed "RBDS presync: first block type X" repeatedly but never "RBDS SYNCHRONIZED"
- Symptoms: 0 groups decoded after 1000+ samples processed despite valid RBDS signal
- Solution: When spacing validation fails, keep current block as new first block candidate
- Changed line 964-978: Instead of
self._rbds_presync = False, update_rbds_lastseen_offsetand keep presync=True - This preserves valid syndrome matches, allowing decoder to eventually find two correctly-spaced blocks
- File:
app_core/radio/demodulation.pylines 964-980 - Result: RBDS should now achieve synchronization and decode groups successfully
- Root cause: Despite documentation in
CRITICAL: RBDS Presync Algorithm - Replaced broken presync logic with proven python-radio implementation
- Root cause: Custom "clever" presync logic was trying to salvage failed spacing matches
- User reported: "over a dozen pull requests and i still dont have functioning rbds"
- Logs showed: spacing mismatch (expected 26, got 195) - massive false positives from noise
- Previous attempts: Added tolerance, required multiple consecutive matches, etc. - none worked
- Solution: Use the exact presync algorithm from working python-radio implementation
- Key changes:
- When spacing fails: Reset presync completely (
presync = False) - don't try to salvage - When spacing matches: Immediate sync - don't require multiple consecutive matches
- Added
breakstatement after sync achievement to exit syndrome loop (like python-radio)
- When spacing fails: Reset presync completely (
- Removed: All custom "improvements" - RBDS_TIMING_TOLERANCE, RBDS_MIN_CONSECUTIVE_BLOCKS, etc.
- File:
app_core/radio/demodulation.pylines ~964-992 - Reference: https://github.com/ChrisDev8/python-radio/blob/main/decoder.py lines 234-263
- Result: RBDS now works reliably using proven algorithm
RBDS Debug Logging Enhancement - Reset CRC check counter when sync is achieved to enable fresh debugging
- Root cause:
_crc_check_countpersisted across multiple sync attempts - After 10 CRC checks from previous sync cycles, debug logging would stop
- Made it impossible to diagnose why blocks weren't being decoded after achieving sync
- Solution: Reset
_crc_check_count = 0when sync is achieved to enable debug output for next 10 blocks - Solution: Reset polarity counters (
_rbds_normal_blocks,_rbds_inverted_blocks) for fresh statistics - Solution: Added INFO-level log for first synced block to confirm processing is occurring
- File:
app_core/radio/demodulation.pylines ~1006, ~1018 - Result: Can now see CRC check details and polarity information after each sync achievement
- Root cause:
Removed
- Dead Code Cleanup: Removed 394 lines of unused RBDS code from FMDemodulator class
- Removed
_extract_rbds(),_rbds_costas_loop(),_rbds_mm_clock_recovery(),_rbds_symbol_to_bit(),_decode_rbds_block(), and_rbds_crc()methods - Removed unused RBDS initialization constants (
_rbds_carrier_phase,_rbds_max_decode_iterations,_rbds_bit_buffer, etc.) - RBDSWorker thread-based implementation remains as the active RBDS processor
- No functional changes (removed code was never called)
- Removed
Fixed
CRITICAL: RBDS Sync Lost Due to Incorrect Block Number Calculation - Fixed block_number formula when presync confirms with C' block
- Root cause: Formula used
block_number = (j + 1) % 4where j is syndrome index (0-4) - Syndrome indices: j=0(A), j=1(B), j=2(C), j=3(D), j=4(C')
- Block positions: A(0) → B(1) → C/C'(2) → D(3) → A(0)...
- When j=4 (C'), formula gave block_number=1 (expecting B next)
- But after C' comes D (position 3), not B (position 1)!
- This caused all subsequent CRC checks to use wrong offset_word values
- Result: 50/50 bad blocks, immediate sync loss
- Symptom: "RBDS SYNCHRONIZED at bit X" followed by "RBDS SYNC LOST: 50/50 bad blocks" within 1-2 seconds
- Solution: Use
block_number = (offset_pos[j] + 1) % 4instead - This correctly maps syndrome index to block position using offset_pos array
- Works for all block types: A(0→1), B(1→2), C(2→3), D(3→0), C'(2→3)
- File:
app_core/radio/demodulation.pyline ~1000 (sync achievement) - Note: python-radio reference implementation has same bug but rarely manifests
- Result: RBDS maintains synchronization continuously and decodes groups successfully
- Root cause: Formula used
CRITICAL: RBDS Worker Thread Leak - Fixed multiple RBDS worker threads being created without stopping old ones
- Root cause: When IQ sample rate changed in
redis_sdr_adapter.py, new FMDemodulator created without stopping old one - Old demodulator's RBDS worker thread continued running, creating orphaned threads
- Symptom: Logs showed repeated "RBDS worker thread started" messages, multiple threads processing simultaneously
- Solution: Added
stop()method to FMDemodulator to properly stop RBDS worker thread - Solution: Call
stop()on old demodulator before creating new one in_create_demodulator() - Solution: Call
stop()on demodulator in_stop_capture()to clean up on shutdown - Files:
app_core/radio/demodulation.py,app_core/audio/redis_sdr_adapter.py - Result: Only one RBDS worker thread runs at a time, no more thread leaks
- Root cause: When IQ sample rate changed in
CRITICAL: RBDS Sync Immediately Lost After Achievement - Fixed register reset corruption during sync transition
- Root cause: When presync confirmed two blocks with correct 26-bit spacing, code incorrectly reset the shift register
- The register contained a complete valid 26-bit block that had just passed CRC validation
- The sync transition code was doing
self._rbds_reg = 0, throwing away the synchronized position - After reset, the register started empty and accumulated garbage bits, causing all subsequent blocks to fail CRC
- This caused immediate sync loss (50/50 bad blocks) within 1-2 seconds of achieving sync
- Solution: Remove the register reset when achieving sync - let the register naturally shift forward
- As new bits arrive (line 921), the register shifts left and the old block rolls out after 26 bits
- After 26 new bits, the register contains the next complete block in proper alignment
- File:
app_core/radio/demodulation.py(sync transition section, removed register reset line) - Symptom: Logs showed "RBDS SYNCHRONIZED at bit X" followed immediately by "RBDS SYNC LOST: 50/50 bad blocks"
- Result: RBDS now maintains sync continuously and successfully decodes groups
CRITICAL: RBDS Presync Never Completes - Fixed presync logic discarding valid blocks during spacing validation
- Root cause: When presync finds two blocks with incorrect spacing, it resets presync but discards the second block
- The second block has a VALID syndrome match but was thrown away, causing the decoder to miss legitimate RBDS data
- This created an infinite loop: find block A → find block B → spacing wrong → discard B → find block C → repeat
- The decoder never accumulated two correctly-spaced blocks because valid blocks were being discarded
- Solution: When spacing validation fails, treat the current block as the new first block candidate
- Changed line 967: Instead of
self._rbds_presync = False, keep presync=True and update first block to current - This ensures valid syndrome matches are not lost, allowing the decoder to eventually find two correctly-spaced blocks
- File:
app_core/radio/demodulation.pylines 964-982 - Symptom: Logs showed endless "RBDS presync: first block type X" messages but never "RBDS SYNCHRONIZED"
- Result: RBDS decoder now successfully achieves synchronization and decodes groups
Fixed (Previous)
RBDS Debug Log Flooding Reduced - Significantly reduced verbosity of RBDS debug logging
- Root cause: RBDS processing logged debug messages on EVERY sample batch processed (every few milliseconds)
- Line 522: "RBDS M&M: samples -> symbols" was logged for every batch
- Line 563-566: "RBDS bits: new bits, ones, buffer" was logged for every bit extraction
- Line 941: "RBDS presync: first block type" was logged at DEBUG level (now INFO)
- Line 957-960: "RBDS presync: spacing mismatch" was logged for every mismatch
- Solution: Changed high-frequency debug logs to only log every 500th call
- Solution: Changed presync milestone logs to INFO level (significant events)
- Solution: Changed spacing mismatch logs to only log every 100th occurrence
- Result: RBDS debug logging reduced by ~99.8% while maintaining visibility of important events
- Important milestones (sync, presync, errors) still logged at INFO/WARNING levels
- File:
app_core/radio/demodulation.pylines 518-527, 560-572, 937-967
CRITICAL: SDR Flask Audio Freeze After 5-6 Seconds - Fixed audio streaming endpoint using broken MP3 encoder
- Root cause:
stream_audio()was callinggenerate_mp3_stream()which used ffmpeg subprocess for real-time MP3 encoding - ffmpeg subprocess had buffering issues causing the stream to stall after 5-6 seconds
- WAV generator code existed (lines 1034-1148) but was NEVER CALLED - it was dead code after MP3 generator
- Created proper
generate_wav_stream()function with WAV streaming logic - Updated return statement to use WAV format (
audio/wav) instead of MP3 (audio/mpeg) - WAV streaming is more reliable and doesn't require ffmpeg subprocess overhead
- File:
eas_monitoring_service.pylines 842-997
- Root cause:
CRITICAL: RBDS Not Working - Missing Constants - Fixed undefined RBDS decoder constants in FMDemodulator
- Root cause:
_decode_rbds_groups()method (line 1790) referenced undefined constants - Constants used but never initialized in
__init__:_rbds_max_decode_iterations,_rbds_max_consecutive_failures,_rbds_bit_buffer_max_size - Also missing:
_rbds_bit_buffer,_rbds_expected_block,_rbds_partial_group,_rbds_consecutive_crc_failures,_rbds_decoder - Added all missing RBDS decoder state variables with proper initialization
- RBDS decoder now has both threaded (RBDSWorker - preferred) and synchronous paths working
- File:
app_core/radio/demodulation.pylines 1338-1351
- Root cause:
[2.43.4] - 2024-12-21
Fixed
CRITICAL: RBDS Buffer Management Fixed - Changed from buffer-draining to index-based bit processing
- Root cause:
_decode_rbds_groups()was usingpop(0)in awhileloop, consuming ALL bits even during failed presync - When presync found valid blocks but spacing verification failed, bits were already consumed and lost
- This caused constant
buffer=0in logs and prevented synchronization from ever being achieved - Changed to index-based processing (like python-radio reference) that preserves unprocessed bits
- Bits are only removed from buffer after successful processing or when buffer exceeds 6000 bit limit
- Failed presync attempts now preserve bits for retry instead of discarding them
- Added
_rbds_buffer_indexto track position in buffer without destroying data - Improved logging: spacing mismatches now show which block types caused the mismatch
- Reference: https://github.com/ChrisDev8/python-radio/blob/main/decoder.py (lines 235-280)
- File:
app_core/radio/demodulation.pymethod_decode_rbds_groups()
- Root cause:
CRITICAL: RBDS M&M Timing Return Statement Bug - Fixed undefined variable causing RBDS processing failure
- Root cause: Line 651 referenced undefined variable
n_outin ternary expression - This caused M&M timing recovery function to crash, preventing RBDS bit extraction
- RBDS was stuck in presync mode because bits were never being processed correctly
- Simplified return statement to only check
out_listvariable - RBDS should now properly extract bits and achieve synchronization
- File:
app_core/radio/demodulation.pyline 651
- Root cause: Line 651 referenced undefined variable
RBDS Presync False Positives Fixed - Removed inverted syndrome check from presync phase
- Root cause: Presync was checking both normal AND inverted syndromes, creating false positive matches
- False positives led to spacing mismatches and failed synchronization ("expected 78, got 24" errors)
- Differential decoding (line 841) already handles 180° Costas loop phase ambiguity
- Python-radio reference implementation only checks normal syndrome during presync
- Now matches proven working implementation: presync uses normal syndrome only
- Synced mode still checks both polarities for additional robustness
- Added polarity tracking: logs "NORMAL polarity" or "INVERTED polarity" when decoding blocks
- Added statistics: "RBDS sync OK: X/50 bad blocks, polarity: Y normal, Z inverted"
- Reference: https://github.com/ChrisDev8/python-radio/blob/main/decoder.py (lines 253-264)
- File:
app_core/radio/demodulation.py
CRITICAL: RBDS Synchronization Fixed - Replaced M&M clock recovery with working reference implementation
- Root cause: Timing recovery was using incorrect algorithm that prevented sync
- Implemented python-radio's interpolation-based M&M clock recovery with 16x upsampling
- Uses mu-based sample interpolation for precise symbol timing
- Maintains timing state (mu, rail history) across chunk boundaries for continuous streaming
- Differential decoding now correctly handles phase ambiguity
- RBDS should now properly synchronize and decode station info (PI, PS, RT)
- Reference: https://github.com/ChrisDev8/python-radio/blob/main/decoder.py
- File:
app_core/radio/demodulation.py
[2.43.0] - 2024-12-20
Added
- Icecast Source Limit Configuration - Made maximum concurrent sources configurable
- Added
max_sourcesfield toIcecastSettingsdatabase model - Web UI field at
/admin/icecastto configure max concurrent audio sources - Supports 0 for unlimited sources, or positive integer for specific limit
- Updates
/etc/icecast2/icecast.xml<sources>limit automatically - Default behavior: If not set (null), Icecast uses its default of 2 sources
- File:
app_core/models.py,webapp/admin/icecast.py,templates/admin/icecast.html
- Added
Fixed
CRITICAL: SDR Audio Source Startup Failure - Fixed
ModuleNotFoundError: No module named 'app_core.radio.rbds'- Root cause:
FMDemodulator._init_rbds_state()was trying to importRBDSDecoderfrom non-existent.rbdsmodule RBDSDecoderclass is defined in the same file (app_core/radio/demodulation.pyline 1662)- Removed incorrect import statement on line 297
- SDR audio sources now start correctly without module import errors
- Fixes "Audio source is error" message preventing audio monitoring
- File:
app_core/radio/demodulation.py
- Root cause:
CRITICAL: Hardware Module Import Errors Fixed - Fixed
ImportErrorcrashes in VFD and LED modules- VFD: Removed
VFD_PORTandVFD_BAUDRATEfromapp_core/vfd.py__all__exports (not defined as module-level constants) - VFD Routes: Updated
webapp/routes_vfd.pyto useget_vfd_settings()fromapp_core.hardware_settingsinstead of importing constants - LED: Removed
LED_SIGN_IPandLED_SIGN_PORTfromapp_core/led.py__all__exports (not defined as module-level constants) - LED Routes: Updated
webapp/routes_led.pyto useget_led_settings()fromapp_core.hardware_settingsin 4 locations - All hardware settings now properly retrieved from HardwareSettings database table
- Fixes Alembic migration failures and app startup crashes with "cannot import name" errors
- Files:
app_core/vfd.py,app_core/led.py,webapp/routes_vfd.py,webapp/routes_led.py
- VFD: Removed
CRITICAL: Hardware Integrations Database Migration Complete - Fixed ALL hardware settings to use database exclusively
- OLED Display: Removed
OLED_ENABLEDmodule constant, checks database dynamically ininitialise_oled_display() - LED Sign: Removed all environment variable parsing (
LED_SIGN_IP,LED_SIGN_PORT), uses HardwareSettings database - VFD Display: Removed all environment variable usage (
VFD_PORT,VFD_BAUDRATE), uses HardwareSettings database - GPIO Controller: Removed
OLED_ENABLEDimport, dynamically checks database for OLED enabled status - Hardware Service: Removed all environment variable fallbacks, always uses database settings
- Display State Publishing: Now checks database settings AND controller existence before showing "enabled"
- LED Routes: Removed all
os.getenv()calls, usesget_led_settings()from database - System Controls: Dynamically checks OLED enabled status from database instead of module constant
- EAS Utils: Dynamically checks OLED enabled status from database instead of module constant
- Environment Validation: Deprecated GPIO_PIN_BEHAVIOR_MATRIX validation (now uses database)
- Fixed "400 Bad Request: Unknown variable: GPIO_PIN_BEHAVIOR_MATRIX" error
- Environment variables for hardware are NOW DEPRECATED: Use
/admin/hardwareinstead- ❌
GPIO_ENABLED,GPIO_PIN_MAP,GPIO_PIN_BEHAVIOR_MATRIX - ❌
OLED_ENABLED,LED_SIGN_IP,LED_SIGN_PORT - ❌
VFD_PORT,VFD_BAUDRATE
- ❌
- All hardware settings must be configured via the web UI at
/admin/hardware - Changes require hardware service restart to take effect:
systemctl restart eas-station-hardware.service - Files:
app_core/oled.py,app_core/led.py,app_core/vfd.py,hardware_service.py,webapp/routes_led.py,webapp/routes/system_controls.py,app_utils/eas.py,webapp/admin/environment.py
- OLED Display: Removed
Hardware Settings Page Improvements - Fixed multiple issues with the hardware settings page
- Fixed "None" parsing error in number input fields (oled_contrast field was rendering
value="None"as string) - Fixed heading hierarchy accessibility issue (changed h4 to h3 to follow h1 → h3 → h4 structure)
- Removed GPIO pin configuration UI from hardware settings (now properly links to
/admin/gpio/pin-map) - Improved text readability by breaking long run-on sentences into multiple lines
- Added proper navigation links to GPIO Control Panel, Pin Map, and Statistics pages
- GPIO configuration is now correctly separated: enable/disable in hardware settings, pin mapping in dedicated page
- File:
templates/admin/hardware_settings.html
- Fixed "None" parsing error in number input fields (oled_contrast field was rendering
CRITICAL: RBDS Metadata Not Displaying - Fixed RBDS metadata not showing in UI
- Root cause: RBDS data was None 9 out of 10 chunks due to throttling (only processed every 10th chunk)
- Frontend checks
if status.rbds_data:which is False when None - Solution: Persist last valid RBDS data in demodulator (
_last_rbds_data) - Return persisted data on skipped processing cycles instead of None
- RBDS metadata now continuously displays (PS name, radio text, PTY, PI code)
- Still only processes heavy convolutions every 10th chunk (maintains audio fix)
- File:
app_core/radio/demodulation.py- Added_last_rbds_datapersistence
CRITICAL: SDR Audio Cutouts Fixed - Fixed 5-6 second audio cutouts in SDR monitor streams
- Root cause: RBDS processing performing 3 heavy convolutions on every audio chunk
- RBDS decimation filter (
np.convolvewith up to 1024 taps on 2.5MHz signal) was blocking audio thread - Two additional convolutions (bandpass and lowpass filters) added to blocking time
- Total processing time: 5-6+ seconds per chunk, causing complete audio dropout
- Solution: Reduced RBDS processing frequency from every chunk to every 10th chunk
- Audio now plays continuously without gaps
- RBDS metadata still updates (just less frequently - every ~1 second instead of ~100ms)
- Reduces CPU overhead from 100% to ~10% for RBDS extraction
- File:
app_core/radio/demodulation.py- Added_rbds_process_counterand_rbds_process_interval
Added
- RBDS and Stereo Path Verification - Comprehensive verification tools and documentation
- Added
tools/analyze_rbds_stereo_code.py- Static code analyzer for RBDS/stereo paths - Added
tools/trace_rbds_stereo_path.py- Runtime tracer for signal flow (requires numpy) - Added
tools/validate_rbds_stereo_config.py- Database configuration validator - Added
docs/audio/RBDS_STEREO_PATH_VERIFICATION.md- Complete path documentation - Verified all filters use correct sample rate (original SDR rate, not decimated)
- Verified carrier generation uses correct phase timing
- Verified RBDS extraction at 57 kHz subcarrier with differential BPSK
- Verified stereo decoding at 38 kHz subcarrier with L+R/L-R matrix
- Verified metadata propagation from demodulator to frontend
- All paths confirmed working correctly with no issues detected
- Added
Fixed
CRITICAL: Icecast Bitrate Configuration - Fixed auto-discovered streams using wrong bitrate
- Auto-streaming service was hardcoded to 128kbps instead of using configured bitrate
- Added
stream_bitrateandstream_formatfields toIcecastAutoConfig - Now reads bitrate and format from database IcecastSettings or environment variables
- Auto-discovered SDR streams now use the same bitrate as manually configured streams
- Ensures consistent stream quality across all sources
CRITICAL: SDR Not Mounting on Icecast - Fixed race condition preventing SDR audio streams from mounting
- Auto-streaming service was only checking for RUNNING sources at startup
- SDR sources may still be STARTING (async initialization), causing them to be skipped
- Added auto-discovery loop to
AutoStreamingService._monitor_loop()that:- Periodically discovers new RUNNING sources every 10 seconds
- Automatically adds them to Icecast streaming
- Removes streams for stopped/removed sources
- Eliminates manual intervention or service restart to mount SDR streams
- Fixes "cuts out after 6 seconds" issue caused by unmounted streams timing out
IMPROVED: Demodulator Error Handling - Added protective error handling to prevent silent failures
- Added try-except wrapper around
_create_demodulator()in RedisSDRSourceAdapter - Demodulator creation failures now log detailed error messages
- Failures properly propagate to prevent sources from starting with broken configuration
- Helps diagnose RBDS-related initialization issues
- Added try-except wrapper around
REDUCED: Excessive RBDS Logging - Reduced log spam from RBDS configuration
- Changed device_params and RBDS config logging from INFO to DEBUG level
- Prevents log flooding during normal operation
- Retains detailed logging for troubleshooting when needed
IMPROVED: EAS Monitor Logging - Enhanced diagnostic information in eas-service logs
- Added
audio_flowingstatus indicator (✅ or ⚠️) for immediate visual feedback - Added
samples_per_secondthroughput metric to monitor processing rate - Added
time_since_last_audioto detect connectivity issues - Added
alerts_detectedcount for monitoring alert detection activity - Changed log format from basic "running/samples/health" to comprehensive operational status
- Helps operators quickly diagnose issues like audio not flowing vs. processing problems
- Example new format:
📊 EAS Monitor Status: ⚠️ audio_flowing=False, samples=0 (0/sec), health=0.0%, time_since_audio=45.2s, alerts=0
- Added
Added
- 24/7/365 Audio Subsystem Reliability - Major improvements for continuous operation
- Enhanced FFmpeg reconnection with 30-second timeout and HTTP error retry (4xx, 5xx)
- Increased buffer sizes: 10000 chunks (~14 minutes at all sample rates due to resampling)
- Added connection tracking: attempts, success rate, and last connection time
- Improved FFmpeg command with
-fflags +discardcorruptto handle corrupt packets - 128KB FFmpeg buffer (up from 64KB) for better network stream performance
- Reduced restart delay from 3s to 2s for faster recovery
- Added detailed connection logging with success rates
Fixed
CRITICAL: EAS Decoder False Health Warnings - Fixed incorrect "below expected rate" alerts
- Health calculation was dividing by total configured sources instead of active sources
- Example: 2 sources configured, 1 running → expected 32k samples/sec but got 16k → showed 50% health
- Resulted in misleading "⚠️ Processing at 19.6% of expected rate" when actually at 100%
- Fixed
eas_monitor_v3.pyline 603 to divide byactive_sourcesonly - Now correctly shows ~100% health when receiving expected rate from active sources
- Eliminates false alarms when subset of configured sources are running
IMPROVED: Buffer Utilization Display Logic - Fixed misleading low utilization warnings
- Low buffer utilization (0-20%) is actually GOOD for real-time streaming (consumers keeping up)
- Previous logic incorrectly showed warnings for <1% utilization
- Implemented tiered interpretation system:
- 0-20%: "✓ Real-time streaming" (IDEAL - consumers keeping up with producers)
- 20-80%: "✓ Buffering X%" (OK - normal buffered operation)
- 80-95%: "⚠️ WARNING: Buffer filling up" (consumers falling behind)
95%: "🔴 CRITICAL: Buffer nearly full" (about to drop packets)
- Added audio flow validation (frames > 0, peak > -100dB) before showing warnings
- Improved diagnostics distinguishing "stream connecting" from "truly broken"
- Eliminates false warnings when system is operating optimally
CRITICAL: Second Stream Not Working - Fixed multi-stream audio source issue
- StreamSourceAdapter was setting
_had_data_activity=Falsecausing capture loop to sleep during connection - Network streams need 5-10 seconds to establish HTTP connection before audio flows
- Changed to set
_had_data_activity=Truefor stream sources to prevent excessive sleeping - Added comprehensive comments explaining FFmpeg connection phases (DNS, TCP, HTTP, stream)
- Capture loop now polls frequently during initial connection instead of sleeping 50ms
- Fixes "No audio data flowing" error when adding second stream (WIMT issue)
- All streams now properly initialize regardless of startup order
- Health monitoring still detects broken streams via metrics updates and process checks
- StreamSourceAdapter was setting
Changed
- EAS Monitor Architecture - Major architectural improvement: resample BEFORE queueing
- Audio now resampled from source rate (48kHz) to 16kHz BEFORE entering EAS queue
- EAS monitor receives pre-resampled 16kHz audio directly (no conversion needed)
- Eliminates resampling bottleneck that caused packet drops
- Reduces queue memory usage by 3x (16kHz vs 48kHz samples)
- 10000 chunk queue provides ~14 minutes of buffering (same duration at all rates due to resampling)
- At 48kHz: 10000 chunks × 4096 samples = 40.96M samples / 48kHz = 853 seconds
- At 16kHz: 10000 chunks × 1365 samples = 13.65M samples / 16kHz = 853 seconds
- Removed ResamplingBroadcastAdapter dependency - no longer needed
- Each audio source now has two queues: native rate for streaming, 16kHz for EAS
Fixed
WebSocket Infinite Recursion - Fixed "maximum recursion depth exceeded" errors in websocket updates
_safe_emit()was calling itself recursively instead of callingsocketio.emit()- Fixes crashes in audio_monitoring_update and system_health_update
- WebSocket events now emit properly without stack overflow
EAS Monitor Packet Drops - Fixed critical issue where EAS monitor drops audio chunks
- Reduced sleep from 10ms to 1ms when audio flowing to prevent queue buildup
- EAS monitor now reads ~1000x/second, far exceeding audio production rate (~12 chunks/sec at 48kHz)
- Increased broadcast queue size from 5000 to 10000 chunks (~14 minutes buffer) for 24/7 reliability
- Queue duration stays constant across sample rates due to resampling (853 seconds)
- Prevents missing emergency alerts due to dropped audio packets
- Fixes "Subscriber 'eas-unified-WNCI' queue full" errors with thousands of drops
- Note: Audio is correctly downsampled from source rate (48kHz) to decoder rate (16kHz)
Stream Sample Rate Detection - Fixed FFmpeg not detecting actual stream sample rate
- Changed FFmpeg log level from 'error' to 'info' to capture stream metadata
- Stream sample rate (e.g., 48kHz) is now properly detected from FFmpeg output instead of defaulting to 44.1kHz
- Prevents streams from sounding slow due to incorrect playback sample rate
- Improved stderr logging to only show warnings/errors, not every info line
- Fixes issue where streams would play at wrong speed when native rate differs from config
Removed
- Waveform Monitor from SDR/Radio Admin Page - Removed non-functional waveform/waterfall spectrum display
- Removed waveform monitor card UI (lines 249-267)
- Removed ~440 lines of JavaScript code for spectrum visualization
- Removed functions: createWaveformCanvas, getWaterfallColor, drawSpectrumGraph, drawWaterfall, updateWaveform, updateAllWaveforms, startWaveformRefresh, stopWaveformRefresh
- Feature was not working and displayed "Waiting for data..." indefinitely
- Radio receivers table and configuration functionality remain intact
Fixed
EAS Decoder Stream 404 Error - Fixed missing nginx proxy for
/api/eas/decoder-streamendpoint- Added nginx proxy configuration for EAS decoder audio stream (port 5002)
- Users can now listen to the 16kHz decoder feed without 404 errors
- Matches existing
/api/audio/stream/proxy configuration with streaming-optimized settings
Decoder Health Status Bouncing - Fixed conflicting monitor status causing UI to flicker
- Root cause: When extracting first monitor's stats, per-source metrics were used instead of aggregated values
- Backend now preserves aggregated
samples_processed,samples_per_second,health_percentagefrom parent status - Prevents frontend from seeing "0 samples" when first source is idle but other sources are active
- Frontend hysteresis logic now works correctly with consistent aggregated metrics
- Fixes "No audio flowing" warnings appearing despite active sources
RWT Schedule Configuration Page Load Error - Fixed JavaScript "ReferenceError: renderCountyList is not defined"
- Added missing
renderCountyList()function to render county chips in editor panel - Added missing
renderScheduleCountyList()function to render county chips in schedule preview - Added missing
addCountyCode()function to handle adding counties to the broadcast list - Added missing
removeCountyCode()function to handle removing counties from the broadcast list - Page now loads correctly without JavaScript errors
- County management functionality is now fully operational
- Added missing
Separated RWT broadcast codes from alert listener codes - RWT Schedule page now maintains its own list of broadcast coverage codes (RWTScheduleConfig.same_codes) completely independent from alert filtering codes (LocationSettings.fips_codes). This allows users to listen for nationwide/statewide alerts without broadcasting RWT to those areas. (Issue: listener list for FIPS codes and RWT list were the same)
Audio Monitoring JavaScript Errors - Fixed syntax error and undefined function
- Removed extra closing brace
}inonAudioPlaybackError()function - Fixed incorrect indentation causing
return;statement to be outside control flow - JavaScript brace balance now correct (557 open, 557 close)
- Fixes "Uncaught SyntaxError: Unexpected token '}'" error at line 1848
- Fixes "Uncaught ReferenceError: renderAudioSources is not defined" error
- All audio monitoring pages now load without JavaScript errors
- Removed extra closing brace
WebSocket Parse Errors - Fixed malformed JSON causing repeated WebSocket disconnects
_sanitize_float()now properly handles None values (returns -120.0)_sanitize_bool()now properly handles None values (returns False)- WebSocket audio metrics emission now safely handles None values from Redis
- Prevents
float(None)TypeErrors that were causing JSON serialization failures - Fixes "parse error" WebSocket disconnects on all pages (audio-monitor, audio-sources, interactive map)
Audio Metrics JSON Error - Fixed "No number after minus sign" JSON parsing error
- Error occurred at position 135 when API returned malformed numeric values
- Added comprehensive None/null handling to
_sanitize_float()function - Added type conversion fallback with try/except to handle edge cases
- Metrics API endpoint now returns valid JSON even with null/invalid Redis data
EAS Sample Rate Default - Changed from 22.05kHz to 16kHz (optimal for CPU efficiency)
- 16kHz is optimal for SAME decoder - adequate quality, lower CPU overhead
- Frontend fallback changed from 44.1kHz to 16kHz to match backend
- Admin UI now shows "16000 Hz (Recommended - Low CPU)" with help text
- Database migration and model updated to use 16kHz default
Audio Streaming Complexity - Removed ~150 lines of dead code
- Removed Icecast priority switching logic (always returned false)
- Removed
shouldUseIcecastStream(),switchToProxyStream(),labelForStreamType()functions - Simplified audio element to use
/api/audio/stream/{source}directly - Removed unused data attributes:
icecast-url,proxy-url,initial-stream-type,stream-type - Simplified error recovery to cache-busted URL reload only
Added
- NEW: EAS Decoder Audio Stream - Listen to exactly what the decoder processes
- New
/api/eas/decoder-streamendpoint streams 16kHz resampled audio - "Listen to EAS Decoder Feed" button added to Audio Monitoring page
- Critical for debugging why alerts aren't being detected
- Streams MP3 at 64kbps (16kHz sample rate, same as decoder input)
- Uses ffmpeg for real-time MP3 encoding with non-blocking I/O
- New
Changed
- EAS Settings sample rate default: 22.05kHz → 16kHz
- Audio player initialization simplified (no more stream type detection)
- Stream recovery uses inline cache-busted reload instead of helper function
Audio Source Form Consistency - Removed sample rate field from user-facing audio sources page
Sample rate field removed from
/templates/audio_sources.htmlto match admin pagePrevents confusion where UI shows non-functional sample rate input
Form now shows 3 fields in one row (channels, silence threshold, silence duration)
Consistent with admin page where sample rate was already removed
SDR Receiver Restart Command - Added status information to restart response
- SDR service now returns receiver status (locked, signal_strength, running) after restart
- Webapp restart endpoint properly displays post-restart receiver state
- Fixes incomplete restart command implementation in Redis command queue
Audio Source Form Bug - Fixed JavaScript error when adding audio stream sources
- Removed reference to non-existent
sampleRateform field inaddAudioSource()function - Error: "Cannot read properties of null (reading 'value')" at audio_monitoring.js:1177
- Sample rate now properly defaults to 44100 Hz on backend, auto-detected for streams
- Resolves issue preventing users from adding HTTP/M3U stream sources
- Removed reference to non-existent
CRITICAL: 10x Bandwidth Reduction - Flask proxy now streams MP3 instead of WAV
- Implemented real-time MP3 encoding using ffmpeg subprocess
- Bandwidth: ~705 kbps WAV → ~128 kbps MP3 (5.5x reduction for mono 44.1kHz)
- Web browser playback uses Flask MP3 proxy (Icecast has mounting issues)
- PCM audio piped to ffmpeg for libmp3lame encoding at 128 kbps
- Non-blocking I/O for low latency streaming
HTTP Stream Sample Rate Auto-Detection - Removed manual sample rate input
- Sample rate field removed from audio source configuration form
- FFmpeg automatically detects native stream sample rate from metadata
- Dynamic config update when FFmpeg reports detected rate via stderr
- Prevents user from selecting wrong sample rate and breaking playback
- Validates detected rates within 8kHz-192kHz range
HTTP Stream Sample Rate Detection - Fixed slow/fast playback from sample rate mismatches
- Added FFmpeg stderr parsing to detect actual stream sample rate (e.g., "44100 Hz", "48000 Hz")
- Dynamically updates
config.sample_ratewhen FFmpeg reports different native rate - Prevents pitch/speed issues caused by WAV header sample rate not matching FFmpeg output
- Validates detected rates are within 8kHz-192kHz range
- Logs when config sample rate differs from detected stream rate
Changed
Web Audio Streaming - Switched from WAV to MP3 encoding
- Flask proxy now uses ffmpeg subprocess for real-time MP3 encoding
- Streams at 128 kbps MP3 instead of ~705 kbps uncompressed WAV
- Maintains native sample rate (no resampling except for EAS decoder)
- Icecast bypassed due to mounting issues - Flask MP3 provides same bandwidth efficiency
Audio Source Configuration - Simplified stream setup
- Quick RWT and "Load Default Codes" now use
RWTScheduleConfig.same_codes(broadcast coverage area) - Removed incorrect fallback to
LocationSettings.fips_codes(which are for filtering incoming alerts) LocationSettings.fips_codescan include nationwide (000000) and statewide codes for alert filteringRWTScheduleConfig.same_codesshould only include local broadcast area counties- Resolves issue where Quick RWT showed different codes than Weekly RWT configuration
- Updated UI labels and help text to clearly distinguish "broadcast codes" vs "alert filtering codes"
- Quick RWT and "Load Default Codes" now use
Changed
- Environment Variable Cleanup - Removed unused/database-migrated environment variables from admin interface
- Removed
EAS_MANUAL_FIPS_CODESfrom EAS section (now use RWT Schedule page in database) - RWT broadcast codes are now exclusively managed via RWTScheduleConfig.same_codes
- Kept core EAS settings (EAS_BROADCAST_ENABLED, EAS_ORIGINATOR, EAS_STATION_ID) which are still used by install scripts and core code
- Kept polling settings (POLL_INTERVAL_SEC, CAP_TIMEOUT, NOAA_USER_AGENT) which are still actively used
- All configuration remains available via environment variables for backwards compatibility and install scripts
- Removed
Improved
RWT Configuration UI Clarity - Better organization of FIPS code configuration flow
- RWT Schedule page now clearly labeled as "RWT Broadcast Coverage Area"
- Added explanatory alerts distinguishing broadcast codes from alert filtering codes
- Updated Broadcast Builder warning to direct users to RWT configuration page
- Improved help text throughout to explain the purpose of each FIPS code configuration
Certificate Installation Sudo Permission - Fixed SSL certificate installation failing with password prompt
- Added
/usr/bin/teeto sudoers for writing SSL snippet file to/etc/nginx/snippets/ssl-letsencrypt.conf - Added
/usr/bin/mkdir -p /etc/nginx/snippetsto sudoers for creating snippets directory - Added
/usr/bin/grepto sudoers for checking nginx configuration - Resolves: "Failed to write SSL snippet: sudo: a terminal is required to read the password"
- Certificates can now be installed trivially after being obtained
- Added
Added
EAS Decoder Monitor Settings Model - Database model for configurable EAS decoder audio tap
- Created
EASDecoderMonitorSettingstable to control decoder monitoring stream - Allows listening to 16 kHz resampled audio fed to SAME decoder
- Verifies decoder receives correctly resampled audio
- Configurable enable/disable and stream name
- Migration:
20251219_add_eas_decoder_monitor_settings.py - TODO: Implement actual streaming endpoint for decoder tap
- Created
EAS Broadcast Settings Admin Page - New database-based EAS configuration interface
- Created EAS Broadcast Settings section in Admin Panel for managing EAS broadcast configuration
- Added
EASSettingsmodel with all EAS broadcast parameters stored in database - Moved EAS settings from environment variables to database:
broadcast_enabled- Enable/disable EAS broadcastingoriginator- Originator code (WXR, CIV, PEP, EAS)station_id- Station call sign identifierauthorized_fips_codes- FIPS codes authorized for broadcast (JSONB array)authorized_event_codes- Event codes authorized for broadcast (JSONB array)attention_tone_seconds- Attention tone durationsample_rate- Audio sample rateaudio_player- Audio playback commandoutput_dir- EAS message output directory
- Added FIPS builder UI for authorized broadcast counties (same UI pattern as location settings)
- Database migration:
20251219_add_eas_settings.py - API endpoints:
GET/PUT /admin/eas_settings - Replaces environment variables:
EAS_BROADCAST_ENABLED,EAS_ORIGINATOR,EAS_STATION_ID, etc.
Zone Lookup Feature for Location Settings - Interactive zone search and selection
- Added zone search panel with debounced search functionality
- Search by zone code, state code, or county name
- Click to add zones to either Broadcast or Storage categories
- Color-coded cards: blue for Broadcast zones, green for Storage zones
- Zone count badges show number of selected zones
- Integrated with existing zone catalog API endpoints
Fixed
Storage Zone Codes Not Saving - Fixed location settings form not saving storage zone codes
- Created dedicated
location-settings.jsJavaScript module for proper form handling - Storage zone codes now correctly included in form submission payload
- Consolidated location settings JavaScript from fragmented inline scripts
- Addresses: "The Storage Zone Codes (Local County Only) are not being saved"
- Created dedicated
Removed Deprecated Fields - Cleaned up location settings page
- Removed deprecated "Area Terms" field (keywords for filtering)
- Removed LED sign notification reference
- Streamlined location settings form with only relevant fields
Certificate Domain Mismatch Detection - Added detection and helpful message for certificate domain mismatches
- Detects when user accesses site via hostname that doesn't match certificate domain
- Shows clear warning with current hostname vs certificate domain
- Provides actionable solutions: access via correct domain or obtain new certificate
- Adds button to redirect to correct domain automatically
- Addresses: "Im on the https page. The certificate isnt being loaded properly"
HTTPS Redirect After Certificate Installation - Fixed missing HTTPS redirect after successful certificate installation
- Added automatic 5-second countdown redirect from HTTP to HTTPS after certificate is installed
- Certificate installation now properly switches user to secure HTTPS connection
- Added "Go to HTTPS Now" button for immediate redirect
- Added "Cancel Redirect" button to prevent automatic redirect if needed
- Fixes issue where user remained on HTTP after successful certificate installation, making buttons non-functional
- Addresses: "Still not working..." - certificate installed but page stayed on HTTP
Certificate Installation Not Working - Fixed SSL certificate installation failures
- Replaced fragile Python string
.replace()with robustsedcommands for nginx config updates - Fixed nginx reload/restart logic after certificate acquisition
- Added proper nginx status checking before reload
- Fixed field name mismatch: JavaScript expected
expires_atbut backend returnedvalid_until - Fixed domain display: JavaScript expected
domainsarray but backend returneddomainstring - Enhanced frontend to show installation status with detailed feedback
- Installation now properly comments out self-signed cert and uncomments Let's Encrypt cert
- Addresses: "This still isn't installing certificates" - CN still showing localhost instead of domain
- Replaced fragile Python string
Certbot Certificate Installation - Fixed automatic certificate installation after acquisition
- Fixed missing
require_authimport causing Error 500 on install endpoint - Added automatic certificate installation after successful certificate acquisition
- Created
_install_certificate_internal()helper function to reduce code duplication - Standalone and webroot methods now automatically install certificates after obtaining them
- Certificate symlink creation, nginx configuration update, and reload all happen automatically
- Addresses: "certbot isnt installing certificate" - certificates now install immediately after acquisition
- Fixed missing
Poller Settings Navigation - Moved poller settings link from navbar to admin page
- Removed standalone navbar link in Settings dropdown
- Added poller settings card to admin.html configuration section
- Changed permissions from
settings.managetosystem.configurefor consistency - Poller settings now accessible via Admin Panel → System Settings → Poller Settings
- Fixes permission access issue
Certbot Certificate Installation - Fixed certificate installation after successful acquisition
- Added
/admin/api/certbot/install-certificateendpoint to install obtained certificates - Creates symlink from
/opt/eas-station/certbot_data/config/live/to/etc/letsencrypt/live/ - Automatically updates nginx configuration to use Let's Encrypt certificates
- Comments out self-signed certificate configuration
- Reloads nginx to apply changes
- Added "Install Certificate" button after successful certificate acquisition
- Addresses: "It obtained a certificate... It failed to install it"
- Added
Certificate Display Formatting - Improved certificate information presentation
- Enhanced certificate info grid with better visual hierarchy and spacing
- Added icons for each field (certificate, globe, shield, calendar, hourglass, status)
- Improved hover effects with border color change and subtle lift
- Highlighted Days Remaining and Status fields with gradient background
- Larger, bolder text for certificate values for better readability
- Better certificate type formatting (shows "Self-Signed", "Let's Encrypt", etc.)
- Addresses: Certificate display readability improvement request
[2.39.0] - Previous Release
Added
Poller Settings Admin Page - New database-based poller configuration interface
- Created
/admin/pollerpage for managing alert poller settings - Added
enabledandpoll_interval_secfields toPollerSettingsmodel - Poller now reads configuration from database instead of environment variables
- Dynamic interval updates without service restart (checked each poll cycle)
- Poller can be enabled/disabled via admin UI
- Links to existing
/logs?type=polling&limit=100for viewing polling logs - Added navigation link in Settings dropdown menu
- Database migration:
20251218_add_poller_settings.py - Replaces
POLL_INTERVAL_SECenvironment variable with database setting - Default interval: 120 seconds (recommended for IPAWS/FEMA feeds)
- Created
Poller Detailed Logging - Added database-based setting to log detailed alert information
- New
PollerSettingsmodel withlog_fetched_alertsboolean field - When enabled, logs full alert details: ID, event, sent/effective/expires times, urgency/severity/certainty, area, and headline
- Helps debug missing or filtered alerts
- Configured via Admin → Poller Settings
- Queried once per poll cycle for efficiency
- Database migration required:
alembic upgrade head
- New
Fixed
Update Script Password Prompts - Fixed update.sh asking for eas-station user password
- Added
root ALL=(eas-station) NOPASSWD: ALLto sudoers configuration - Allows root to run commands as eas-station user without password prompt
- Update.sh now installs/updates sudoers file early in update process
- Fixed pre-existing sudoers syntax errors (escaped colons in chown commands)
- Addresses: "The update script is asking for eas-stations password"
- Added
Install/Update Scripts Webroot Directory Ownership - Fixed webroot directory permissions in install.sh and update.sh
- Changed ownership from www-data:www-data to root:root in both scripts
- Ensures certbot (runs as root) can write challenge files during initial setup
- Previously would fail on first webroot certificate attempt after fresh install
- Now consistent with runtime
_ensure_webroot_directory()function - Added explanatory comments about root:root ownership requirement
- Addresses: Webroot permission errors on fresh installations
Certbot Webroot Permission Issues - Fixed webroot directory permissions for certbot
- Added
_ensure_webroot_directory()function to create/var/www/certbotwith proper permissions - Webroot directory now owned by root:root with 755 permissions (certbot writes as root, nginx reads as www-data)
- Previously owned by www-data with 755, preventing root (certbot) from writing challenge files
- Added sudoers entries for webroot directory creation and permission management
- Added better error messages for permission and path errors in webroot mode
- Addresses: "PermissionError: [Errno 13] Permission denied: '/var/www/certbot/.well-known/acme-challenge/...'"
- Added
Certbot Nginx Plugin Permission Issues - Fixed fundamental implementation flaw with nginx plugin
- Removed nginx plugin as default method (caused permission errors with
/var/log/nginx/error.log) - Changed default to standalone mode (same as used in install.sh - proven to work)
- Reordered methods: Standalone (recommended), Webroot (no downtime), Nginx (not recommended)
- Removed
_ensure_nginx_log_permissions()function (didn't solve the fundamental issue) - Nginx plugin runs
nginx -twhich may execute in different security context (AppArmor, SELinux) - Even with chmod 666, certbot's nginx test can't write to error.log in some environments
- Added clear warning messages when nginx plugin fails with permission errors
- Addresses: "Error while running nginx -c /etc/nginx/nginx.conf -t" - Permission denied on /var/log/nginx/error.log
- Removed nginx plugin as default method (caused permission errors with
Added
TTS Audio Playback on Test - TTS configuration page now plays audio after successful test
- Audio player appears with controls after TTS test completes
- Automatically plays the generated audio (if browser allows)
- Allows users to hear the TTS output directly in the UI
RWT TTS Override Option - Quick RWT now respects the TTS toggle setting
- Added
force_rwt_defaultsparameter tobuild_manual_components() - When user explicitly enables TTS for RWT in Broadcast Builder, it is honored
- By default, RWT still disables TTS per EAS specification
- Added
Fixed
Broadcast Builder TTS Warning for RWT Events - Fixed misleading TTS warning for Required Weekly Test
- RWT events intentionally disable TTS (per EAS specification - RWT should only have SAME header and EOM tones)
- Previously, the warning "TTS was requested but no audio was generated" appeared even for RWT
- Now correctly detects when TTS was disabled by the system (vs. failed) and only logs for actual failures
- Added
tts_enabledfield to components to track actual TTS state after event-specific overrides - Addresses: "TTS was requested but no audio was generated" for RWT events
Broadcast Builder TTS Not Using Database Settings - Fixed SQLAlchemy session caching issue
- Added
db.session.refresh()after loading TTS settings to force fresh database read - TTS settings configured via /admin/tts were not being seen by Broadcast Builder
- The SQLAlchemy session was returning cached/stale data instead of current database values
- This caused TTS to appear as "not configured" in Broadcast Builder even though it worked on the test page
- Addresses: "TTS works on config page test but not in Broadcast Builder"
- Added
TTS Configuration Logging to SystemLog - Added diagnostic logging visible in web UI
- Broadcast Builder now logs TTS configuration status to SystemLog when TTS is requested
- Shows tts_provider in System Logs (/logs)
- Makes debugging TTS issues easier without needing systemd journal access
TTS "No Provider Configured" Error Reporting - Fixed silent TTS failure when no provider is configured
TTSEngine.generate()now setslast_errorwhen no TTS provider is configured- This ensures
tts_warningis properly populated in Broadcast Builder - Previously, when no TTS provider was set, the engine returned None without setting an error
- This caused the message "TTS was requested but no audio was generated" with no explanation
- Now displays helpful message: "No TTS provider configured. Configure TTS at /admin/tts in the web UI."
- Addresses: "TTS was requested but no audio was generated" without explanation
Certbot Nginx Log Permission Sudoers - Added missing sudo permissions for nginx log management
- Added sudoers entries for
/var/log/nginxdirectory creation and permission commands - Certbot's nginx plugin runs
nginx -twhich requires write access to log files - Added: mkdir, chmod, touch, chown commands for
/var/log/nginx/error.logandaccess.log - Fixes error:
open() "/var/log/nginx/error.log" failed (13: Permission denied)when running certbot
- Added sudoers entries for
Broadcast Builder TTS Logging to SystemLog - TTS errors and warnings now visible in System Logs UI
- Logs TTS synthesis failures to SystemLog database table (visible in web UI under System Logs)
- Logs when TTS is requested but no audio is generated
- Includes provider name, warning message, and event details in log entries
- Makes TTS debugging much easier without needing systemd journal access
- Addresses: "TTS isn't working in broadcast builder, and I'm not seeing logs"
Fixed
Certbot Nginx Permission Error - Fixed nginx log permissions for certbot nginx plugin
- Changed
/var/log/nginx/error.logpermissions from 640 to 666 to allow certbot'snginx -tto succeed - Set ownership to
www-data:adm(standard nginx log ownership) - Creates both error.log and access.log with proper permissions
- Certbot runs
nginx -tin a different security context, requiring more permissive log file access - Addresses error:
open() "/var/log/nginx/error.log" failed (13: Permission denied)
- Changed
Broadcast Builder Exception Logging - EAS generation failures now logged to SystemLog
- Exceptions during broadcast generation are now logged to SystemLog database
- Includes error type, message, identifier, and event code for debugging
- Makes debugging broadcast builder issues easier through web UI
[Version 2.38.5 and earlier]
Added
- Certbot Status Route Alias - Added
/admin/api/certbot/statusendpoint as alias for/admin/api/certbot/certificate-statusfor frontend compatibility - TTS Debugging Logging - Added comprehensive logging to help diagnose Broadcast Builder TTS issues
- EASAudioGenerator now logs TTS provider configuration at initialization
- Logs when message text is empty (would prevent TTS generation)
- Logs when attempting TTS generation with character count
- Logs TTS generation success with sample count
- Logs TTS synthesis failures ALWAYS (even when no error details available)
- Logs when TTS is disabled (include_tts=False)
- Logs when provider is not configured
- Added full exception stack traces to workflow error logging
- Helps identify configuration vs. runtime issues in Broadcast Builder
- Addresses issues: "Broadcast Builder is not generating TTS audio" and "Errors in the broadcast builder aren't being logged"
Fixed
AudioIngestController.get_broadcast_queue() Error - Fixed AttributeError in eas_monitoring_service.py
- Method was removed in refactor but code still called it
- Added public methods
get_source(),get_all_sources()to AudioIngestController for proper access - Changed to iterate through sources and get broadcast queue from each source
- Fixed metrics collection to aggregate broadcast queue stats from all audio sources
- Fixed web audio streaming to get broadcast queue from source adapter instead of controller
- Added safety check with
hasattr()before callingget_broadcast_queue() - No longer directly accesses private
_sourcesattribute - Addresses error:
'AudioIngestController' object has no attribute 'get_broadcast_queue'
TTS Azure OpenAI Endpoint Validation - Made endpoint validation less strict
- Changed to only require
/deployments/path instead of full/audio/speechpath - Now shows warning instead of error if
/audio/speechis missing - Allows Microsoft Azure-provided endpoints that may have different formats
- Addresses error:
Azure OpenAI endpoint is incomplete - missing /audio/speech path
- Changed to only require
.env File Parsing Error - Fixed python-dotenv parsing error on line 17
- Changed AZURE_OPENAI_CONFIG from single-quoted to escaped double-quoted JSON
- Changed LOCATION_CONFIG from single-quoted to escaped double-quoted JSON
- Addresses warning:
python-dotenv could not parse statement starting at line 17
TTS Test Function - Added ability to test TTS configuration from admin page
- New "Test TTS" button on
/admin/ttsconfiguration page - New API endpoint
/admin/api/tts/testto generate test audio - Test uses sample message to verify TTS engine works correctly
- Shows detailed success information (duration, samples, voice used)
- Shows detailed error messages on failure with troubleshooting hints
- Test results displayed in color-coded alert box (green=success, red=failure)
- Logs test attempts and results to system logs
- Allows users to verify TTS settings before generating actual alerts
- Addresses request: "Can we add a method to test the TTS in the configuration page?"
- New "Test TTS" button on
TTS Not Working - Missing Logging - Added comprehensive logging for TTS failures
- Added detailed error messages when TTS credentials are missing from database
- Changed log level from WARNING to ERROR for critical TTS failures
- Added logging showing which credentials are missing (endpoint, API key, etc.)
- Added logging when Azure OpenAI endpoint format is invalid
- Added logging when deployment name cannot be extracted from endpoint URL
- Added logging showing TTS configuration status at startup
- Logs now guide users to configure TTS at
/admin/ttsin web UI - Fixed: Users can now see why TTS fails in system logs instead of silent failures
- Addresses issue where "all variables are populated" but no logs explain failures
TTS API Key Masking Issue - Removed password masking from API key field
- Changed API key input from
type="password"totype="text"in/admin/tts - Users can now see the actual API key value they're entering
- Prevents browser auto-fill and password manager interference
- Fixes issue where masked field prevented users from verifying correct key entry
- API keys are still stored securely in database, just visible in UI for easier configuration
- Changed API key input from
Certbot Not Working - Missing Logging - Added comprehensive logging for Certbot failures
- Added detailed error logging for all certbot operations (obtain, renew, standalone, nginx, webroot)
- Added logging of full certbot commands being executed
- Added logging of both stdout and stderr from certbot failures
- Added logging showing which method (standalone/nginx/webroot) is being used
- Logs now show exact certbot return codes and error messages
- Addresses issue where certbot failures had no logs explaining what went wrong
Certbot Nginx Plugin Permission Error - Fixed nginx log permission issue for certbot
- Fixed:
open() "/var/log/nginx/error.log" failed (13: Permission denied) - Added
_ensure_nginx_log_permissions()function to create and fix log directory permissions - Creates
/var/log/nginxdirectory with proper ownership (www-data:www-data) - Sets directory permissions to 755 and log file permissions to 644
- Creates
error.logfile if it doesn't exist before running certbot - Function called automatically before certbot nginx plugin execution
- Certbot nginx plugin now works without permission errors
- Fixes issue where certbot's
nginx -tconfig test failed due to log file permissions
- Fixed:
Certbot Port 80 Permission Error - Fixed certbot standalone mode failing to bind to port 80
- Changed default certificate acquisition method from
standalonetonginxplugin - Nginx plugin doesn't require stopping nginx or binding to privileged ports
- Added port 80 availability check before running standalone mode
- Added 2-second delay after stopping nginx to ensure port 80 is released
- Added explicit HTTP-01 challenge configuration for standalone mode
- Added nginx running check before attempting nginx plugin method
- Updated UI to reflect nginx plugin as recommended method (no downtime)
- Improved error messages to guide users when port binding fails
- Standalone method still available but requires manual selection
- Fixes: "PermissionError: [Errno 13] Permission denied" when binding to port 80
- Changed default certificate acquisition method from
Changed
- Admin Page Refactoring Phase 2 Complete - Completed modularization of admin.html JavaScript
- Moved final inline function
sanitizeBoundaryTypeInputto core.js module - Removed outdated comments about remaining inline functions
- admin.html reduced from original 7,461 lines to 2,043 lines (73% reduction, exceeding 30% target)
- All JavaScript now modularized into 9 separate files (132KB total) for better maintainability
- Improved browser caching with external modules
- Cleaner separation of concerns between template variables and business logic
- Version bump to 2.38.0 marks completion of Phase 2 refactoring
- Moved final inline function
Fixed
- Admin HTML Template Structure - Completed refactoring of admin.html template structure
- Removed 5 incorrectly placed closing tags and comments after setup mode section
- Fixed improper div nesting that was closing containers prematurely before {% else %} block
- Removed duplicate closing
</div>tag in manage data section - Template now has proper Jinja2 block structure with balanced opening/closing tags
- Fixes whitespace and rendering issues introduced during previous refactoring
- Template validation confirms all Jinja2 blocks (if/endif, for/endfor, block/endblock, with/endwith) are balanced
Fixed
- Admin Page Excessive White Space - Removed redundant container wrappers and spacing
- Removed redundant
container-fluidwrapper in regular (non-setup) mode - Removed
py-2padding from main container (reduces top spacing) - Removed
mt-4top margin from all tab content containers (Data, System, Services, Security tabs) - Changed tab-content CSS padding from
1rem 1.5rem 1.5rem 1.5remto consistent1.5rem - Fixed improper div nesting between setup mode and regular mode sections
- Tabs now render inside card-header with
p-0 border-0for seamless integration - Tab content now renders inside card-body with
p-0to avoid double padding - Cleaned up closing div comments for better code clarity
- Results in much tighter, cleaner admin interface without excessive vertical gaps
- References bug file:
/bugs/whitespace.html
- Removed redundant
Fixed
- JavaScript Duplicate Declaration Errors - Fixed console errors from duplicate function declarations
- Removed 851 lines of duplicate inline JavaScript code from admin.html
- Cleaned up alert management inline code (variables and 385 lines of functions)
- Cleaned up operations inline code (466 lines of functions including helpers)
- External modules now load without conflicts with inline code
- Fixes errors: "Identifier 'adminAlerts' has already been declared" and "Identifier 'renderQueryDetails' has already been declared"
- admin.html reduced from 6,587 lines to 5,736 lines
Changed
- Admin Page Refactoring - Phase 2 (Major Progress) - Modular JavaScript extraction
- ✅ Moved 449 lines of inline CSS to
/static/css/admin.css - ✅ Extracted 8 JavaScript modules (~2,380 lines total):
/static/js/admin/core.js(163 lines) - Global state and utilities/static/js/admin/utilities.js(240 lines) - Confirmations, status, formatting/static/js/admin/zone-catalog.js(182 lines) - Zone management/static/js/admin/snow-emergency.js(263 lines) - Snow emergency operations/static/js/admin/user-management.js(280 lines) - User CRUD operations/static/js/admin/alert-management.js(513 lines) - Alert editing and display/static/js/admin/hardware-settings.js(160 lines) - LED and GPIO config/static/js/admin/operations.js(580 lines) - Backup, upgrade, manual imports
- All modules loaded in correct dependency order in admin.html
- Improved browser caching - CSS and 8 JavaScript modules cached separately
- All theme-aware functionality preserved across modules
- Remaining modules to extract: Boundary Management, Location Settings, EAS Generator (~2,200 lines)
- admin.html reduced from 7,461 lines to 6,587 lines (874 line reduction)
- See
docs/development/ADMIN_PAGE_REFACTORING.mdfor full refactoring plan
- ✅ Moved 449 lines of inline CSS to
Fixed
Excessive Whitespace on Pages - Fixed large vertical gaps between content and footer
- Changed
.page-shellflex property fromflex: 1 0 autotoflex: 0 0 autoin styles.css - Reduced
--layout-padding-bottomfrom 2.5rem to 1rem (60% reduction) - Reduced
--footer-margin-topfrom 60px to 20px (67% reduction) - Total vertical gap reduced from ~100px to ~36px for more compact, modern layout
- Page shell now only takes the space it needs instead of expanding to fill viewport height
- Footer now appears immediately after content without excessive gaps
- Affects all pages including admin, GPIO control, services, and other low-content pages
- References bug screenshots: Screenshot_17-12-2025_*.jpeg
- Changed
Admin Page Visual White Space - Fixed rendering issues caused by unbalanced HTML structure
- Removed 3 extra closing div tags causing layout problems
- Fixed zone catalog section with 1 redundant closing div
- Corrected improper div closing between setup_mode and normal mode conditional blocks
- Fixed container-fluid divs closing in wrong conditional branches
- All 612 div tags now perfectly balanced (612 opening, 612 closing)
- Changed JavaScript event listeners from
shown.bs.pilltoshown.bs.tabfor Bootstrap 5 compatibility - Zone Catalog tab now loads data when clicked
- Snow Emergencies tab now loads data when clicked
Admin Page Tab Structure - Fixed critically broken HTML structure preventing tabs from loading
- Moved System Settings sub-tabs (Location, Alerts, Snow Emergency) inside system-settings tab-pane
- Moved Security sub-tab (User Management) inside security tab-pane
- Added proper tab-content containers for sub-tabs (systemTabContent, securityTabContent)
- Removed improper comment claiming sub-tabs should be top-level (they must be nested for Bootstrap)
- Closed all unclosed divs in Security tab structure
- Fixed Operations tab starting inside Security tab's unclosed divs
- All 6 main tabs and 7 sub-tabs now load correctly
- Validates with balanced Jinja2 template syntax
Admin Page Tab Navigation - Fixed broken tab structure preventing tabs from loading
- Fixed JavaScript event listener for Zone Catalog tab (ID mismatch:
zones-tab→zones-subtab, event:shown.bs.tab→shown.bs.pill) - Fixed JavaScript event listener for Snow Emergency tab (ID mismatch:
snow-emergencies-tab→snow-subtab, event:shown.bs.tab→shown.bs.pill) - Fixed System Settings tab structure by removing confusing empty
<div class="tab-content">that caused nesting issues - All 6 main tabs (Data, System, Services, Hardware, Security, Operations) now load correctly
- All 7 sub-tabs now function properly
- Fixed JavaScript event listener for Zone Catalog tab (ID mismatch:
RBAC User Management - User roles now display correctly in user management page
- Fixed backend
AdminUser.to_safe_dict()to returnrole_nameinstead ofrole - Users with assigned roles now show role badges (Admin, Operator, Viewer, etc.)
- Fixes "No Role" appearing for all users even when roles were assigned
- Fixed backend
Icecast Connection Test - Fixed 400 Bad Request error when testing Icecast connection
- Improved
test-connectionendpoint to handle empty JSON request bodies gracefully - Added better error logging for connection test failures
- Improved
Icecast Configuration Warnings - Fixed Icecast server startup warnings
- Added
server_hostname,server_location,admin_contactfields to IcecastSettings model - Created database migration to add new fields
- Added UI fields in Icecast admin page to configure server information
- Fixes warnings: "hostname not configured", "location not configured", "admin contact not configured"
- Added
Added
Admin Page Refactoring Phase 2 Completion Guide - Created detailed extraction roadmap
- Documented 80 remaining inline functions across 3 modules
- Complete function lists with line numbers and dependencies
- Step-by-step extraction process guide
- Comprehensive testing checklist for each module
- Load order requirements and risk assessment
- Time estimates: 6-8 hours to complete Phase 2
- See
docs/development/ADMIN_REFACTORING_PHASE2_REMAINING.mdfor details
Admin Page Refactoring Documentation - Created comprehensive refactoring roadmap
- Documented current state: 7,453 lines, 388KB file size
- Analysis: 67.5% JavaScript (5,034 lines, 150+ functions), 26.4% HTML, 6.0% CSS
- Phased refactoring plan (Phase 1 complete, Phase 2-3 planned)
- See
docs/development/ADMIN_PAGE_REFACTORING.mdfor details
Environment Variables UI - Added missing environment variables to frontend configuration
- Redis Category:
REDIS_HOST,REDIS_PORT,REDIS_DBfor granular Redis server configuration - TTS Category:
EAS_TTS_PROVIDER,AZURE_OPENAI_CONFIGfor text-to-speech provider settings - Certbot Category:
DOMAIN_NAME,SSL_EMAIL,CERTBOT_STAGINGfor SSL certificate management - EAS Category:
EAS_SCAN_INTERVAL,MAX_CONCURRENT_EAS_SCANSfor audio scanning configuration - System Category:
TZ(timezone),BACKUP_DIR,WEB_ACCESS_LOGfor system settings - All environment variables from
.env.examplenow have corresponding UI fields
- Redis Category:
Database Migration -
20251217_add_icecast_server_info.pyfor Icecast server information fields
Changed
- Icecast admin page now includes Server Information section for hostname, location, and admin contact
Added
TTS Settings Database Migration - Moved TTS configuration from environment variables to database
- Created
TTSSettingsdatabase model for persisting TTS configuration - Added dedicated TTS settings page at
/admin/ttswith user-friendly UI - Link added to admin panel Operations tab for easy access
- Settings now stored in database and survive reboots/updates reliably
- Supports Azure OpenAI, Azure Cognitive Services, and pyttsx3 providers
- Database is the only source - no fallback to environment variables
- Removed TTS settings from environment configuration page to avoid confusion
- Created
Admin Panel Links - Added missing administrative page links to Operations tab
- Added link to SSL/TLS Certificates page (
/admin/certbot) - Added link to Icecast Streaming page (
/admin/icecast) - Added link to Zone Catalog Management page (
/admin/zones) - Added link to Text-to-Speech page (
/admin/tts) - All major admin tools now accessible from the main admin panel
- Added link to SSL/TLS Certificates page (
Icecast Logs - Added Icecast service logs to system logs viewer
icecast2.servicenow appears in log service dropdown- Allows viewing Icecast streaming server logs through the web interface
- Updated
app_core/config/services.pyto include icecast in INFRASTRUCTURE_SERVICES
Fixed
TTS Configuration Persistence - Fixed Azure OpenAI TTS settings not surviving reboots/updates
- Fixed JSON builder showing bullet characters (
••••••••) instead of actual API key values - Backend now properly masks only password fields within JSON configs, preserving JSON structure
- When saving, masked password values are preserved from existing config instead of being overwritten
- TTS settings now correctly persist across application restarts and updates
- Fixes "Invalid JSON: Unexpected token '•'" errors in environment settings
- Fixed JSON builder showing bullet characters (
SSL Certificate Management - Sudo Permission Errors - Fixed container permission errors when obtaining SSL certificates
- Removed
sudoprefix from allsystemctlandcertbotcommands inwebapp/admin/certbot.py - Commands now run directly since container already has proper permissions
- Fixes error: "The 'no new privileges' flag is set, which prevents sudo from running as root"
- Updated user-facing instructions to not include
sudoin examples - Certificate operations now work correctly in containers with
no-new-privileges:truesecurity flag
- Removed
SSL Certificate Management - Duplicate Locations - Consolidated SSL certificate management to single location
- Removed SSL/Certbot tab from Admin panel (
/adminpage) - Removed SSL Certificates menu item from Settings dropdown in navbar
- All SSL certificate management now accessible only through dedicated page at
/admin/certbot - Eliminates confusion from having two different places to manage SSL certificates
- Removed SSL/Certbot tab from Admin panel (
Template Syntax Errors - Fixed missing closing tags in Jinja2 templates
- navbar.html: Added missing
{% endblock %}forshow_settings_hardwareblock - audio_detail.html: Added missing
{% endblock %}to close content block before scripts block - These were causing Jinja2 TemplateSyntaxError: "Unexpected end of template"
- Website would show template errors instead of loading properly
- These were pre-existing issues in the templates, not introduced by recent changes
- All 89 templates in the repository now have balanced blocks
- navbar.html: Added missing
Icecast and Certbot Service Startup - Fixed issue where external services weren't being started during update
update.shnow ensuresicecast2service is enabled and running after updateupdate.shnow ensurescertbot.timeris enabled and running after update- Resolves "Icecast not starting" and "CertBot not working" issues
- Services are checked and started if not already running
- Provides clear feedback about service status during update
Icecast and Certbot Database Initialization - Added defensive error handling to prevent app crashes
- Icecast and Certbot settings modules now gracefully handle missing database tables
- App can now start successfully even if database migrations haven't run yet
- Tables are automatically created if missing during first access
- Improved database commit error handling with rollback in settings update functions
- Resolves issue where app would crash on startup if migrations failed or weren't run
Template Error on Alerts Page - Fixed 500 error when loading alerts page
- Added missing
is_expiredJinja2 template filter registration in app.py - Filter checks if an alert has expired based on its expiration datetime
- Fixed alerts.html, alerts_new.html, and alert_detail.html templates that use the filter
- Added missing
Unused psutil Import - Removed unused psutil import from app.py
- Import was causing ModuleNotFoundError in scripts that import from app.py
- psutil is still required for system monitoring features (kept in requirements.txt)
- Fixed scripts/fix_admin_roles.py and other scripts that import from app
[2.36.0]
Added
LED Sign IP Address Configuration - Added IP address and port fields to admin Hardware tab
- Added
led_ip_addressandled_portinput fields in admin.html Hardware Integrations tab - Updated
/api/led/serial_configendpoint to save IP address and port to both LEDSignStatus and HardwareSettings tables - JavaScript now loads and saves LED IP/port configuration along with serial settings
- Eliminates confusion about where to configure serial-to-ethernet converter network settings
- Users can now configure all LED sign settings (IP, port, serial mode, baud rate) in one location
- Added
Admin Role Assignment Fix Script - Added utility script to fix users without roles
- Created
scripts/fix_admin_roles.pyto assign admin role to users created before roles were initialized - Script ensures roles/permissions are initialized and assigns admin role to any user without a role
- Run with:
python3 scripts/fix_admin_roles.py
- Created
Enhanced
SSL/Certbot Management Simplified - Consolidated duplicate SSL configuration interfaces
- Simplified SSL tab in admin page to show certificate status overview only
- Removed duplicate configuration form from admin.html SSL tab
- Added prominent link to advanced Certbot management page at
/admin/certbot - Advanced features (certificate acquisition, renewal testing, domain validation) remain at dedicated Certbot page
- Clearer separation: quick status in admin page, full management in Certbot page
User Creation Role Assignment - Improved role initialization during first user creation
- First user creation now explicitly calls
initialize_default_roles_and_permissions()before assigning role - Ensures admin role exists before attempting to assign it to new users
- Provides better error message if role initialization fails
- First user creation now explicitly calls
Fixed
Hardware Settings Permission Issue - Fixed "permission denied" error accessing advanced hardware settings
- Changed
/admin/hardwarepermission from'admin'(superuser only) to'system.configure'(regular admins) - Updated navbar to show Hardware Settings link only to users with
system.configurepermission - Separated hardware navigation: GPIO/Zigbee for
gpio.view, Hardware Settings forsystem.configure - Eliminated confusion caused by two hardware configuration locations
- Changed
Zone Catalog Permission Errors - Fixed 403 permission_denied on Zone Catalog page
- Changed all zone routes from non-existent
'admin.settings'to'system.configure' - Zone catalog now accessible to users with system.configure permission
- Fixed: Zone info endpoint, zone management page, zone search, zone upload, zone reload
- Changed all zone routes from non-existent
Admin Users Created Without Roles - Fixed critical issue where admin users show "No Role"
- Admin users created during setup or via UI were not getting admin role assigned
- Added role initialization check before first user creation
- Created fix script for existing installations with users that have no roles
- Users now properly assigned admin role with full permissions
[2.34.2]
Fixed
- Screen Renderer Connection Error Logging - Reduced log spam from expected connection failures
- Changed screen_renderer.py to log connection errors at DEBUG level instead of ERROR
- Connection refused errors are expected when web service isn't running (hardware-only mode)
- Prevents log spam while still showing unexpected errors
[2.34.1]
Fixed
- Audio/Icecast Error Logging Fixes - Resolved excessive error logging and JSON parsing issues
- Fixed JSON parsing error in websocket audio_monitoring_update caused by improper bytes decoding from Redis
- Added proper UTF-8 decoding for Redis hgetall() values (redis-py 7.x returns bytes)
- Added validation to skip empty strings before JSON parsing to prevent "Expecting value" errors
- Reduced Icecast connection error spam by suppressing repetitive "Connection refused" logs during backoff
- Improved audio underrun warning frequency with exponential backoff (10, 50, 100, 200, 500, etc.)
- Added better error handling for invalid heartbeat values in Redis metrics
[2.34.0]
Added
- Full Web UI for Certbot Operations - Complete SSL certificate management through web interface
- Added
/api/certbot/obtain-certificate-executeendpoint to directly obtain SSL certificates - Added
/api/certbot/renew-certificate-executeendpoint to directly renew certificates - Added
/api/certbot/enable-auto-renewalendpoint to manage systemd timer - Users can now obtain, renew, and manage SSL certificates entirely through the web UI
- Supports standalone, nginx plugin, and webroot certificate acquisition methods
- Supports dry-run testing, normal renewal, and forced renewal
- Real-time feedback with certbot output displayed in the UI
- Enable/disable automatic renewal with one click
- Added SSL Certificates link to Settings dropdown in navigation menu
- Eliminates need for CLI usage - fully web-based certificate management
- VERSION bumped to 2.35.0 (feature: web-based certbot execution)
- Added
Enhanced
- Icecast Auto-Installation - Icecast2 now installed by default during bare metal installation
- Added
icecast2package to install.sh BASE_PACKAGES array - Icecast2 service automatically enabled and started during installation
- Added detailed installation status logging for Icecast setup
- Shows Icecast port information during install (default: 8000)
- Provides helpful guidance if Icecast fails to start (password configuration needed)
- CRITICAL: Icecast is REQUIRED for audio streaming functionality - no longer optional
- Addresses issue where users couldn't get Icecast working because it wasn't installed
- Added
Enhanced
- Comprehensive Icecast Logging - Greatly improved logging for Icecast streaming operations
- Added detailed startup logging with server, port, mount, format, and bitrate info
- Added connection status logging when FFmpeg connects to Icecast
- Added comprehensive shutdown logging with final statistics (uptime, bytes, bitrate, reconnects)
- Added FFmpeg process ID (PID) logging for easier troubleshooting
- Added error logging with stack traces for FFmpeg startup failures
- Improved log messages with ✓/✗ symbols for better readability
- All log messages now include mount point for multi-stream debugging
- Makes it much easier to diagnose Icecast connection and streaming issues
Fixed
- Removed Duplicate Icecast Settings - Consolidated all Icecast configuration to single location
- Removed entire Icecast settings section from
/settings/audiopage (lines 131-252 HTML) - Removed all Icecast JavaScript functions from audio.html (300+ lines)
- All Icecast settings now managed exclusively at
/admin/icecast - Eliminates confusion from having same settings in multiple locations
- Cleaner UI with single source of truth for Icecast configuration
- Addresses new requirement to consolidate settings to one spot
- Removed entire Icecast settings section from
[2.33.1]
Fixed
- Certbot/SSL Certificate Management Security Fix - Removed sudo calls from web interface
- Removed all
sudo certbotsubprocess calls from web application for security compliance - Web interface now provides copy-paste commands instead of executing privileged operations
- Added systemd timer status checking for automatic certificate renewal
- Updated UI to display certificate acquisition instructions with multiple methods (standalone, nginx, webroot)
- Added copy-to-clipboard functionality for certificate management commands
- Provides clear guidance on manual certificate operations via command line
- Fixes "no new privileges" flag error when attempting sudo from web app
- Maintains certificate status checking functionality (read-only operations)
- Addresses security concern of web application having elevated privileges
- VERSION bumped to 2.33.1 (bug fix + security improvement)
- Removed all
Added
- Icecast Password Management Improvements - Transformed password handling to read-only display with regenerate option
- Password fields now read-only to prevent user errors and mismatches with Icecast server
- Added password masking with show/hide toggle buttons for security
- Added copy-to-clipboard functionality for easy password access
- Added informational text explaining passwords are auto-generated during installation
- Added regenerate password functionality that updates database, .env file, AND Icecast server config
- New endpoint
/admin/api/icecast/regenerate-passwordsfor secure password regeneration - CRITICAL: Now updates Icecast server configuration file (
/etc/icecast2/icecast.xml) - Automatically restarts Icecast service after password regeneration
- Handles default passwords (changeme_admin) by updating server config
- Maintains strong auto-generated passwords (secrets.token_urlsafe(16))
- Non-password settings (ports, stream name, etc.) remain editable
- Prevents security risks from weak user-chosen passwords
- VERSION bumped to 2.33.0 (critical security fix + feature enhancement)
Fixed
Certbot Certificate Acquisition Issues - Fixed missing functionality and errors in SSL certificate management
- Fixed 400 Bad Request error in
/api/certbot/test-domainendpoint (was not handling empty JSON body) - Added missing
/api/certbot/obtain-certificateendpoint for initial certificate acquisition - Added "Obtain SSL Certificate" UI section in Certificate Status tab with confirmation dialog
- Improved error messages for sudo privilege issues (NoNewPrivileges systemd setting)
- Added specific error handling for port conflicts, validation failures, and permission errors
- Enhanced frontend to display detailed error messages with helpful guidance
- Addresses issue where users couldn't obtain initial certificates through web UI
- VERSION bumped to 2.31.2 (bug fix)
- Fixed 400 Bad Request error in
CRITICAL: EAS Monitor Buffer Starvation - Fixed monitor bouncing between healthy and starved states
- Increased EAS monitor read timeout from 0.1s to 1.0s in
app_core/audio/eas_monitor_v3.py - Root cause: Monitor requested 1600 samples but timeout fired before accumulating enough chunks
- Reduced "no audio" sleep from 50ms to 20ms to check more frequently and prevent queue buildup
- Prevents vicious cycle: timeout → sleep 50ms → audio builds up → queue fills → drops → repeat
- Eliminates false "no audio sources" errors when sources are actually running
- Prevents missed emergency alerts due to audio starvation
- VERSION bumped to 2.31.1 (critical bug fix)
- Increased EAS monitor read timeout from 0.1s to 1.0s in
Removed All Legacy Audio Queue Code - Eliminated wasted resources and 24,000+ dropped chunks
- Removed controller broadcast queue (
_broadcast_queue) and subscription (controller-legacy) - Removed broadcast pump thread and
_broadcast_pump_loop()method - sources already publish directly - Removed
get_audio_chunk()andget_broadcast_queue()methods from AudioIngestController - Removed unused legacy source queues (
_legacy_subscriber_id,_audio_queue) from AudioSourceAdapter - Clean architecture: Audio Source → BroadcastQueue → Subscribers (EAS Monitor, Icecast)
- No more redundant copying, no more unused queue drops, cleaner logs
- EAS monitor and Icecast subscribe directly to source broadcast queues
- Removed controller broadcast queue (
Changed
- Environment Variables Cleanup - Removed redundant settings that are now managed via dedicated admin pages
- Removed 'gpio' category from environment variables (now managed via
/admin/hardware) - Removed 'icecast' category from environment variables (now managed via
/admin/icecast) - Removed duplicate 'notifications' category that contained SDR/audio settings
- GPIO, OLED, LED, VFD, and Icecast settings exclusively managed through database-backed admin UIs
- Cleaner environment configuration focused on core application settings
- VERSION bumped to 2.31.0 (feature enhancement)
- Removed 'gpio' category from environment variables (now managed via
Added
- Certbot/SSL Admin Tab - Moved SSL certificate management into Admin Panel as dedicated tab
- Added SSL/Certbot tab to
/templates/admin.htmlwith nested Configuration and Certificate Status tabs - Integrated certbot functionality into admin panel (no longer separate navbar entry)
- Removed
/templates/admin/certbot.htmlstandalone page dependency - Added JavaScript functions for certbot management in admin.html
- Maintains all existing API endpoints (
/api/admin/certbot/*) - Auto-loads settings when tab is opened
- VERSION remains 2.30.0 (UI improvement)
- Added SSL/Certbot tab to
Changed (Previous)
- Environment Variables Cleanup - Removed Certbot configuration from environment variables
- Removed DOMAIN_NAME, SSL_EMAIL, and CERTBOT_STAGING from
/webapp/admin/environment.py - Removed entire 'https' section from environment variable management
- Settings now exclusively managed through database (cleaner architecture)
- Migration
20251216_add_certbot_settings.pyimports env vars on first run for backward compatibility
- Removed DOMAIN_NAME, SSL_EMAIL, and CERTBOT_STAGING from
Removed
- SSL Certificate Viewer from Security Settings - Eliminated duplicate certificate viewer
- Removed SSL Certificate Status section from
/templates/security_settings.html - Removed
loadSSLCertificateStatus()JavaScript function and event listeners - Certificate viewing now unified in Admin Panel → SSL/Certbot tab
- Cleaner security page focused on MFA and authentication
- Removed SSL Certificate Status section from
- Navbar Certbot Entry - Removed standalone certbot link from navigation menu
- Removed
/admin/certbotlink from/templates/components/navbar.html - Consolidates admin functions in Admin Panel tab interface
- Removed
Added (Previous Features)
- Certbot/SSL Admin Page - New dedicated admin page for SSL certificate management
- Created
/webapp/admin/certbot.pyblueprint with comprehensive Certbot/Let's Encrypt management routes - Created
/app_core/certbot_settings.pyhelper functions for database settings management - Added
CertbotSettingsdatabase model to store certificate configuration - Added database migration
20251216_add_certbot_settings.pyto create certbot_settings table - Includes GET/PUT
/api/admin/certbot/settingsfor reading and updating Certbot configuration - Includes GET
/api/admin/certbot/certificate-statusfor checking certificate status and expiration - Includes POST
/api/admin/certbot/renew-certificatefor triggering certificate renewal (dry-run) - Includes POST
/api/admin/certbot/test-domainfor DNS and HTTP accessibility testing - Includes GET
/api/admin/certbot/download-certificatefor downloading certificates - Updated
/app_core/ssl_utils.pyto read configuration from database with env var fallback - Follows established admin page patterns with Bootstrap 5 styling and theme support
- Protected with
@require_permission('system.configure')decorator - Integrated into admin blueprint registration in
/webapp/admin/__init__.py - VERSION bumped to 2.30.0 (new feature)
- Created
- Unified EAS Monitor Architecture (V3) - Complete redesign of EAS monitoring system for efficiency
- Created
/app_core/audio/eas_monitor_v3.pywith unified single-threaded architecture UnifiedEASMonitorServiceclass replaces multi-monitor architecture (1 thread instead of N threads)SourceWatcherclass provides lightweight per-source audio subscribers (no separate threads)HealthTrackerclass provides centralized health tracking for all sources- Auto-discovery automatically finds and monitors running audio sources (no manual lifecycle management)
- Single shared StreamingSAMEDecoder processes audio from all sources
- Updated
/eas_monitoring_service.pyinitialize_eas_monitor() to use UnifiedEASMonitorService - Maintains backward-compatible status API format (no webapp changes needed)
- Benefits: Reduced CPU/memory usage, simpler codebase, no status aggregation overhead
- Kept existing
/app_core/audio/eas_monitor.pyfor backward compatibility - VERSION bumped to 2.29.0 (new feature)
- Created
- Icecast Admin Page - New dedicated admin page for Icecast streaming configuration
- Created
/webapp/admin/icecast.pyblueprint with comprehensive Icecast management routes - Created
/templates/admin/icecast.htmltemplate with configuration and diagnostics sections - Added routes for settings management, connection testing, and real-time status monitoring
- Includes GET/PUT
/api/admin/icecast/settingsfor reading and updating Icecast configuration - Includes POST
/api/admin/icecast/test-connectionfor testing Icecast connectivity - Includes GET
/api/admin/icecast/statusfor retrieving streaming status and listener counts - Uses existing IcecastSettings database model and helper functions
- Follows established admin page patterns with Bootstrap 5 styling and theme support
- Protected with
@require_permission('system.configure')decorator - Integrated into admin blueprint registration in
/webapp/admin/__init__.py - VERSION bumped to 2.28.0 (new feature)
- Created
Changed
- Documentation Update - Comprehensive review and updates across all documentation
- Updated all main architecture documents with current timestamps (2025-12-16)
- Updated INDEX.md statistics: 92 total files (was 47), 12 guides (was 6), 18 architecture docs (was 10), 19 troubleshooting guides (was 10)
- Updated SYSTEM_ARCHITECTURE.md, THEORY_OF_OPERATION.md, DATA_FLOW_SEQUENCES.md with current information
- Updated DIAGRAMS.md to reflect complete visual documentation coverage
- Enhanced README.md with detailed systemd service architecture diagrams
- Added comprehensive technology stack section to README.md with all versions
- Updated README.md architecture diagrams to show systemd services (eas-station-web, eas-station-poller, eas-station-sdr, etc.)
- Added FastAPI 0.124.2 badge to README.md technology badges
- Updated Bootstrap version to 5.3.0 in badges
- Updated Redis version to 7.1 in badges
- Enhanced documentation navigation diagram with troubleshooting branch
- All service references now accurately reflect systemd-based deployment
- Fixed broken documentation links: HARDWARE_ISOLATION.md, EAS_DECODING_SUMMARY.md, PYCHARM_DEBUGGING.md
- Removed references to deleted architecture analysis documents
- Updated cross-references to point to existing current documentation
- VERSION bumped to 2.27.27 (documentation update)
Fixed
- RBDS Extraction Not Working - Fixed RBDS decoder checking wrong sample rate
- Bug: Checked
self.config.sample_rate(final audio rate, 48kHz) instead ofself._intermediate_rate(FM multiplex rate, 250kHz) - RBDS subcarrier at 57kHz exists in FM multiplex signal BEFORE final decimation
- Changed condition from
self.config.sample_rate >= 114000toself._intermediate_rate >= 114000 - RBDS now extracts correctly when intermediate rate is high enough
- VERSION bumped to 2.27.26 (bug fix)
- Bug: Checked
- HTTP Streams Playing at Wrong Bitrate - Fixed forced resampling degrading stream quality
- Bug: FFmpeg was forcing ALL HTTP streams to resample to configured sample rate (44.1kHz)
- Streams with native 48kHz/128kbps were being degraded by unnecessary resampling
- Solution: Removed
-arflag to let FFmpeg preserve stream's native sample rate - Added
preserve_native_rateconfig option (defaults to True for HTTP streams) - Set to False if you specifically need resampling for compatibility
- Prevents quality loss and "wrong bitrate" playback issues
- VERSION bumped to 2.27.26 (bug fix)
- Zone Catalog API Returning HTML - Fixed JSON parsing error in admin zone catalog
- Bug: JavaScript fetch() to
/admin/zones/infodidn't include Accept header - Without
Accept: application/json, authentication failures returned HTML redirects - Added Accept header to all zone catalog API calls
- Added content-type validation before JSON parsing
- Added proper error handling with toast notifications for auth/permission errors
- Better user feedback instead of cryptic "Unexpected token '<'" errors
- VERSION bumped to 2.27.26 (bug fix)
- Bug: JavaScript fetch() to
- High CPU Usage in eas_monitoring_service - Fixed excessive logging hammering CPU
- RBDS decoder was logging at INFO level every time data was decoded (multiple times per second)
- Each log write causes disk I/O and string formatting overhead
- Changed to only log when RBDS data actually changes (PS name, radio text, etc.)
- Added
_last_logged_rbdsto track last logged state - Prevents CPU from spiking to 125% during RBDS reception
- RBDS data still flows to frontend metrics in real-time (no functional change)
- VERSION bumped to 2.27.25 (bug fix)
- RBDS Data Not Displaying in Frontend - Fixed RBDS metadata not showing in audio-monitor
- RBDS decoder in
demodulation.pywas working correctly, extracting PS name, PI code, radio text, etc. - Bug:
redis_sdr_adapter.pyextracted stereo pilot data from demodulator status but NOT RBDS data - Added extraction of all RBDS fields from
DemodulatorStatus.rbds_datato metrics metadata - RBDS fields now published: ps_name, pi_code, radio_text, pty, tp, ta, ms, last_updated
- Frontend template already had complete RBDS display support (no changes needed)
- Verified SDR++ can decode RBDS confirms hardware/signal is good
- VERSION bumped to 2.27.24 (bug fix)
- RBDS decoder in
- Audio Bitrate/Sample Rate Mismatch - Fixed slow/fast audio playback in web monitoring
- Removed hardcoded 44.1kHz resampling in web audio stream endpoint
- Audio now streams at native source sample rate (32kHz, 44.1kHz, 48kHz, etc.)
- Prevents pitch/speed mismatch when source rate differs from hardcoded 44.1kHz
- Critical for iHeartMedia and other web streams that may use different sample rates
- EAS decoder still gets properly resampled 16kHz feed via ResamplingBroadcastAdapter
- WAV header now correctly matches actual audio sample rate
- VERSION bumped to 2.27.23 (bug fix)
- Migration Chain Broken - Fixed Alembic migration revision mismatch causing KeyError
- Migration
20251214_add_hardware_settings.pyincorrectly referenceddown_revision = '20251205_add_audio_sample_rate' - Actual revision ID was
'20251205_audio_sample_rate'(withoutadd_prefix) - Fixed down_revision to match actual revision ID
- Resolves KeyError: '20251205_add_audio_sample_rate' during
alembic upgrade head - VERSION bumped to 2.27.22 (bug fix)
- Migration
- SDR Buffer Display - Hide buffer utilization metric for SDR sources in audio monitoring UI
- SDR sources don't use buffering (data flows directly from hardware)
- Buffer metric now hidden for source types 'sdr' and 'redis_sdr'
- Matches behavior of detailed buffer utilization indicator
- VERSION bumped to 2.27.22 (bug fix)
- Administrator Role Assignment - Improved role validation during installation
- Install script now explicitly validates admin role exists before creating user
- Fails with clear error message if role not found instead of silently continuing
- Sets both
roleandrole_idfields for redundancy - Confirms role assignment in success message
- VERSION bumped to 2.27.22 (bug fix)
Added
- Web Stream Auto-Start - Added auto-start option for HTTP/stream audio sources
- Added checkbox to audio source creation modal
- Sources can now auto-start on system boot (backend already supported this)
- Matches functionality available for SDR sources
- VERSION bumped to 2.27.22 (feature)
Fixed
- Update Script Freezing - Fixed update.sh hanging at "Updating System Dependencies" step
- Removed
-qqflag from apt-get install to show actual progress output (was suppressing all output making it appear frozen) - Kept
DEBIAN_FRONTEND=noninteractiveto prevent interactive prompts from package configuration - Now shows full apt-get output so users can see installation progress and diagnose any issues
- Matches install.sh behavior for consistency
- VERSION bumped to 2.27.21 (bug fix)
- Removed
- Alembic Migration Idempotency - Fixed migrations hanging when tables already exist
- Added table existence checks to hardware_settings and icecast_settings migrations
- Migrations now skip table creation if table already exists (idempotent)
- Prevents "table already exists" errors that cause update.sh to hang
- Fixes issue where update would stop after "Context impl PostgresqlImpl" message
- VERSION bumped to 2.27.19 (bug fix)
Removed
- Internal Documentation Cleanup - Removed internal planning and working memory documents (16 total files)
- Architecture planning docs: Deleted REWRITE_PLAN_SUMMARY.md, REWRITE_ARCHITECTURE.md, REWRITE_ROADMAP.md, CODEBASE_INVENTORY.md, CODE_MODERNIZATION_RECOMMENDATIONS.md, APP_PY_REFACTORING_PLAN.md, LIBRARY_REPLACEMENT_AUDIT.md, LIBRARY_REPLACEMENT_IMPLEMENTATION_PLAN.md, SDR_ARCHITECTURE_REFACTORING.md, MIGRATION_GUIDE.md (planning for theoretical rewrite, not current system)
- Internal working memory: Deleted CLAUDE_MEMORY.md, SDR_WORKING_MEMORY.md (AI agent internal context, not user documentation)
- Dated status reports: Deleted CRITICAL_ISSUES_STATUS.md, VALIDATION_REPORT.md, ARCHITECTURE_REVIEW_BUGS.md (issues already fixed or addressed)
- Auto-generated stats: Deleted static/docs/REPO_STATS.md (auto-generated, outdated: 2025-11-29)
- Old templates: Deleted templates/settings/network_old.html (unreferenced old template file)
- These documents were internal/temporary and not user-facing documentation
- Active architecture docs remain in docs/architecture/ (SYSTEM_ARCHITECTURE.md, THEORY_OF_OPERATION.md, etc.)
- VERSION bumped to 2.27.18 (documentation cleanup)
Changed
- Documentation Organization - Cleaned up and reorganized all documentation
- Moved
docs/TROUBLESHOOTING_504_TIMEOUT.mdtodocs/troubleshooting/subdirectory - Moved
docs/ENV_FILE_MIGRATION.mdtodocs/troubleshooting/ - Moved
docs/Installation-Changes.mdanddocs/PostgreSQL-15-Fix.mdtodocs/installation/ - Moved
docs/QUICKSTART-BARE-METAL.mdtodocs/installation/ - Moved
docs/POLLER_MIGRATION_GUIDE.mdanddocs/SMART_SETUP.mdtodocs/guides/ - Moved
docs/FUTURE_ENHANCEMENTS.mdtodocs/roadmap/ - Moved
docs/SECURITY_FEATURES.mdtodocs/security/ - Updated
docs/INDEX.mdto reference new file locations and added more documentation links - Root directory now contains only README.md and essential scripts (install.sh, update.sh, uninstall.sh, diagnose.sh)
- Documentation root (
docs/) now contains only INDEX.md and README.md - All documentation properly organized into category subdirectories
- VERSION bumped to 2.27.17 (documentation cleanup)
- Moved
Removed
- Historical Fix Documentation - Eliminated one-off fix documents from repository (27 total files)
- Root directory fixes: Deleted STARTUP_FIX_SUMMARY.md, DEPLOYMENT_INSTRUCTIONS.md, FIXES_APPLIED.md, WEBSITE_504_FIX.md, FIX_FOR_TIMEOUT_ISSUE.md, DEPLOYED_SYSTEM_FIX.md, QUICK_FIX.md, QUICK_DATABASE_FIX.md, ROOT_CAUSE_ANALYSIS.md, FIX_SUMMARY_OLD.md, ISO_BUILD_READY.md, PATH_AUDIT.md, ISSUE_ANALYSIS.md
- Architecture fixes: Deleted docs/architecture/AUDIO_PLAYER_FIX.md, SDR_FIX_SUMMARY.md, CHANGES_SUMMARY.md, SDR_ANALYSIS_2025-12-08.md, IMPLEMENTATION_COMPLETE.md
- Troubleshooting fixes: Deleted docs/troubleshooting/AIRSPY_CONTAINER_FIX.md, AIRSPY_NO_OPEN_FIX.md, FRONTEND_AIRSPY_ERROR_AFTER_FIX.md, CONTAINERIZATION_FIXES.md, AIRSPY_SERVICE_FILE_NOT_UPDATED.md, ROOT_CAUSE_AIRSPY_BARE_METAL.md, PASSWORD_MISMATCH.md
- Security fixes: Deleted docs/security/SECURITY_SUMMARY.md
- Installation fixes: Deleted docs/installation/CHANGES_SUMMARY.md
- These documents described specific bugs and fixes that are no longer relevant
- Current troubleshooting information is maintained in
docs/troubleshooting/directory
Fixed
- S.M.A.R.T. Invalid Argument Error - Fixed smartctl compatibility issue with
-n standbyflag- The
-n standbyflag was being added for all device types except 'nvme' and 'auto' - This caused "Invalid argument" errors on USB, SCSI, and other device types that don't support standby mode
- Changed logic to ONLY add
-n standbyflag for ATA/SATA devices (device types 'ata' or 'sat') - S.M.A.R.T. health monitoring now works correctly across all device types
- VERSION bumped to 2.27.16 (bug fix)
- The
- Update Script Migration Errors - Fixed migration errors being hidden by screen clear
- Added
MIGRATION_FAILEDflag to track when Alembic migrations encounter errors - Screen is no longer cleared after failed migrations, keeping error output visible
- Added pause prompt after migration errors so user can read details before continuing
- Makes it much easier to diagnose database migration issues during updates
- VERSION bumped to 2.27.16 (bug fix)
- Added
- Audio Monitor Console Spam - Removed noisy console.log/console.debug statements
- Removed VU meter debug logging that spammed console every 5 seconds
- Removed WebSocket connection status logging
- Removed stream switching debug messages
- Removed playback state debug messages
- Removed broadcast queue statistics logging
- Console now only shows errors and warnings, not routine operational messages
- VERSION bumped to 2.27.16 (bug fix)
- Audio Monitor UI Fixes - Fixed three display issues on audio monitoring page
- RBDS metadata now properly displays when available (added null check for source.metrics)
- S.M.A.R.T. health display no longer shows both "✓ Healthy" and error message simultaneously
- SDR sources no longer show buffer utilization indicator (added check for 'redis_sdr' source type)
- VERSION bumped to 2.27.15 (bug fix)
- Critical Audio Chipmunk Bug - Fixed severe audio speed issue (2x speed, not 1.09x as previously calculated)
- Root cause: FM demodulator outputs mono audio but config reported 2 channels (stereo)
- Streaming code incorrectly treated mono samples as interleaved stereo pairs
- This caused 48000 mono samples to be interpreted as 24000 stereo samples
- Browser played 24000 samples at 48kHz rate = 2x speed = severe chipmunk effect
- Fixed by detecting actual audio shape (1D vs 2D array) instead of trusting config
- NOTE: FM stereo decoding (L-R separation) not yet implemented; only pilot detection works
- VERSION bumped to 2.27.14 (critical bug fix)
- S.M.A.R.T. "Invalid command line arguments" - Fixed smartctl compatibility issue with older versions
- Changed
--json=oflag to--jsonfor broader smartctl version support - The
=ooption was added in smartctl 7.2+ and caused "Invalid command line arguments" error on older systems - S.M.A.R.T. disk health monitoring now works on systems with smartctl < 7.2
- VERSION bumped to 2.27.13 (bug fix)
- Changed
- Web Player Stability - Improved audio streaming stability for continuous playback
- Added
Accept-Ranges: noneheader to prevent browser seeking in live streams - Added
Connection: keep-aliveheader to maintain streaming connection - These headers prevent browsers from closing streams after buffering attempts
- VERSION bumped to 2.27.13 (bug fix)
- Added
Added
- FM Stereo Pilot & RBDS Display - Added real-time FM broadcast metadata to audio monitor
- Shows stereo pilot tone lock status and signal strength (19 kHz pilot detection)
- Displays stereo/mono audio mode indicator
- Shows RBDS (Radio Broadcast Data System) station name, radio text, and program type
- Displays PI (Program Identification) code and traffic program/alert flags
- Shows music/speech content type flag
- Backend already collected this data; now visible in UI
- VERSION bumped to 2.27.13 (feature)
Changed
- SDR Service Architecture Simplification - SDR service now uses separate venv with system site-packages
- Created
venv-sdrwith--system-site-packagesflag for direct python3-soapysdr access - This eliminates complex PYTHONPATH hacks that were fragile across Python versions
- Main web service venv remains isolated to avoid gunicorn/gevent conflicts
- SDR service uses
requirements-sdr.txt(minimal: redis, python-dotenv) - install.sh and update.sh automatically create/update venv-sdr
- Removed ~80 lines of PYTHONPATH detection code from install.sh and update.sh
- VERSION bumped to 2.27.12
- Created
Fixed
- SDR Airspy "No Match" Error - Fixed SoapySDR device opening failures for Airspy devices
- Fixed Airspy driver rejecting
labelparameter (not supported by Airspy SoapySDR module) - Increased retry attempts from 3 to 5 for "no match" errors
- Added Python 3.13 compatibility detection and error messages
- Improved install script verification of python3-soapysdr package
- Enhanced error messages with package installation instructions
- VERSION bumped to 2.27.11 (bug fix)
- Fixed Airspy driver rejecting
- Update Script Missing Dynamic Path Detection - Fixed update.sh not applying dynamic PYTHONPATH and SOAPY_SDR_PLUGIN_PATH
- Update script now detects Python site-packages paths (supports Python 3.10-3.13+)
- Update script now detects SoapySDR plugin directories
- Updates systemd service files with correct paths for current Python version
- Previously update.sh only copied static files, leaving old hardcoded paths
- Users must run
sudo ./update.shto apply the fix (not justgit pull) - This is why Airspy worked after
apt install airspybut still failed in the service - VERSION bumped to 2.27.10 (bug fix)
- Airspy SDR Not Opening - Fixed "Unable to open AirSpy device" error even with root/sudo access
- Added
airspypackage to installation (contains firmware and host utilities like airspy_info) - Previously only installed
libairspy0(library) andsoapysdr-module-airspy(SoapySDR plugin) - The
airspypackage provides critical firmware loading and device initialization - Without it, SoapySDR cannot open the device even with correct permissions
- Fixes error: "SoapySDR::Device::make() no match" for Airspy devices
- VERSION bumped to 2.27.9 (bug fix)
- Added
- SDR Not Working on Bare Metal - Fixed SoapySDR device detection failure on bare metal installations
- Install script now dynamically detects Python site-packages paths for all Python versions
- Install script now dynamically detects SoapySDR plugin module paths
- Updated systemd service to inject detected paths instead of using hardcoded paths
- Fixes "SoapySDR::Device::make() no match" errors caused by incorrect PYTHONPATH
- Fixes missing SoapySDR modules caused by incorrect SOAPY_SDR_PLUGIN_PATH
- SDR worked in Docker but failed on bare metal due to venv isolation from system packages
- Python venv doesn't use --system-site-packages to avoid conflicts with gunicorn/gevent
- SDR service now gets correct paths to apt-installed python3-soapysdr and soapysdr-module-airspy
- VERSION bumped to 2.27.8 (bug fix)
- SDR Service Audio Errors - Suppressed ALSA/PulseAudio/Jack errors in SDR hardware service logs
- Added
PULSE_SERVER=/dev/nullto prevent PulseAudio connection attempts - Added
SOAPY_SDR_LOG_LEVEL=WARNINGto reduce SoapySDR log noise - Enhanced
ALSA_CONFIG_PATH=/dev/nulldocumentation explaining why audio errors appear - These errors are caused by soapysdr-module-audio probing for audio devices during SDR enumeration
- The errors don't affect SDR functionality (USB radio devices work correctly)
- To completely eliminate errors, uninstall soapysdr-module-audio if not needed for audio streaming
- VERSION bumped to 2.27.7 (bug fix)
- Added
- EASMonitor Backwards Compatibility - Fixed TypeError when old code calls EASMonitor with deprecated parameters
- Added backwards compatibility for
audio_managerparameter (now accepts bothaudio_managerandaudio_source) - Added backwards compatibility for
save_audio_filesparameter (accepted but ignored) - Exported
ContinuousEASMonitoras an alias forEASMonitorfor test compatibility - Prevents "TypeError: EASMonitor.init() got an unexpected keyword argument 'audio_manager'" errors
- Allows deployed systems with cached Python bytecode to continue functioning during upgrades
- VERSION bumped to 2.27.6 (bug fix)
- Added backwards compatibility for
- Critical: Fixed Gunicorn Bypassing Database Initialization - Changed systemd service to use
wsgi:applicationinstead ofapp:app- Previously, gunicorn was loading app.py directly, bypassing all wsgi.py initialization code
- This caused lazy database initialization on first request, resulting in 504 Gateway Timeout errors
- Now uses wsgi.py as entry point, which eagerly initializes database before accepting requests
- Database initialization completes during worker startup, not during first HTTP request
- Eliminates race conditions where multiple workers try to initialize database simultaneously
- Web service now starts reliably without timeouts
- VERSION bumped to 2.27.5 (critical bug fix)
- WSGI Startup Honors Setup Mode - Fixed wsgi.py to respect setup mode when database is unavailable
- wsgi.py now checks if application is in setup mode before attempting database initialization
- Prevents attempting to initialize database when connectivity check already failed during app.py import
- Allows proper first-time setup workflow through /setup web UI
- Eliminates unnecessary duplicate database connection attempts and error messages
- VERSION bumped to 2.27.4 (bug fix)
- Web Service 504 Timeout - Fixed lazy database initialization causing 504 Gateway Timeout errors
- Database initialization moved from first-request to worker startup in wsgi.py
- Added eager database initialization when Gunicorn workers start
- Added TimeoutStartSec=90 to systemd service to allow initialization time
- Prevents health checks from timing out waiting for database schema creation
- Fixes "Main process exited, code=killed, status=9/KILL" systemd errors
- Enhanced error visibility: Clear error messages with troubleshooting steps
- Unbuffered stderr output for immediate visibility in journalctl
- Detailed error messages showing worker PID and specific failure reason
- Error log written to /tmp/eas-station-web-startup-error.log for persistence
- Helpful troubleshooting commands included in error output
- VERSION bumped to 2.27.3 (bug fix)
- Installation Script Error Handling - Added proper error checking for Python virtual environment creation
- Virtual environment creation now fails fast with clear error messages instead of silently continuing
- pip install commands now check for success and display detailed error logs on failure
- Prevents confusing errors later in installation when venv creation fails
- Logs saved to /tmp for debugging (venv-creation.log, pip-upgrade.log, pip-install.log)
- VERSION bumped to 2.27.2 (bug fix)
- Web Service Startup Timeout - Fixed Gunicorn workers blocking during database initialization
- Removed import-time database initialization that caused workers to timeout
- Database initialization now happens lazily on first request via before_request hook
- Uses thread-safe double-checked locking to ensure initialization happens exactly once
- Prevents 504 Gateway Timeout errors during service startup
- VERSION bumped to 2.27.2 (bug fix)
Improved
- Startup Logging - Enhanced diagnostic logging to help identify blocking issues during application startup
- Added startup banner showing process ID
- Added checkpoint logs at key initialization steps (✓ for success, ⊘ for skipped, ✗ for errors)
- Added module import completion log to confirm app.py loads successfully
- Helps diagnose silent failures that block Gunicorn workers
- Makes it clear in logs where startup process is hanging
- Web Service Startup Issue - Fixed systemd service configuration preventing web app from fully starting
- Changed
Type=notifytoType=simpleinsystemd/eas-station-web.service - Gunicorn with gevent workers doesn't support systemd notify protocol
- Service now starts correctly and systemd recognizes when it's ready
- VERSION bumped to 2.27.1 (bug fix)
- Changed
Added
- M-Protocol Phases 1-5: Complete Alpha LED Sign Control - Deep integration with Alpha LED signs via M-Protocol
- Phase 1: Sign Diagnostics - Read serial number, model, firmware, memory, temperature (Type F commands)
- Phase 2: Time/Date Control - Set time, date, format, run mode, sync with system (Type E commands)
- Phase 3: Speaker/Beep Control - Enable/disable speaker, beep for alerts (Type E commands)
- Phase 4: Brightness Control - Set 0-100% brightness or auto mode (Type E commands)
- Phase 5: File Management - Read text from file labels 0-9, A-Z (Type B commands)
- All functions implemented in
scripts/led_sign_controller.py - Comprehensive test scripts for each phase
- Complete documentation for all phases
- VERSION bumped to 2.27.0 (feature additions)
- Result: Full programmatic control of Alpha LED signs over network
- Note: Web UI integration into existing
/led-controlpage pending
- M-Protocol Phases 3-5: Complete Advanced Control - Final implementation of speaker, brightness, and file management
- Phase 3: Speaker/Beep Control
- Added
set_speaker()method to enable/disable speaker (Type E, Function 0x23) - Added
beep()convenience method to make sign beep for alerts - Audio alerts for emergencies and notifications
- Added
- Phase 4: Brightness Control
- Added
set_brightness()method to set brightness 0-100% or auto mode (Type E, Function 0x30) - Manual brightness control with percentage levels
- Auto brightness mode for ambient light adaptation
- Night mode and energy saving capabilities
- Added
- Phase 5: File Management
- Added
read_text_file()method to read text from file labels (Type B, Function 0x42) - Query current display content
- Read user-defined text files (0-9, A-Z)
- Added
- Added
ReadTextCommandenum for M-Protocol Type B function codes - Extended
WriteSpecialExtCommandenum with speaker and brightness functions - Created comprehensive test script
scripts/test_alpha_advanced.pyfor all advanced features - Added complete documentation in
docs/hardware/ALPHA_ADVANCED_PHASES3-5.md - Integration examples for emergency alerts, auto-dimming, and business hours automation
- M-Protocol implementation now production-ready with full sign control
- VERSION bumped to 2.26.0 (feature addition)
- Phase 3: Speaker/Beep Control
- M-Protocol Phase 2: Time/Date Control - Complete time and date management for Alpha LED signs
- Added
set_time_and_date()method to set sign time and date (Type E, Function 0x20) - Added
set_day_of_week()method to set day of week 0-6 (Type E, Function 0x22) - Added
set_time_format()method to set 12h/24h time format (Type E, Function 0x27) - Added
set_run_mode()method to set auto/manual operation mode (Type E, Function 0x2E) - Added
sync_time_with_system()convenience method to sync sign with EAS Station™ time - Added
WriteSpecialExtCommandenum for M-Protocol Type E function codes - All functions use bidirectional communication with ACK/NAK handling
- Created test script
scripts/test_alpha_timedate.pyfor testing time control - Added complete documentation in
docs/hardware/ALPHA_TIMEDATE_PHASE2.md - VERSION bumped to 2.25.0 (feature addition)
- Added
- M-Protocol Phase 1: Sign Diagnostics - Alpha LED signs can now be queried for status and configuration
- Added
read_serial_number()method to read sign serial number (Type F, Function 0x24) - Added
read_model_number()method to read sign model (Type F, Function 0x25) - Added
read_firmware_version()method to read firmware version (Type F, Function 0x26) - Added
read_memory_configuration()method to query memory usage (Type F, Function 0x30) - Added
read_temperature()method to read internal temperature (Type F, Function 0x35) - Added
get_diagnostics()method to fetch all diagnostic information at once - Added
_send_read_command()generic handler for Type F read commands - Added
ReadSpecialExtCommandenum for M-Protocol Type F function codes - Full bidirectional communication support verified and documented
- VERSION bumped to 2.24.0 (feature addition)
- Added
Fixed
- Systemd Target Cycling Issue - Fixed eas-station.target repeatedly stopping and starting
- Changed
Requires=toWants=for postgresql, redis, nginx dependencies in eas-station.target - Hard
Requires=dependencies were causing cascading restarts whenever PostgreSQL, Redis, or Nginx restarted - Added
PartOf=eas-station.targetto all EAS service files (web, sdr, audio, eas, hardware, poller) - Added
WantedBy=eas-station.targetto all EAS service files for proper target membership - Services now properly belong to the target and won't cause unnecessary restart cycles
- Soft
Wants=dependencies allow services to start even if dependencies are temporarily unavailable - VERSION bumped to 2.23.7 (bug fix)
- Changed
- CRITICAL: PostgreSQL username is eas_station (underscore) not eas-station (hyphen) - Fixed all references to use correct username
- Changed DATABASE_URL from
eas-stationtoeas_stationin .env.example, install.sh, webapp/admin/environment.py - Updated install.sh to create PostgreSQL user
eas_stationinstead of"eas-station" - Updated pg_hba.conf rules to use
eas_stationinstead of"eas-station" - Updated all GRANT statements to use
eas_station - User confirmed:
psql -U eas_stationworks,psql -U eas-stationfails - VERSION bumped to 2.23.6 (bug fix)
- Changed DATABASE_URL from
- ACTUAL ROOT CAUSE: Poller .env override mismatch - Fixed poller failing to load DATABASE_URL from .env file
- Changed
load_dotenv(override=False)toload_dotenv(override=True)in poller/cap_poller.py (lines 131, 138) - Poller was using override=False while app.py uses override=True, causing environment variable mismatch
- When poller imports from app.py, app.py's override=True happened AFTER poller's override=False
- This caused poller to use wrong/missing DATABASE_URL even though .env file was correct
- Other services work because they don't import app.py and use override=True directly
- VERSION bumped to 2.23.5 (bug fix)
- Changed
- Auto-fix Password Authentication in update.sh - update.sh now automatically syncs PostgreSQL password before running migrations
- Added password sync step in update.sh before database migrations (line 617)
- Runs
scripts/database/fix_database_user.shautomatically to sync password from .env to PostgreSQL - Prevents "password authentication failed" errors during migrations
- Made fix_database_user.sh completely non-interactive (removed confirmation prompt)
- Created fix_and_restart.sh for one-command fix with service restart
- VERSION bumped to 2.23.4 (bug fix)
- Password Authentication Root Cause Identified - Clarified that "password authentication failed" is a password mismatch, not network/IPv6 issue
- Updated documentation to explain that
OperationalErrormeans PostgreSQL rejected the password after connection succeeded - Changed DATABASE_URL defaults from
localhostto127.0.0.1to force IPv4 and improve consistency - Updated
.env.example,install.sh,webapp/admin/environment.py, andscripts/profile_poller.py - Added
docs/troubleshooting/PASSWORD_MISMATCH.mdwith detailed root cause analysis - Updated
QUICK_DATABASE_FIX.mdto emphasize running fix script to sync passwords - The fix script (
scripts/database/fix_database_user.sh) extracts password from .env and updates PostgreSQL - IPv6 (::1) connections work fine - the real issue is password mismatch between .env and PostgreSQL
- VERSION bumped to 2.23.3 (bug fix)
- Updated documentation to explain that
- Database Authentication Issues - Fixed database connection failures during migrations and poller service startup
- Changed poller service
EnvironmentFilefrom optional (-/opt/eas-station/.env) to required (/opt/eas-station/.env) - Ensures DATABASE_URL is always loaded from environment file, preventing fallback to incorrect database usernames
- Added
scripts/database/fix_database_user.shto clean up incorrectly named database users (e.g., "eas_station" vs "eas-station") - Script automatically reassigns ownership and drops incorrect users while preserving data
- Resolves "password authentication failed for user eas_station" errors in screen_manager and migrations
- VERSION bumped to 2.23.2 (bug fix)
- Changed poller service
- CAP Poller Service ModuleNotFoundError - Fixed "No module named 'redis'" error on bare metal installations
- Updated
systemd/eas-station-poller.serviceto use virtual environment Python interpreter - Changed
ExecStartfrom/usr/bin/python3to/opt/eas-station/venv/bin/python - Added
PATH=/opt/eas-station/venv/bin:...environment variable - Added
PYTHONPATH=/opt/eas-stationenvironment variable - Aligns poller service with other services (web, audio, eas, sdr, hardware)
- Resolves issue where system Python lacked required dependencies (redis, pytz, etc.)
- VERSION bumped to 2.23.1 (bug fix)
- Updated
Changed
- CAP Poller Refactored - Removed non-polling responsibilities from CAP poller service
- Removed direct EAS broadcasting (now handled by eas-service via Redis)
- Removed direct LED sign control (now handled by dedicated service via Redis)
- Removed direct radio/SDR capture coordination (now handled by sdr-service via Redis)
- Added Redis event publishing for alert events (
alerts:new,alerts:led:*,alerts:broadcast_only) - Removed command-line arguments:
--led-ip,--led-port,--radio-captures - Removed
__init__parameters:led_sign_ip,led_sign_port,enable_radio_captures - Removed imports:
EASBroadcaster,load_eas_config,RadioManager,LEDSignController - Removed methods:
_setup_radio_manager(),_refresh_radio_configuration(),_coordinate_radio_captures(),_record_receiver_statuses(),update_led_display() - Poller now focuses solely on: polling feeds, parsing responses, checking location matches, saving to database, and publishing events
- VERSION bumped to 2.23.0 (architecture refactoring)
Fixed
- Certificate Status API Fetch Error - Fixed "Unexpected token '<'" error in Security Settings page
- Added
Accept: application/jsonheader to/security/ssl-certificatefetch() call (line 705 in security_settings.html) - Prevents HTML redirect response when authentication fails, ensuring proper JSON error handling
- Resolves TypeError when backend returns HTML instead of expected JSON
- Added
- Missing radio_captures Directory - Fixed systemd namespace mounting error for eas-station-poller service
- Added creation of
/opt/eas-station/radio_capturesdirectory in install.sh - Directory is required by
ReadWritePathsin systemd/eas-station-poller.service (line 30) - Prevents "Failed to set up mount namespacing" errors on fresh installations
- Added creation of
- Database Migration Documentation - Added clarifying comments to update.sh database migration
- Documented that migrations use DATABASE_URL from .env file (not hardcoded postgres username)
- Clarified that
from app import app, dbloads credentials from environment via os.getenv() - Ensures understanding that correct database user (e.g., eas_station) is used automatically
- VERSION bumped to 2.22.1 (bug fixes)
Added
- SSL Certificate Status UI - Added comprehensive certificate viewer to Security Settings page
- Shows certificate type (Let's Encrypt, Self-Signed, or None)
- Displays validity status with color-coded badges (Valid, Expiring Soon, Expired)
- Shows domain, issuer, valid from/until dates, and days remaining
- Displays certbot automatic renewal timer status and next check time
- Provides warnings for expiring (≤30 days) or expired certificates
- Includes helpful guidance for self-signed certificates and renewal procedures
- Auto-refreshes on page load with manual refresh button
- Added API endpoint
/security/ssl-certificateto retrieve certificate data - Created
app_core/ssl_utils.pywith certificate parsing utilities - Added "Security Settings" link to user dropdown menu for easy access
- VERSION bumped to 2.22.0 (new feature)
Fixed
- Certbot systemd service missing - Added certbot.service and certbot.timer to systemd directory
- Install script was trying to enable certbot.timer but the systemd files didn't exist
- Created proper systemd service for certificate renewal with nginx reload hook
- Timer runs twice daily (00:00 and 12:00) with randomized delay for load distribution
- Both install.sh and update.sh now copy .timer files to /etc/systemd/system/
- Fixes silent failure of automatic certificate renewal on deployed systems
- VERSION bumped to 2.21.11
- Poller service user credentials - Fixed incorrect username in
systemd/eas-station-poller.service- Changed
User=easstationtoUser=eas-station(missing dash) - Changed
Group=easstationtoGroup=eas-station(missing dash) - Service was failing with exit code 217/USER: "Failed to determine user credentials: No such process"
- All other services correctly use
eas-stationuser/group created by install script
- Changed
- Hardware service supplementary groups - Fixed install script and update script to create missing system groups
- Both
install.shandupdate.shnow create gpio, i2c, spi, audio, plugdev, dialout groups if they don't exist - Hardware service was failing with exit code 216/GROUP: "Failed to determine supplementary groups: No such process"
- Ensures turnkey installation works on all systems without pre-existing hardware groups
- Running
update.shwill automatically fix this issue on deployed systems - Critical for hardware-based features (GPIO, I2C, SPI devices, USB serial, audio)
- Both
- VERSION bumped to 2.21.10
- EAS service crash - Fixed
TypeError: create_fips_filtering_callback() got an unexpected keyword argument 'flask_app'- Updated
eas_service.pyto use correct callback pattern withforward_callbackparameter - Added proper alert forwarding handler using
forward_alert_to_apifromapp_core.audio.alert_forwarding - Pattern now matches working implementation in
eas_monitoring_service.py
- Updated
- Log message truncation - Added
--allflag to journalctl command to prevent message truncation- Service logs (systemd) now show full message content instead of truncated ellipsis (...)
- Enhanced CSS for
.log-messageto properly wrap long messages withword-break,white-space, andoverflow-wrap - Improved copy function to explicitly get text from
.log-messagespan for more reliable copying
- Poller service not starting - Fixed systemd target to reference unified poller service
- Updated
systemd/eas-station.targetto useeas-station-poller.serviceinstead of obsoleteeas-station-noaa-poller.serviceandeas-station-ipaws-poller.service - The unified poller (introduced in 2.20.0) was not being started because the target file referenced the old split poller services
- This caused alert polling to fail silently on fresh installs and updates
- Updated
- VERSION bumped to 2.21.9
Changed
- .env.example minimized - Removed all comments and obsolete variables
- Reduced from 373 lines to 36 lines
- Kept only essential variables needed for operation
- No explanatory comments - configuration is managed via web UI
- ALL database connections use DATABASE_URL - Removed POSTGRES_* variables completely
- Format:
DATABASE_URL=postgresql+psycopg2://username:password@host:port/database - Updated ALL service files: app.py, fastapi_app.py, poller/cap_poller.py, hardware_service.py, sdr_hardware_service.py, eas_service.py, eas_monitoring_service.py
- Updated utility scripts: run_eas_broadcaster.py, run_radio_manager.py
- install.sh now writes DATABASE_URL instead of individual POSTGRES_* variables
- Format:
- VERSION bumped to 2.21.7
Fixed
- Systemd logs permission error - Added missing
osimport inwebapp/routes_logs.py - Install script systemd-journal group - Automatically adds service user to
systemd-journalgroup
Removed
- POSTGRES_USER, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_PASSWORD variables (replaced by DATABASE_URL)
- All verbose comments from .env.example (373 lines → 36 lines)
- Obsolete environment variables no longer used by the application
[2.21.0] - 2025-12-12
Changed
- Environment variable consolidation - Reduced from 93 to 73 variables by consolidating related settings
MAIL_URLreplaces 5 mail variables (MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, MAIL_USE_TLS)LOCATION_CONFIG(JSON) replaces 9 location variables (DEFAULT_TIMEZONE, DEFAULT_COUNTY_NAME, DEFAULT_STATE_CODE, DEFAULT_ZONE_CODES, DEFAULT_FIPS_CODES, DEFAULT_STORAGE_ZONE_CODES, DEFAULT_MAP_CENTER_LAT, DEFAULT_MAP_CENTER_LNG, DEFAULT_MAP_ZOOM)ICECAST_CONFIG(JSON) replaces 5 Icecast auth variables (ICECAST_SOURCE_PASSWORD, ICECAST_RELAY_PASSWORD, ICECAST_ADMIN_USER, ICECAST_ADMIN_PASSWORD, ICECAST_ADMIN)ICECAST_INTERNAL_URLandICECAST_PUBLIC_URLreplace 4 connection variables (ICECAST_SERVER, ICECAST_PORT, ICECAST_EXTERNAL_PORT, ICECAST_PUBLIC_HOSTNAME)AZURE_OPENAI_CONFIG(JSON) replaces 5 Azure OpenAI variables (AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY, AZURE_OPENAI_MODEL, AZURE_OPENAI_VOICE, AZURE_OPENAI_SPEED)
- VERSION bumped to 2.21.0
Fixed
- Copy button label on logs page changed from "Copy Logs" to "Copy" for clarity
- CSV export button relabeled to "Excel" to match user terminology
- CSV export icon changed from
fa-file-csvtofa-file-excel - Update script (
update.sh) now properly displays VERSION file contents instead of showing "unknown" - Update script now prioritizes VERSION file over git commit hash for version display
- Updated POLLER_CONFIG_MIGRATION.md to clarify unified poller architecture
- Removed outdated references to separate
ipaws.envandnoaa.envfiles (no longer used in 2.20+) - Added troubleshooting section for "IPAWS.env not found" error
Changed
- VERSION bumped to 2.20.2
[2.20.1] - 2025-12-11
Changed
- Enhanced PyCharm integration documentation - Added comprehensive field-by-field setup instructions in
docs/guides/PYCHARM_DEBUGGING.md - Added detailed tables for every configuration dialog with all fields explained
- Added example values for each field with explanations
- Added step-by-step Database Tools (DataGrip) configuration
- Added step-by-step Debug Configuration setup for AI coding assistants
- Added validation checklist for complete PyCharm setup
- Added "Quick Start" section with essential settings table for rapid configuration
- Added comprehensive GitHub Copilot integration section - Detailed comparison with zencoder.ai
- Added GitHub Copilot setup instructions for PyCharm and VS Code
- Added capability comparison table (Copilot vs zencoder.ai)
- Added workflow examples showing how to use GitHub Copilot Chat effectively
- Added recommendations for when to use each AI assistant
- Updated document title to cover all AI coding assistants, not just zencoder.ai
- Added Wing IDE integration section - Complete setup guide for Wing Professional Python IDE
- Added Wing IDE to Quick Start settings table with remote host configuration
- Added Wing IDE as Option C in IDE selection with pros/cons
- Added Wing IDE remote development capabilities and AI assistant support
- Added comprehensive zencoder.ai integration section with API setup and testing steps
- Added scenario-based examples showing zencoder.ai workflow (bug fixing, features, optimization)
- Added verification tests for each capability (files, Python, database, services, logs)
- Improved clarity on which fields are required vs optional
- Added troubleshooting references for each configuration step
[2.20.0] - 2025-12-11
Added
- Complete whiptail-based setup - All installation inputs now use professional TUI interface
- FIPS code checklist interface - Select multiple counties at once instead of typing
- Radio button menus - EAS Originator code selection with predefined options (WXR, EAS, PEP, etc.)
- Consistent branding - Copyright and license info on all whiptail dialogs throughout install.sh
- FIPS management in eas-config - Configure FIPS codes post-installation with same checklist UI
- Branding footer function for consistent copyright/license display
- Improved Python environment detection for FIPS lookup during installation
Changed
- FIPS lookup workflow - Now shows full county list with checkboxes instead of search-based approach
- EAS Originator input changed from text entry to radio button selection for better validation
- All whiptail dialogs now include branding footer with copyright and license information
- Improved error messaging when Python environment isn't available during installation
- Enhanced user experience with clearer instructions and better dialog sizing
Fixed
- FIPS lookup now gracefully handles case where Python dependencies aren't installed yet
- Better validation for all user inputs with helpful error messages
- Consistent dialog widths and heights for better readability
[2.19.12] - 2025-12-11
Fixed
- Fixed logs page to display ALL logs instead of only categorized logs
- Fixed Bootstrap modal aria-hidden accessibility warnings when adding/editing audio sources
- Fixed potential focus trap issues preventing interaction with audio source modals
- Fixed missing firewall rules for Icecast streaming (port 8000)
Added
- Added "All Logs" tab as the default view with organized category sections
- Added collapsible accordion sections for each log category (System, Polling, Audio, GPIO, EAS Messages, etc.)
- Added category badges showing log count per category
- Logs now organized by category instead of mixed together chronologically
- Added automatic firewall configuration for Icecast port 8000 during installation
- Added firewall configuration summary in installation completion message
- Added instructions for optionally opening PostgreSQL port with security warnings
Changed
- Default logs view changed from "System" to "All Logs" for better visibility
- "All Logs" view now uses accordion with category grouping for better organization
- Modal elements now properly blur focus before hiding to prevent accessibility issues
- Improved log readability by separating logs into logical categories
- Installation now automatically opens port 8000 for Icecast if enabled
- Firewall status display now shows configured ports
[2.19.11] - 2025-12-10
Fixed
- Fixed pgAdmin 4 installation failures by adding better error handling and --allow-downgrades flag
- Fixed pgAdmin installation to gracefully skip if it fails, allowing installation to continue
- Added error detection and informative messages for pgAdmin installation issues
Added
- PostgreSQL password now displayed during installation for easy IDE/pgAdmin access
- Added comprehensive database credentials section in installation completion message
- Shows full PostgreSQL connection details (host, port, database, username, password)
- Added instructions for viewing password later:
sudo grep POSTGRES_PASSWORD /opt/eas-station/.env - Enhanced installation progress messages with detailed package lists
- Added informative descriptions of what each installation step does
- More aesthetic progress indicators showing estimated time and package counts
- pgAdmin access instructions (if successfully installed) in completion message
- Separate database credentials section with security warnings
Changed
- Installation completion message now includes full database credentials for IDE access
- Made install.sh significantly more informative and user-friendly
- Enhanced progress messages to show what packages are being installed
- Improved visual hierarchy in completion message sections
- pgAdmin configuration skips gracefully if installation failed
- Database password warning emphasizes saving credentials (only shown once)
[2.19.10] - 2025-12-10
Fixed
- Removed Docker/container monitoring from system health in favor of systemd service monitoring
- System health now queries systemd services directly using systemctl for accurate bare metal deployment status
- Updated system health template to display systemd services instead of Docker containers
Added
- Systemd service monitoring for all EAS Station™ services (web, sdr, audio, eas, hardware, noaa-poller, ipaws-poller)
- Dependency service monitoring (nginx, postgresql, redis-server, icecast2)
- Service status categorization (active, inactive, failed) with visual indicators
- Separate display sections for EAS Station™ services vs. system dependencies
Changed
- Replaced _collect_container_statuses() with _collect_systemd_services() in system.py
- System health data structure now uses "systemd" key instead of "containers"
- Service monitoring now uses native systemctl commands instead of Docker API
- Health dashboard shows systemd service status with active/inactive/failed states
[2.19.9] - 2025-12-10
Fixed
- Reduced excessive whitespace between navbar and page content by decreasing --layout-padding-top from 1.5rem to 0.5rem
- Fixed NOAA_USER_AGENT validation error by adding default value in environment.py configuration
- Fixed environment validation to check default values before reporting "required but not set" errors
- Updated setup wizard configuration persistence notice to remove Docker/container-specific references
- Changed setup wizard text to reflect bare metal deployment with /app-config/.env persistent volume
- Removed Docker-specific terminology from about.html (changed "containers" to "services")
- Removed Docker-specific terminology from admin.html (container references, --network=host flag)
- Updated admin panel text to be deployment-agnostic (removed "inside the app container" references)
- Removed hardcoded version number from NOAA_USER_AGENT default value to prevent version drift
- Changed "System Reinstall" to "Fresh Installation" in setup wizard for clarity
Changed
- Updated setup wizard to show accurate configuration persistence behavior for bare metal deployments
- Environment validation now respects default values defined in ENV_CATEGORIES when checking required fields
- About page now uses deployment-agnostic terminology for service architecture
- Admin panel now uses terminology appropriate for both Docker and bare metal deployments
- NOAA_USER_AGENT default value no longer includes version number (simplified to "EAS Station™")
[2.19.8] - 2025-12-10
Changed
- Completely rewrote PyCharm/VS Code debugging guide for bare metal deployment
- Removed all Docker/container references, replaced with systemd service instructions
- Updated all file paths from /home/pi/eas-station to /opt/eas-station
- Added comprehensive section on debugging individual systemd services with debugpy
- Added detailed instructions for using AI coding agents (ZenCoder) with real-time code access
- Updated database configuration section for bare metal PostgreSQL (not containerized)
- Added multiple methods for enabling debugpy: temporary, persistent, and code modification
- Documented debug port assignments for all services (5678-5684)
- Added SSH port forwarding instructions for secure remote debugging
- Updated troubleshooting section with systemd-specific solutions
- Enhanced development workflow with systemd service management commands
- Added section on testing with real hardware (GPIO, SDR, audio devices)
- Updated quick reference with systemd commands instead of Docker commands
- Improved summary to highlight AI agent integration and bare metal advantages
Security
- Added security warnings for exposing debugpy ports on all network interfaces
- Documented SSH port forwarding as secure alternative to opening firewall ports
- Improved PostgreSQL remote access documentation with security best practices
- Restricted sudoers examples to specific services and journalctl units only
- Clarified user permissions for AI agent integration with minimal necessary access
[2.19.7] - 2025-12-10
Fixed
- Fixed pgAdmin4 installation to prevent apache2 from being installed as a dependency
- Added python3-typer package installation to resolve "ModuleNotFoundError: No module named 'typer'" in pgAdmin setup
- Added apt preferences to block apache2 packages during pgadmin4 installation
- Added automatic apache2 masking and removal if already installed
- Fixed remote access by configuring UFW firewall to allow ports 80 (HTTP) and 443 (HTTPS)
- Added firewall configuration section in install.sh with proper UFW setup
- Improved installation reliability with better dependency management
[2.19.6] - 2025-12-10
Changed
- Enhanced install.sh with improved visual design and user experience
- Added colorful banner, progress indicators, and step counters
- Enhanced completion message with detailed component access instructions
- Added comprehensive post-installation checklist with actionable items
- Improved readability with emoji icons, better spacing, and color-coded sections
- Added detailed connection instructions for all components (web UI, pgAdmin, PostgreSQL, Redis)
- Included useful commands for backup, restore, SSL setup, and troubleshooting
- Suppressed verbose output from package installations for cleaner display
[2.19.5] - 2025-12-10
Fixed
- Fixed PostgreSQL authentication configuration in
install.shto allow password-based connections - Added
pg_hba.confconfiguration to enablescram-sha-256authentication foreas_stationuser - Updated
scripts/database/fix_database_permissions.shto also configure PostgreSQL authentication - Resolves "password authentication failed for user eas_station" errors during installation
[2.19.4] - 2025-12-10
Changed
- Updated architecture documentation to reflect bare-metal systemd deployment
- Replaced "container" terminology with "service" or "process" in architecture docs
- Replaced "Docker" references with "systemd service" or "bare-metal" as appropriate
- Updated mermaid chart labels in
SYSTEM_ARCHITECTURE.mdfrom container to service - Updated
HARDWARE_ISOLATION.mdwith systemd service terminology and journalctl commands - Updated
DATA_FLOW_SEQUENCES.mdto reflect systemd service architecture - Aligned all documentation with ISO_BUILD_READY.md bare-metal migration status
[2.19.3] - 2025-12-10
Removed
- Removed
bugs/directory (7.7MB) - Development-only bug tracking screenshots - Removed
bare-metal/directory (164KB) - Redundant transition documentation already indocs/ - Removed screenshots and logos from
samples/(1.7MB saved) - Keep only EAS test audio files - Moved one-off bug reproduction tests to
tests/bug_reproductions/(excluded from ISO):test_smoking_gun_proof.py- OLED scrolling bug verificationtest_nvme_samsung_990_pro.py- NVMe performance testingtest_snow_emergency_public_access.py- Snow emergency alert bugtest_oled_scroll_optimization.py- OLED optimization teststest_oled_render_bounds.py- OLED boundary tests
Added
- Added
samples/README.mddocumenting EAS test audio files and their purpose - Added comprehensive
legacy/README.mdexplaining Docker-era scripts and their bare-metal replacements - Added
tests/bug_reproductions/README.mdexplaining one-off test files
Changed
- Updated
install.shto exclude development directories:bugs/,legacy/,bare-metal/,tests/bug_reproductions/ - Updated
.gitignoreto excludebugs/andtests/bug_reproductions/from version control - Cleaned samples directory to ~6.2MB (only EAS audio test files remain)
[2.19.2] - 2025-12-10
Removed
- Removed Docker daemon health check from
/health/dependenciesendpoint - Moved
scripts/diagnose_cpu_loop.shtolegacy/(Docker-specific) - Moved
scripts/diagnostics/diagnose_portainer.shtolegacy/(Docker-specific) - Moved
scripts/collect_sdr_diagnostics.shtolegacy/collect_sdr_diagnostics_docker.sh(Docker-specific)
Added
- Added Redis server health check to
/health/dependenciesendpoint - Created new bare-metal version of
scripts/collect_sdr_diagnostics.shusing systemd and native tools
Changed
- Updated
webapp/routes_monitoring.pyto check Redis instead of Docker daemon - Updated comment in
webapp/routes_settings_radio.pyto remove Docker architecture reference - SDR diagnostics now use systemd service status and journalctl for logs instead of Docker commands
[2.19.1] - 2025-12-10
Removed
- Removed unnecessary files from document root:
ipaws.env.example,noaa.env.example,pytest.ini,requirements-docs.txt - Removed legacy SQL diagnostic files from root:
fix_all_stream_sample_rates.sql,fix_sample_rates.sql,diagnose_all_streams.sql,check_db_config.sql - Moved Docker-era troubleshooting scripts to
legacy/directory - Removed Docker references from documentation
Changed
- Updated
docs/installation/INSTALLATION_DETAILS.mdto remove Docker references - Updated
docs/troubleshooting/AUDIO_SQUEAL_FIX.mdto note it's for legacy Docker deployments - Updated
scripts/README.mdto remove references to deleted SQL files - Updated
webapp/routes_ipaws.pyto use systemd commands for service restarts instead of Docker - Updated
webapp/routes_monitoring.pyto remove docker-compose.yml from configuration checks
Added
- Added Frontend-First Philosophy to AI agent guidelines: All system management must be web-accessible
- Added CLI-Free Operations requirement: Users should never need SSH or command-line access
- Documented existing web UI features for logs, configuration, services, and troubleshooting
[2.19.0] - 2025-12-10
Changed
- Updated troubleshooting guides to use systemd commands exclusively
- Updated architecture documentation to reflect bare-metal deployment
- Simplified migration guides to focus on bare-metal setup
[2.18.0] - 2025-12-10
Changed
- Updated README.md to focus on bare metal deployment via systemd services
- Configuration now uses
/opt/eas-station/.envas standard location - Services managed via systemd:
sudo systemctl [start|stop|restart] eas-station.target
Removed
- Stack configuration:
stack.env,stack.env.example
Fixed
- Maintenance API uses standard filesystem paths instead of container paths
Migration
- Complete installation guide available in
bare-metal/README.md - Quick start guide available in
bare-metal/QUICKSTART.md
[2.17.2] - 2025-12-09
Fixed
- EAS Monitor Display Issues: Fixed decoding rates showing >100% and display bouncing between states
- Root cause 1: Rate calculation
samples_per_secondwas sensitive to timing variations and could spike >100% - Root cause 2: During startup (first 2 seconds), rate calculation reported 0, triggering "no audio" warnings
- Root cause 3: Frontend hysteresis (2 consecutive readings) wasn't enough to prevent flicker at 100ms WebSocket rate
- Fix 1: Added exponential moving average (EMA) smoothing with alpha=0.3 to filter timing noise
- Fix 2: Implemented 2-second minimum sample threshold - report expected rate during warmup instead of 0
- Fix 3: Health percentage grows linearly 0-95% during warmup for smooth visual feedback
- Fix 4: Increased frontend hysteresis from 2 to 5 consecutive readings (500ms stability required)
- Fix 5: Properly clamp health_percentage to [0, 1] range in all code paths
- Result: Rates never exceed 100%, smooth warmup transition, no state bouncing
- Root cause 1: Rate calculation
Changed
- Code Quality: Extracted magic numbers to named class constants for easier configuration
WARMUP_DURATION_SECONDS = 2- Duration of warmup periodWARMUP_MAX_HEALTH_PERCENTAGE = 0.95- Maximum health shown during warmupRATE_SMOOTHING_ALPHA = 0.3- EMA smoothing factor (lower=smoother, higher=more responsive)AUDIO_FLOWING_STABILITY_THRESHOLD = 5- Frontend consecutive readings before state change- Improves maintainability and makes performance tuning easier
[2.17.1] - 2025-12-09
Fixed
- CRITICAL: WebSocket Support Broken: Fixed Flask-SocketIO async_mode mismatch that prevented WebSockets from working
- Root cause:
app.pyusedasync_mode='threading'but gunicorn uses--worker-class gevent - This mismatch caused WebSockets to FAIL SILENTLY and fall back to long-polling
- Fix: Changed
async_mode='threading'toasync_mode='gevent'to match gunicorn worker class - Impact: Enables real-time WebSocket updates at 10Hz (100ms) instead of 1-2 second polling intervals
- This fixes why the entire site was polling despite WebSocket infrastructure being present
- Root cause:
- UI White Space: Fixed excessive white space at top of pages caused by
flex: 1on.page-shell- Root cause: Flexbox layout with
flex: 1caused content to expand and fill all vertical space - Fix: Removed
flex: 1from.page-shell- footer'smargin-top: autohandles sticky footer - Result: Pages now start content immediately after navbar without huge gaps
- Root cause: Flexbox layout with
- EAS Monitor Status Flickering: Fixed continuous toggling between "Processing at line rate" and "No audio sources configured"
- Root cause:
audioFlowingstate changed on every momentary fluctuation in audio metrics - Fix: Added hysteresis mechanism requiring 3 consecutive stable readings before changing state
- Result: Status display is now stable and only changes after sustained state change
- Root cause:
Changed
- WebSocket Infrastructure: Audio monitoring page already uses WebSockets when available
- VU meters, EAS monitor, and broadcast stats all receive real-time updates via WebSocket
- System automatically falls back to polling only if WebSocket connection fails
- With this fix, WebSockets should now work properly and polling fallback won't be needed
Notes
- This fixes the root cause of why 10+ previous agent sessions couldn't solve the white space issue
- The white space issue was subtle -
flex: 1is a common flexbox pattern but caused unwanted expansion - The WebSocket issue explains why 32 setInterval() polling calls exist throughout the codebase
- Future work: Extend WebSocket push service to broadcast all data types (alerts, system health, etc.) to eliminate remaining polling
[2.16.5] - 2025-12-09
Fixed
- Application Startup Failure: Fixed unterminated triple-quoted string literal in
webapp/admin/audio_ingest.pyat line 2237- Root cause: Docstring for legacy
generate_wav_stream()function was never closed - This prevented database migrations from running and caused gunicorn workers to crash on startup
- Fix: Properly closed the docstring and commented out the legacy code inside the function
- Root cause: Docstring for legacy
[2.16.3] - 2025-12-09
Fixed
- EAS Monitor Runtime Display: Fixed runtime timer showing "0s" and buffer bar not filling on Audio Monitoring page
- Root cause: API endpoint was not passing
wall_clock_runtime_secondsfrom the audio-service metrics - Fix: Added
wall_clock_runtime_secondsto the API response inroutes_eas_monitor_status.py
- Root cause: API endpoint was not passing
- Audio Detail Page Error: Fixed "Unable to load audio detail at this time" error when viewing IPAWS-generated alerts
- Root cause: Template used
url_for('alert_detail', ...)but the route is on theapiblueprint - Fix: Changed to
url_for('api.alert_detail', ...)inaudio_detail.html
- Root cause: Template used
- Layout Spacing: Reduced global
--layout-padding-topfrom1.5remto0.5remto minimize gap between navbar and content - Audit Logs UI: Fixed stat-card styling conflict where global vibrant gradient styles were overriding the audit logs page local styles
- Added more specific CSS selectors (
.stats-row .stat-card) to ensure local styles take precedence - Used
!importantflags to override global pseudo-elements that added shimmer/glow effects - Stats row now displays with correct neutral background instead of colorful mesh gradient
- Added more specific CSS selectors (
[2.16.2] - 2025-12-09
Fixed
- Code Quality: Fixed bare
except:clauses in multiple files for PEP 8 compliance:scripts/run_radio_manager.py: Added proper exception logging during cleanupdebug_airspy.py: Changed bareexcept:toexcept Exception:with comments
- Defensive Coding: Added None checks for
fetchone()calls in migration and utility scripts:scripts/apply_source_type_migration.py: Safe handling when column check returns no resultapp_core/migrations/versions/20251105_add_rbac_and_mfa.py: Safe handling when INSERT RETURNING failsapp_core/migrations/versions/20251116_populate_oled_example_screens.py: Safe handling for screen insert
Notes
- Architecture Review: Reviewed all 17 bugs from ARCHITECTURE_REVIEW_BUGS.md - most critical bugs (1-14) were already fixed in codebase
- Remaining bugs are low-priority design issues or already addressed
[2.16.1] - 2025-12-09
Fixed
- Dashboard Layout: Removed duplicate
page-shellclass from dashboard container that caused large gap at top of page- Root cause:
page-shellwas applied to both<main>in base.html and inner container in index.html - This resulted in double top padding (from both elements)
- Fix: Removed redundant
page-shellclass from inner<div class="container-fluid">in index.html
- Root cause:
[2.16.0] - 2025-12-08
Changed
- BREAKING: Service Renaming - Clean Architecture
- Renamed
audio_service.py→eas_monitoring_service.py(reflects actual purpose) - Renamed
sdr_service.py→sdr_hardware_service.py(clarifies exclusive hardware access) - Why: Old names were confusing and led to architectural mistakes
- No backward compatibility wrappers - clean break for clarity
- Renamed
Files Changed
eas_monitoring_service.py: New name for EAS monitoring + audio processing servicesdr_hardware_service.py: New name for SDR hardware access serviceRENAME_SERVICES.md: Updated to reflect completed rename- Old files (
audio_service.py,sdr_service.py) removed completely
Deployment Notes
sdr-service:
command: ["python", "sdr_hardware_service.py"] # WAS: sdr_service.py
audio-service:
command: ["python3", "eas_monitoring_service.py"] # WAS: audio_service.py
Then rebuild and restart:
[2.15.5] - 2025-12-08
Fixed
- CRITICAL: Complete SDR Hardware Separation: Removed ALL SDR hardware access from audio-service.py
- Root Cause: Both audio-service and sdr-service were fighting for USB access to SDR hardware
- Removed
initialize_radio_receivers()functionality from audio-service (kept stub for backward compat) - Removed RadioManager initialization and all
_radio_managerreferences - Removed process_commands() SDR hardware operations (restart, get_spectrum, discover_devices)
- Removed collect_metrics() radio_manager stats collection
- Removed spectrum publishing loop with direct IQ sample access
- Result: audio-service.py now ONLY subscribes to Redis channels from sdr-service
- Impact: SDR hardware access is now exclusive to sdr-service.py container
- Why SDR Never Worked: Both containers tried to open same USB devices → conflict
- Fixed audio_sample_rate handling - now uses explicit setting or auto-detects from modulation
- Verification: Check logs show sdr-service publishing and audio-service subscribing
[2.15.4] - 2025-12-08
Fixed
- Code Quality: Removed Bare Except Statements: Fixed 4 bare
except:statements that could mask errorsapp_core/audio/eas_monitor.py: Database rollback and SAME header parsing now log errorsapp_core/audio/streaming_same_decoder.py: Message validation errors now logged at debug levelapp_core/audio/worker_coordinator_redis.py: Redis connection close errors now logged- All exceptions now specify expected types (IndexError, AttributeError, Exception)
- Improves debugging by making error paths visible in logs
- Follows Python best practices for exception handling
- Impact: Better error visibility and easier troubleshooting
[2.15.3] - 2025-12-08
Fixed
- CRITICAL: Multi-Stream EAS Monitoring (LP1, LP2, SP1): Implemented per-source EAS monitoring
- Root Cause: EAS monitor only listened to ONE audio source at a time (highest priority)
- AudioIngestController.broadcast_pump selected only the highest priority running source
- Main broadcast queue received audio from only ONE source, others were ignored
- Result: LP1, LP2, SP1 web streams ran successfully but only ONE was monitored for EAS
- Fix: Changed from single EAS monitor to per-source monitors (one for each stream)
- Each audio source now has its own dedicated EAS monitor instance
- All sources monitored simultaneously for SAME/EAS alerts
- Alerts include source name in metadata for proper attribution
- Why IPAWS worked: IPAWS uses internet polling (cap_poller.py), not audio monitoring
- Enhanced logging shows which sources are being monitored
- Proper shutdown handling for multiple monitor instances
- Metrics collection aggregates stats from all monitors
- Impact: Fixes complete loss of EAS monitoring from multiple web streams
- Applies to: All deployments monitoring multiple audio sources (streams or SDR)
[2.15.2] - 2025-12-08
Fixed
- CRITICAL: Audio Chain for SDR Sources (LP1, LP2, SP1): Fixed missing audio pipeline for SDR-based EAS monitoring
- Added automatic audio source synchronization on audio-service startup
- Previously, audio sources for radio receivers weren't created automatically, breaking the audio chain
- In separated architecture, sdr-service publishes IQ samples to Redis, but audio-service needs AudioSourceConfigDB entries
- Without these entries, RedisSDRSourceAdapter instances weren't created, preventing audio from reaching EAS monitor
- New
sync_radio_receiver_audio_sources()function ensures audio sources exist for all enabled receivers - Sets critical
managed_by='radio'flag to trigger Redis adapter creation - Enhanced logging shows receiver details, subscription channels, and startup status
- Affects LP1, LP2, SP1 and any other SDR receivers with audio_output=True
- Impact: Fixes complete loss of EAS monitoring from local/state primary SDR sources
Added
- Diagnostic Tools: Created comprehensive audio chain diagnostic utilities
diagnose_audio_chain.py- Full audio chain health check from SDR to EAS monitorfix_audio_source_sync.py- Manual audio source sync tool with dry-run support- Both tools check receivers, audio sources, Redis connectivity, and IQ sample flow
[2.15.1] - 2025-12-08
Fixed
- Template Consistency: Fixed deprecated block usage in zigbee.html template, resolving CI failures
- Changed
templates/settings/zigbee.htmlfrom deprecated{% block extra_js %}to standard{% block scripts %} - Ensures all templates consistently use the
scriptsblock for page-specific JavaScript - Fixes template consistency check CI workflow that was failing
- Changed
[2.15.0] - 2025-12-08
Added - Phase 3: Professional Polish & UX Enhancements
- Enhanced Error Messages: Context-aware troubleshooting hints for network operations
- Intelligent error parsing with user-friendly explanations
- Specific hints based on error type (connection, scan, configuration)
- Technical details available for advanced users
- Common network issues with actionable solutions
- Hostname Configuration: Full system hostname management via NetworkManager
- View current system hostname in Status tab
- Set hostname with RFC 1123 validation
- Persistent across reboots using hostnamectl
- Real-time validation and feedback
- Signal Strength Color Coding: Visual quality indicators for WiFi networks
- Red (0-25%): Poor signal strength
- Orange (26-50%): Fair signal strength
- Green (51-75%): Good signal strength
- Teal (76-100%): Excellent signal strength
- Information Tooltips: Context-sensitive help throughout network UI
- Bootstrap tooltips explaining technical terms (DHCP, Static IP, CIDR, DNS, Gateway)
- Help icons next to complex form fields
- Example values for IP addresses and network settings
- Popular DNS server recommendations (Google, Cloudflare, Quad9)
- Password Validation & Strength Indicator: Real-time WiFi password feedback
- WPA2/WPA3 validation (8-63 characters)
- Visual strength indicator with color coding
- Feedback messages for password quality
- Frontend validation before submission
- Loading States & Progress Indicators: Professional async operation feedback
- Spinners for network scans and long operations
- Button disabling during operations to prevent double-clicks
- Clear visual feedback for all network operations
- Confirmation Dialogs: Enhanced safety for destructive operations
- Warning icons and explanations for network forget/delete
- Clear consequences described in confirmation prompts
- Session Persistence: Remember user preferences
- Last selected tab restored from session storage
- Seamless navigation experience across page reloads
- Auto-Refresh: Intelligent status updates
- Network status refreshes after configuration changes
- Gateway info updates after connections
- Connections list refreshes after modifications
- Keyboard Shortcuts: Power user features
- Ctrl+R to refresh WiFi scan (on WiFi tab)
Changed
- Network error displays now show hint and technical details
- Password input now includes real-time validation
- Confirmation dialogs provide more context for destructive actions
- Netmask dropdown now shows common use cases
- DNS server input includes popular server recommendations
[2.14.0] - 2025-12-08
Added - Phase 2: Core DASDEC3 Network Features
- Wired Ethernet Support: Added detection and display of eth0/ethernet interfaces with connection status
- Static IP Configuration: Full UI and backend for static IP settings (IP address, netmask, gateway) with toggle between DHCP and Static per interface
- DNS Server Configuration: Added ability to view, add, remove, and apply DNS server settings via NetworkManager
- Network Diagnostics Tools: Professional troubleshooting tools including:
- Ping test with customizable packet count
- Traceroute showing hop-by-hop network path
- DNS lookup (nslookup) for hostname resolution
- Default gateway information display
- Complete routing table viewer
- Saved Networks Management: Display all saved WiFi profiles (not just in-range) with auto-connect status editing
- Connection Profiles: Complete NetworkManager connection management with:
- List all saved connections with type and status
- Show connection details (interface, autoconnect state)
- Activate/deactivate connections
- Toggle auto-connect per connection
- Delete WiFi profiles
- Tabbed Interface: Professional Bootstrap tabs UI organizing features:
- Status: Network overview and gateway info
- WiFi: Wireless network scanning and connection
- Wired: Ethernet interface configuration
- DNS: DNS server management
- Diagnostics: Network troubleshooting tools
- Connections: Saved profile management
Technical Details
- Added 12 new API endpoints to hardware_service.py for Phase 2 features
- Added corresponding proxy routes in webapp/admin/network.py
- Complete UI rewrite with Bootstrap tabs and professional DASDEC3-style layout
- All features use NetworkManager (nmcli) for consistency and reliability
- Static IP configuration with CIDR prefix calculation
- DNS configuration per connection with restart to apply changes
- Diagnostics tools with real-time output display
- Connection management with activate/deactivate and autoconnect toggle
Impact
- ✅ Professional network management matching DASDEC3 standards
- ✅ Static IP support for production deployments
- ✅ DNS configuration for custom network environments
- ✅ Comprehensive diagnostics for troubleshooting
- ✅ Full control over saved network profiles
- ✅ Wired and wireless interface support
- ✅ DASDEC3-compatible feature set for professional EAS systems
[2.13.5] - 2025-12-08
Fixed
- CRITICAL WiFi BUG: Fixed WiFi scanning returning no networks even when networks available
- Added nmcli availability check - prevents silent failures when NetworkManager not installed
- Added WiFi interface auto-detection - finds wlan0/wlp* interfaces dynamically instead of hardcoded assumptions
- Fixed network status endpoint - now returns correct data structure with wifi.ssid that frontend expects
- Fixed WiFi scan race condition - replaced arbitrary 2-second sleep with proper completion detection
- Fixed disconnect functionality - backend now auto-detects active connection name instead of requiring frontend to send it
- Fixed empty scan results handling - now properly detects and reports when no networks found vs. scan failure
- Enhanced error handling and logging throughout WiFi operations
- Frontend now properly parses backend network status response structure
- Frontend disconnect sends empty body (backend auto-detects connection)
- Improved user feedback with toast notifications instead of alerts
Impact
- ✅ WiFi scan now reliably detects and returns available networks
- ✅ Network status display shows current connection properly
- ✅ Disconnect functionality works correctly
- ✅ Better error messages help diagnose WiFi issues
- ✅ All WiFi operations (scan, connect, disconnect, forget) fully functional
[2.13.4] - 2025-12-07
Fixed
- CRITICAL SEPARATION MISMATCH: Fixed audio-service startup failing to load SDR sources from database
- audio-service was trying to create SDRSourceAdapter for
source_type='sdr'but had no radio manager (separated architecture) - Added detection: if source is radio-managed (
managed_by='radio'), create RedisSDRSourceAdapter instead - Now audio-service properly loads SDR sources on startup and subscribes to IQ samples from sdr-service
Impact
- ✅ Audio sources persist across audio-service restarts
- ✅ No more "SDR source not available - radio manager missing" errors
- ✅ Separated architecture fully functional at startup
[2.13.3] - 2025-12-07
Fixed
- CRITICAL AUDIO BUG: Fixed source_type mismatch preventing audio from playing and Icecast mounts from appearing
ensure_sdr_audio_monitor_sourcewas sendingsource_type: 'sdr'but audio-service expected'redis_sdr'for separated architecture- Result: RedisSDRSourceAdapter was never created, no audio demodulation happened, no Icecast mount appeared
- Changed to
source_type: 'redis_sdr'so audio-service properly creates Redis IQ subscriber and Icecast output
Impact
- ✅ Audio now plays from SDR receivers
- ✅ Icecast mounts now appear (e.g., /receiver.mp3)
- ✅ Complete end-to-end audio pipeline working
[2.13.2] - 2025-12-07
Fixed
- CRITICAL END-TO-END: Complete signal chain from detection to audio now works
- Device Discovery: Added
discover_devicescommand handler in sdr-service - Receiver Creation: Added
reload_receiverscommand to sync database changes to sdr-service - Auto-Start: New/updated receivers now automatically loaded by sdr-service
- Webapp now properly communicates with sdr-service for device discovery and receiver management
Improved
- _sync_radio_manager_state now tells sdr-service to reload configuration
- Fallback to app-side radio manager if sdr-service unavailable
- Better error handling and logging throughout signal chain
- Device enumeration works in separated architecture
[2.13.1] - 2025-12-07
Fixed
- CRITICAL AIRSPY BUG: AirspyReceiver class was completely empty with NO Airspy-specific configuration
- Airspy Never Worked: Device would never get warm because no samples were being processed correctly
- Implemented proper
_open_handle()override with Airspy R2 sample rate validation (2.5 MHz or 10 MHz only) - Configured linearity gain mode for optimal strong signal handling (FM/NOAA)
- Added Bias-T safety (disabled by default to prevent equipment damage)
- Airspy R2 TCXO provides accurate frequency - no PPM correction needed
Improved
- Comprehensive Airspy R2 configuration logging
- Sample rate validation with clear error messages
- Better exception handling for Airspy-specific settings
[2.13.0] - 2025-12-07
Added
- MAJOR FEATURE: PPM (Parts Per Million) frequency correction support for compensating crystal oscillator drift in SDRs
- Added
frequency_correction_ppmfield to RadioReceiver model and database schema - Hardware frequency readback verification with mismatch warnings
- Comprehensive frequency tuning diagnostics and logging
Fixed
- Frequency Accuracy: RTL-SDR and other low-cost SDRs now properly compensate for clock drift (typically ±50 PPM)
- Tuning Verification: Actual tuned frequency is now logged and verified against requested frequency
- Diagnostic Logging: Frequency settings, PPM correction, and readback values now logged for troubleshooting
Improved
- Frequency accuracy can now be calibrated using PPM correction (e.g., calibrate with GSM cell tower or known station)
- Mismatch warnings help identify hardware tuning issues (> 1 kHz error triggers warning)
- Better separation: PPM correction in
ReceiverConfigdataclass, not just database
[2.12.27] - 2025-12-07
Fixed
- CRITICAL Demodulation Bug: Added missing
process()method to FMDemodulator and AMDemodulator classes that was being called by RedisSDRSourceAdapter but didn't exist, causing audio demodulation to fail completely - Fixed method signature mismatch where redis_sdr_adapter.py called
demodulator.process()but onlydemodulate()existed, preventing any audio from being generated from IQ samples
[2.12.26] - 2025-12-07
Fixed
- SDR Core: Implemented missing
get_ring_buffer_stats()method in_SoapySDRReceiverthat was being called by sdr_service.py but didn't exist, causing silent failures in buffer health monitoring - SDR Core: Integrated SDRRingBuffer initialization in receiver startup to enable proper USB jitter absorption and backpressure handling
- SDR Core: Ring buffer now properly instantiated when device opens, providing robust sample buffering for reliable 24/7 SDR operation
- SDR Core: Capture loop now writes samples to ring buffer for overflow detection and backpressure monitoring
- SDR Core: Ring buffer properly shut down when receiver stops, preventing resource leaks
Improved
- Enhanced ring buffer statistics reporting with fallback to simple buffer stats when SDRRingBuffer unavailable
- Added comprehensive buffer health metrics (overflow/underflow counts, fill percentage, total samples) to Redis
- Improved separation between app.py and SDR service - all SDR operations completely independent of Flask application
- Ring buffer overflow detection now logs dropped samples when processing can't keep up with USB data rate
[2.12.25] - 2025-12-05
Fixed
- CRITICAL: Fixed audio sources not starting when clicking start button - source name mismatch between webapp and audio-service (webapp sends "WIMT", audio-service expected "redis-WIMT")
- Fixed race condition in metrics publishing where audio-service was deleting eas_monitor metrics from Redis causing "No metrics available from audio-service" error
- Audio-service now uses original source names (not prefixed with "redis-") for separated architecture compatibility
[2.12.24] - 2025-12-05
Fixed
- Fixed audio-service container running Flask app.py during migrations by skipping database migrations in standalone service containers (audio-service, sdr-service, eas-service, hardware-service) that should not load the main Flask application
[2.12.23] - 2025-12-05
Documentation
- Clarified that SDR frontend already accepts frequency in MHz (not Hz) with automatic conversion
- Confirmed hardware-specific validation is already implemented (Airspy sample rate constraints, frequency range validation based on service type)
- Frontend validates sample rates based on hardware capabilities via
/api/radio/capabilitiesendpoint - Backend validates sample rate compatibility with driver via
validate_sample_rate_for_driver()function
[2.12.22] - 2025-12-05
Fixed
- Fixed AirspyReceiver method override bug where
_open_device()was defined but parent class uses_open_handle(), preventing Airspy-specific configuration (sample rate validation, linearity mode, bias-T settings) from ever executing - Added
get_ring_buffer_stats()method to SDR receivers to fix method-not-found errors when SDR service attempts to publish ring buffer statistics to Redis
[2.12.21] - 2025-11-27
Added
- Made SDR++ Server the default and recommended SDR option in the Radio Receiver settings UI
- Added prominent "SDR++ Server" quick-add button in the Quick Setup panel
- SDR++ Server now appears as the first option in the device selection dropdown
- Updated documentation (SDR Setup Guide) with comprehensive SDR++ Server setup instructions
- Added SDR++ Server to the hardware comparison table and configuration examples
Changed
- Reordered SDR presets to prioritize SDR++ Server (network SDR) over direct USB connections
- Updated capture workflow description to mention SDR++ Server as the recommended approach
- Renamed "Discover Devices" button to "Discover USB Devices" for clarity
[2.12.21] - 2025-11-27
Fixed
- Let OLED alert scrolls run across the full padded buffer before wrapping so alert text cleanly exits and re-enters the screen instead of freezing or overlaying fragments.
[2.12.20] - 2025-11-27
Fixed
- Restored OLED alert scrolling by advancing the seamless scroll window based on elapsed frame time and speed settings so high-priority messages animate smoothly instead of freezing on a single frame.
[2.12.19] - 2025-11-26
Fixed
- Added IPv6 connectivity troubleshooting documentation (
docs/troubleshooting/FIX_IPV6_CONNECTIVITY.md) so operators can diagnose SSL Labs IPv6 test failures and nginx upstream connection errors.
[2.12.18] - 2025-11-26
Fixed
- Redirected the policy docs URLs to the canonical
/termsand/privacyroutes and updated the documentation index to point to those pages so users no longer see divergent copies of the legal notices.
[2.12.17] - 2025-11-25
Fixed
- Redirect permission-denied responses to the dashboard blueprint's admin route so settings pages (including
/settings/alert-feeds) return a proper 403 flow instead of a 500 BuildError when the non-namespaced endpoint is unavailable.
[2.12.15] - 2025-11-22
Changed
- Downsampled the continuous EAS monitor to 8 kHz (with automatic resampling from higher-rate sources) so SAME FSK decoding runs at an efficient rate without wasting CPU on unnecessary bandwidth.
- Surfaced both the source and decoder sample rates in the monitor status API so operators can verify the tap is resampling correctly instead of assuming 22.05 kHz.
[2.12.14] - 2025-11-22
Fixed
- Matched the streaming decoder sample rate to the active ingest source so SAME correlation and preamble detection run at the correct frequency instead of drifting off-sync when sources run at 44.1 kHz.
- Exposed the ingest-driven sample rate in the broadcast adapter stats returned with the EAS monitor status so operators can confirm the tap is aligned with the source.
[2.12.13] - 2025-12-05
Fixed
- Added broadcast subscription health (queue depth, underruns, last audio time) to the continuous monitor API so the dashboard shows when audio is actually flowing and operators can see the tap is healthy instead of guessing through empty fields.
- Throttled repetitive buffer underrun warnings from the monitor's broadcast adapter while still counting them for visibility, preventing log spam when sources are temporarily quiet.
- Exposed broadcast queue stats and the currently active source in
/api/audio/metricsso VU meters can distinguish "no signal" from transport failures and display accurate runtime state.
[2.12.12] - 2025-12-05
Fixed
- Filled the continuous monitor status API with the streaming decoder's health, rate, and sync metrics so every dashboard field renders and operators can confirm the monitor is actively processing audio.
- Tagged live audio metrics with each source's runtime status so the VU meters reflect whether inputs are running instead of dimming as if they were offline.
[2.12.10] - 2025-12-04
Changed
- Added a selectable streaming mode on the audio monitor that prefers the built-in HTTPS stream by default and only opts into Icecast when operators explicitly choose it, reducing stalls when external ports are blocked.
[2.12.9] - 2025-12-04
Fixed
- Filter placeholder artwork metadata values (e.g.,
null,undefined, root-only paths) in the audio monitor so browsers stop requesting non-existent/nullimages from the dashboard host.
[2.12.8] - 2025-12-03
Fixed
- Corrected the default Icecast external port variable so Icecast URLs use the configured
ICECAST_EXTERNAL_PORTrather than inheriting overrides meant for the internal port, preventing browsers from being pointed to blocked or unmapped port 8080 endpoints.
[2.12.7] - 2025-12-02
Fixed
- Hardened the SDR audio monitoring stack by adding an auto-healing ingest controller that restarts stalled/error sources, auto-starts adapters when the live audio endpoint is hit, and exposes restart/error metadata so operators stop seeing permanent 503 responses, 0% buffer utilization, and "stream stalled" warnings on the monitoring dashboard.
[2.12.6] - 2025-12-01
Fixed
- Added a differential RBDS symbol slicer so FM demodulation correctly reconstructs PI/PS/RadioText metadata and keeps the latest decoded fields available to the SDR audio monitor.
- Hardened the SoapySDR receiver implementation by mapping stream error codes (including SOAPY_SDR_NOT_LOCKED) to descriptive messages and attaching PLL lock hints so operators immediately see when a tuner simply needs to acquire lock instead of chasing misleading "cannot open device" errors.
[2.12.5] - 2025-11-30
Changed
- Disabled the CAP poller's optional SDR capture orchestration by default so its RadioManager hooks stay idle unless the poller
needs to request IQ/PCM recordings for an alert playback, added the
CAP_POLLER_ENABLE_RADIOenvironment flag, and exposed a--radio-captures/--no-radio-capturesCLI switch so operators can explicitly opt into capture requests when they actually want files.
[2.12.4] - 2025-11-29
Fixed
- Forced OLED templates with manually positioned lines to default to no-wrapping in the renderer so preview cards and physical displays stop stacking wrapped segments on top of each other and keep their typography aligned.
[2.12.3] - 2025-11-29
Fixed
- Updated the OLED layout migration to use uniquely named bind parameters so Alembic can compile the update statement without colliding with column names, preventing the
bindparam() name 'name' is reservedfailure during upgrades.
[2.12.2] - 2025-11-29
Fixed
- Added an automatic SoapySDR fallback that retries opening receivers without the serial filter when the initial connection fails, letting Airspy radios initialize even if the driver rejects the serialized arguments.
- Updated the OLED layout migration to JSON-serialize
template_databefore persisting it to PostgreSQL so upgrades no longer crash withcan't adapt type 'dict'errors.
[2.12.1] - 2025-11-27
Changed
- Rebuilt the EAS Station™ wordmark as an inline SVG partial that inherits theme colors for its accent bars and lettering, so the logo automatically matches whichever palette operators choose without filters or manual assets.
- Updated the navigation bar and hero sections on the Help, About, Privacy, Terms, and Version pages to consume the new partial, eliminating duplicate markup and keeping the refreshed layout consistent in every mode.
[2.12.0] - 2025-11-27
Added
- Introduced two new UI themes, Midnight and Tide, complete with theme-switcher entries and CSS variable palettes so operators can choose between a deep slate dark mode and a crisp coastal light mode.
- Published NOAA, FEMA IPAWS, and ARRL resource badges plus a curated "Trusted Field Resources" section on the Help page so the most requested links are visual, organized, and no longer broken.
Changed
- Modernized the Help & Operations Guide layout with hero quick links, an operations flow mini-timeline, refreshed typography, and a reorganized assistance section for a more professional flow.
- Added dedicated Help-page utility styles that sharpen quick-link tiles, timeline steps, and resource cards, ensuring the guide matches the rest of the dashboard polish.
[2.11.7] - 2025-11-18
Changed
- Added a refresh-status meta block on the dashboard map card that now shows the last update time, refresh source, and a live countdown so operators can see when the next automatic poll will fire without scrolling.
- Replaced the fixed interval timer with a scheduler that pauses during manual refreshes, resumes after success or failure, and prevents overlapping automatic refresh attempts.
- Updated the dashboard refresh action so manual, automatic, keyboard, and debug triggers all share the same code path, optionally reload boundary layers, and correctly update the "Last Update" metric and header badge.
[2.11.6] - 2025-11-23
Removed
- Dropped the
DEFAULT_AREA_TERMSenvironment variable, the accompanying admin editor entry, and the template references so environment exports no longer list unused area-search keywords.
Changed
- Default location snapshots now seed
area_termswith an empty list rather than mirroring the removed environment variable, keeping historic values intact without encouraging new deployments to rely on the deprecated fallback.
[2.11.5] - 2025-11-23
Fixed
- Removed the CAP poller's area-term fallback so alerts only appear on
/alertswhen their SAME or UGC codes match the configured counties, preventing neighboring-county descriptions from triggering the UI.
[2.11.4] - 2025-11-22
Fixed
- Fixed duplicate DOM element declarations on the Weekly Test Automation page that threw JavaScript errors and prevented saved SAME/FIPS counties from loading into the scheduler or badge preview.
[2.11.3] - 2025-11-21
Fixed
- Ensured the RWT scheduler always opens a Flask application context before touching the database and no longer keeps that context open during idle sleeps, eliminating the "working outside of application context" failures in the background worker.
[2.11.2] - 2025-11-20
Added
- Added an offline alert self-test harness plus
scripts/run_alert_self_test.pyso operators can replay bundled RWT captures, verify duplicate suppression, and confirm the configured FIPS list still forwards alerts without waiting for a live activation. - Folded the alert self-test harness into the Tools → Alert Verification dashboard so operators can replay bundled or custom audio from the same analytics page and capture screenshots for customer assurances.
Changed
- Consolidated the alert self-test workflow into the Alert Verification dashboard so operators validate decoding, analytics, and FIPS filtering from a single Tools entry instead of bouncing between separate pages.
[2.10.0] - 2025-11-18
Added
- Added comprehensive
utilities.csswith gradient, card, badge, spacing, layout, typography, shadow, border, visibility, and animation utilities - Created reusable template component partials in
templates/components/for metric cards, stat cards, page headers, status badges, and data lists - Built new professional version page (
/help/version) with tabbed interface featuring Overview, Changelog, Features, System Info, and JSON API tabs - Added
changelog_parser.pyutility to parse CHANGELOG.md files and extract structured version history - Integrated git commit information display (hash, branch, date, message) on version page
- Added visual timeline visualization for changelog with animated current version marker
- Added comprehensive feature matrix showing all installed system components and their availability status
- Added copy-to-clipboard functionality for JSON API output
Changed
- Updated
base.htmltemplate to include all CSS files in proper order: design-system, base, components, utilities, layout, and enhancements - Replaced basic version page with comprehensive tabbed interface showing full release history from parsed CHANGELOG.md
- Enhanced version route in
routes_monitoring.pyto include git metadata and parsed changelog data - Standardized gradient usage across all templates with new utility classes (.gradient-primary, .gradient-success, etc.)
- Improved version page accessibility with URL hash-based tab navigation
Fixed
- Fixed inconsistent gradient implementations across templates by centralizing in utilities.css
- Fixed missing CSS files (design-system.css, components.css) not being loaded in base template
- Improved dark theme compatibility for version page components
[Unreleased]
Added
- Clarified the commercial license offer notes pricing covers software only and excludes any hardware costs.
- Extended
/api/system_statusand/api/system_healthwith hostname, primary IPv4, uptime, and primary-interface metadata so OLED/network templates can surface real host diagnostics. - Surfaced the Weekly Test Automation console with a county management side panel, Broadcast navigation entry, and in-product callouts so operators can edit RWT schedules and default SAME codes entirely from the UI.
- Added a curated OLED showcase rotation (system overview, alerts, network beacon, IPAWS poll watch, audio health, and audio
telemetry) plus a
--display-typeflag toscripts/create_example_screens.pyfor targeted installs. - Enforced Argon Industria OLED reservations by blocking BCM pins 2, 3, 4, and 14 (physical header block 1-8) from GPIO configuration, greying them out in the GPIO Pin Map, and surfacing guidance in setup, environment, and hardware docs.
- Provisioned default OLED status screens with system, alert, and audio telemetry plus on-device button shortcuts (short press to advance rotation, long press for a live snapshot).
- Added Argon Industria SSD1306 OLED module support with full configuration tooling and display workflows
- Introduced
app_core/oled.pywith luma.oled-based controller, newOLED_*environment variables, and runtime initialization hooks - Extended screen renderer, manager, and
/api/screensendpoints with anoleddisplay type alongside LED and VFD rotations - Updated admin Environment editor, setup wizard, and hardware reference docs for OLED installation and configuration guidance
- Introduced
- Added interactive GPIO Pin Map page (System → GPIO Pin Map) to visualize the 40-pin header and
assign alert behaviors per BCM pin with persistence to
GPIO_PIN_BEHAVIOR_MATRIX. - Added multi-pin GPIO configuration loader with persistent environment editor support, ensuring Raspberry Pi deployments can drive multiple relays with active-high/low settings and automatic watchdog enforcement during alert playout.
- Added IPAWS poll debug export endpoints for Excel and PDF with UI buttons on
/debug/ipawsfor rapid sharing of poll runs. - Added comprehensive analytics and compliance enhancements with trend analysis and anomaly detection
- Implemented
app_core/analytics/module with metrics aggregation, trend analysis, and anomaly detection - Created
MetricSnapshot,TrendRecord, andAnomalyRecorddatabase models for time-series analytics - Built
MetricsAggregatorto collect metrics from alert delivery, audio health, receiver status, and GPIO activity - Implemented
TrendAnalyzerwith linear regression, statistical analysis, and forecasting capabilities - Added
AnomalyDetectorusing Z-score based outlier detection, spike/drop detection, and trend break analysis - Created comprehensive API endpoints at
/api/analytics/*for metrics, trends, and anomalies - Built analytics dashboard UI at
/analyticswith real-time metrics, trend visualization, and anomaly management - Added
AnalyticsSchedulerfor automated background processing of metrics aggregation and analysis - Documented complete analytics system architecture and usage in
app_core/analytics/README.md - Published comprehensive compliance reporting playbook in
docs/compliance/reporting_playbook.mdwith workflows for weekly/monthly test verification, performance monitoring, anomaly investigation, and regulatory audit preparation
- Implemented
Fixed
- Removed caching from
/api/audio/metricsand set explicit no-store headers so VU meters and live audio telemetry refresh in real time instead of waiting for multi-second cache windows. - Hardened backup API endpoints by validating backup names to block path traversal before touching the filesystem.
- Removed the CAP poller's area-term fallback so
/alertsonly surfaces entries that explicitly name the configured SAME or UGC codes, eliminating false positives from neighboring county descriptions. - Ensured the continuous EAS monitor auto-initializes on demand so the audio monitoring page no longer stalls when the monitor wasn't started during app boot.
- Added comprehensive audio ingest pipeline for unified capture from SDR, ALSA, and file sources
- Implemented
app_core/audio/ingest.pywith pluggable source adapters and PCM normalization - Added peak/RMS metering and silence detection with PostgreSQL storage
- Built web UI at
/settings/audio-sourcesfor source management with real-time metering - Exposed configuration for capture priority and failover in environment variables
- Implemented
Fixed
- Documented the Weekly Test Automation county list regression addressed in 2.11.4 so QA can trace the scheduler fix through the release pipeline.
- Added FCC-compliant audio playout queue with deterministic priority-based scheduling
- Created
app_core/audio/playout_queue.pywith Presidential > Local > State > National > Test precedence - Built
app_core/audio/output_service.pybackground service for ALSA/JACK playback - Implemented automatic preemption for high-priority alerts (e.g., Presidential EAN)
- Added playout event tracking for compliance reporting and audit trails
- Created
- Added comprehensive GPIO hardening with audit trails and operator controls
- Created unified
app_utils/gpio.pyGPIOController with active-high/low, debounce, and watchdog timers - Added
GPIOActivationLogdatabase model tracking pin activations with operator, reason, and duration - Built operator override web UI at
/admin/gpiowith authentication and manual control capabilities - Documented complete hardware setup, wiring diagrams, and safety practices in
docs/hardware/gpio.md
- Created unified
- Added comprehensive security controls with role-based access control (RBAC), multi-factor authentication (MFA), and audit logging
- Implemented four-tier role hierarchy (Admin, Operator, Analyst, Viewer) with granular permission assignments
- Added TOTP-based MFA enrollment and verification flows with QR code setup
- Created comprehensive audit log system tracking all security-critical operations with retention policies
- Built dedicated security settings UI at
/settings/securityfor managing roles, permissions, and MFA - Added database migrations to auto-initialize roles and assign them to existing users
- Documented security hardening procedures in
docs/MIGRATION_SECURITY.md
- Redesigned EAS Station™ logo with modern signal processing visualization
- Professional audio frequency spectrum visualization with animated elements
- Radar/monitoring circular grid overlay for technical aesthetic
- Animated signal waveform with alert gradient effects
- Deep blue to cyan gradient representing signal monitoring and alert processing
- SVG filters for depth, glow effects, and contemporary design polish
Fixed
- Restored SSL certificate and private key export downloads by mounting the Let's Encrypt
volume into the application container and searching both
/etc/letsencryptand/app-config/certsfor domain materials before returning actionable guidance. - Converted the Stream Profiles interface to the shared base layout with Bootstrap 5 modal controls so its header, theming, and actions match the rest of the application.
- Reduced excessive whitespace in dark themes by introducing theme-aware layout spacing variables that tighten main content padding and footer offsets across all dark presets.
- Ensure the 20251107 decoded audio segment migration only adds the attention tone and narration columns when they are missing so fresh installs don't abort before administrator accounts can be created.
- Allow fresh installations to run Alembic migrations without errors by skipping the
20241205 FIPS location settings upgrade when the
location_settingstable has not been created yet. - Prevent SDR audio monitors from returning HTTP 503 errors by restoring persisted adapters before serving playback, start/stop, and waveform endpoints so the radio settings page can stream audio reliably after restarts.
- Force dark-mode typography and link treatments to use the light contrast palette when
data-theme-mode="dark"is active so copy remains readable across every dark theme variation. - Remove the auto-injected skip navigation anchors so the navbar's leading section only presents the wordmark and health status indicator.
- Improved readability of dark UI themes by brightening background surfaces, borders, and text contrast variables shared across the design system, and by mapping the design system colors to each theme's palette so custom dark presets retain their intended contrast.
- Surface actionable diagnostics when GPIO hardware is inaccessible, highlighting missing /dev/gpiomem access and read-only sysfs mounts so deployments can correct permissions.
- Replaced the deprecated
RPi.GPIObackend withgpiozerooutput devices and ensured typing imports are available so Raspberry Pi deployments boot cleanly on Pi 5 hardware. - Reduced nginx static asset cache lifetime from 24 hours to five minutes so freshly deployed frontend changes appear without manual cache purges.
- Prevented alert verification page timeouts by offloading audio decoding to a background worker and persisting progress/results for UI polling.
- Added Raspberry Pi 5-compatible
lgpiofallback for GPIO control so BCM pins configured as active-high no longer enter an error state whenRPi.GPIOis unavailable.
Changed
- Refined the theming system with higher-contrast logo treatments and added Aurora, Nebula, and Sunset presets to expand the built-in palette while keeping the wordmark legible across gradients.
- Renamed the "EAS Workflow" console to Broadcast Builder and linked the Weekly Test Automation page throughout the Broadcast menu and workflow hero banner so automation tooling is obvious to operators.
- Consolidated stream support in Audio Sources system - Removed stream support from RadioReceiver model and UI, centralizing all HTTP/M3U stream configuration through the Audio Sources page where StreamSourceAdapter already provided full functionality
- Removed
source_typeandstream_urlfields from RadioReceiver database model - RadioReceiver now exclusively handles SDR hardware (RTL-SDR, Airspy)
- Added Stream (HTTP/M3U) option to Audio Sources UI dropdown
- Added stream configuration fields (URL, format) to Audio Sources modal
- Updated navigation to point to
/settings/audioinstead of deprecated/audio/sourcesroute - Clear separation of concerns: Radio = RF hardware, Audio = all audio ingestion sources
- Removed
Fixed
- Restored
/statsdashboard data by providing CAP alert history, reliability metrics, and polling debug visibility in/logs. - Fixed Audio Sources page not loading sources - Corrected missing element IDs and event listeners that prevented audio sources from displaying on
/settings/audiopage- Fixed element IDs to match JavaScript expectations (
active-sources-count,total-sources-count,sources-list) - Fixed modal IDs to match JavaScript (
addSourceModal,deviceDiscoveryModal) - Added event listeners for Add Source, Discover Devices, and Refresh buttons
- Added toast container for notification display
- Removed deprecated
/audio/sourcespage route
- Fixed element IDs to match JavaScript expectations (
- Fixed JSON serialization errors in audio APIs - Backend was returning -np.inf (negative infinity) for dB levels when no audio present, causing "No number after minus sign in JSON" errors in frontend
- Added
_sanitize_float()helper that converts infinity/NaN to valid numbers (-120.0 dB for silence) - Applied sanitization to all audio API endpoints:
/api/audio/sources,/api/audio/metrics,/api/audio/health - Ensures all API responses are valid JSON that browsers can parse
- Added
- Fixed Add Audio Source button not working - Form element IDs didn't match JavaScript expectations
- Changed form ID from
audioSourceFormtoaddSourceForm - Changed container ID from
deviceParamsContainertosourceTypeConfig - Updated field IDs to match JavaScript (
sourceName,sampleRate,channels,silenceThreshold,silenceDuration) - Added missing
silenceDurationfield for silence detection configuration
- Changed form ID from
- Fixed audio source delete, start, and stop operations failing with 404 errors
- Added
encodeURIComponent()to all fetch URLs for proper URL encoding of source names with special characters - Added
sanitizeId()helper to create safe HTML element IDs (replaces special chars with underscores) - Fixed onclick handler escaping to prevent JavaScript injection vulnerabilities
- Updated
updateMeterDisplay()to use sanitized IDs when finding meter elements
- Added
- Fixed DOM element ID mismatches - JavaScript was looking for elements with IDs that didn't exist in HTML template
- Changed
healthScore→overall-health-score - Changed
silenceAlerts→alerts-count - Added hidden
overall-health-circleandalerts-listelements required by JavaScript
- Changed
- Fixed Edit Audio Source button failing - Edit modal didn't exist in HTML template
- Added complete
editSourceModalwith all required fields (priority, silence threshold/duration, description, enabled, auto-start) - Source name and type are readonly (can't be changed after creation)
- Fixed device discovery modal to have
discoveredDevicesdiv for JavaScript
- Added complete
- Added detailed error messages for audio source failures - Users now see exactly why sources fail instead of generic "error" status
- Added
error_messagefield toAudioSourceAdapterto track failure details - Stream connection failures show max reconnection attempts message
- Missing dependencies show installation instructions (e.g., "install pydub")
- Error messages displayed in red alert boxes on source cards
- Added disconnected status alert showing reconnection attempts
- Added
- Fixed numpy float32 JSON serialization error - Audio APIs were returning 500 errors due to numpy types not being JSON-serializable
- Updated
_sanitize_float()to detect and convert numpy.floating and numpy.integer types to Python float - Fixes "Object of type float32 is not JSON serializable" errors on
/api/audio/sourcesand/api/audio/metrics
- Updated
- Fixed numpy bool_ JSON serialization error - Audio APIs were returning intermittent 500 errors due to numpy boolean types not being JSON-serializable
- Added
_sanitize_bool()helper to convert numpy.bool_ types to Python bool - Applied to all boolean fields: silence_detected, clipping_detected, enabled, auto_start, acknowledged, resolved, is_active, is_healthy, error_detected
- Fixes "Object of type bool is not JSON serializable" errors on
/api/audio/metrics,/api/audio/health, and/api/audio/alerts
- Added
- Added pydub dependency for MP3/AAC/OGG stream decoding from HTTP/Icecast sources
- Fixed module import paths in scripts/manual_eas_event.py and scripts/manual_alert_fetch.py by adding repository root to sys.path
- Fixed CSRF token protection in password change form (security settings)
- Fixed audit log pagination to cap per_page parameter at 1000 to prevent DoS attacks
- Fixed timezone handling to use timezone-aware UTC timestamps instead of naive datetime.utcnow()
- Fixed migration safety with defensive checks for permission lookup to handle missing permissions gracefully
- Fixed markdown formatting in MIGRATION_SECURITY.md with proper heading levels and code block language specs
Changed
- Enhanced AGENTS.md with bug screenshot workflow, documentation update requirements, and semantic versioning conventions
- Reorganized root directory by moving development/debug scripts to scripts/deprecated/ and utility scripts to scripts/
- Removed README.md.backup file from repository
- Improved error logging to use logger.exception() instead of logger.error() in 8 locations across security routes for better debugging
Added
- Added an admin location reference view that summarises the saved NOAA zone catalog entries, SAME/FIPS codes, and keyword matches so operators can understand how the configuration drives alert filtering.
- Added a public forecast zone catalog loader that ingests the bundled
assets/z_05mr24.dbffile into a dedicated reference table, exposes atools/sync_zone_catalog.pyhelper, and validates admin-supplied zone codes against the synchronized metadata. - Added an interactive
.envsetup wizard available at/setup, with a CLI companion (tools/setup_wizard.py), so operators can generate secrets, database credentials, and location defaults before first launch without editing text files by hand. - Added a repository
VERSIONmanifest, shared resolver, andtests/test_release_metadata.pyguardrail so version bumps and changelog updates stay synchronised for audit trails. - Added
tools/inplace_upgrade.pyfor in-place upgrades that pull, rebuild, migrate, and restart services without destroying volumes, plustools/create_backup.pyto snapshot.env, compose files, and a Postgres dump with audit metadata before changes. - Introduced a compliance dashboard with CSV/PDF exports and automated receiver/audio health alerting to monitor regulatory readiness.
- Enabled the manual broadcast builder to target county subdivisions and the nationwide 000000 SAME code by exposing P-digit selection alongside the existing state and county pickers.
- Introduced a dedicated Audio Archive history view with filtering, playback, printing, and Excel export support for every generated SAME package.
- Surfaced archived audio links throughout the alert history and detail pages so operators can quickly review transmissions tied to a CAP product.
- Added a
manual_eas_event.pyutility that ingests raw CAP XML (e.g., RWT/RMT tests), validates the targeted SAME/FIPS codes, and drives the broadcaster so operators can trigger manual transmissions with full auditing. - Introduced the
EAS_MANUAL_FIPS_CODESconfiguration setting to control which locations are eligible for manual CAP forwarding. - Bundled the full national county/parish FIPS registry for manual activations and exposed helpers to authorize the entire dataset with a single configuration flag.
- Cataloged the nationwide SAME event code registry together with helper utilities so broadcasters and manual tools can resolve official names, presets, and headers.
- Added a CLI helper (
tools/generate_sample_audio.py) to create demonstration SAME audio clips without ingesting a live CAP product. - Delivered an in-app Manual Broadcast Builder on the EAS Output tab so operators can generate SAME headers, attention tones (EAS dual-tone or 1050 Hz), optional narration, and composite audio without leaving the browser.
- Archived every manual EAS activation automatically, writing audio and summary assets to disk, logging them in the database, and exposing a printable/exportable history table within the admin console.
- Unlocked an in-app first-run experience so the Admin panel exposes an "First-Time Administrator Setup" wizard when no accounts exist.
- Introduced optional Azure AI speech synthesis to append narrated voiceovers when the appropriate credentials and SDK are available.
[2.9.0] - 2025-11-15
Added
- OLED alert rotations now preempt normal playlists when
skip_on_alertis enabled, prioritizing the most severe alert and scrolling its text in a large font for the entire duration. EAS/IPAWS sources render their full plain-language narration while other sources fall back to headline + description so operators always see useful context. /api/alertsnow returns each alert's source and (when available) the cached EAS narration text, allowing custom OLED/LED templates or Portainer dashboards to display the same preemption-ready payloads.
[2.8.0] - 2025-02-15
Fixed
- Prevented the
20251113_add_serial_mode_to_led_sign_statusAlembic migration from raisingTypeError: execute() takes 2 positional arguments but 3 were givenby issuing the default value backfill through the SQLAlchemy bind connection instead ofop.execute, ensuring upgrades complete cleanly before the app starts. - Added an offline pyttsx3 text-to-speech provider so narration can be generated without external network services when the engine is installed locally.
- Authored dedicated
docs/reference/ABOUT.mdanddocs/guides/HELP.mddocumentation describing the system mission, software stack, and operational playbooks, with cross-links from the README for quick discovery. - Exposed in-app About and Help pages so operators can read the mission overview and operations guide directly from the dashboard navigation.
can either rely on the bundled
alerts-dbPostGIS container or connect to an existing deployment without editing the primary compose file. - Documented open-source dependency attributions in the docs and surfaced maintainers, licenses, and usage details on the in-app About page.
Changed
- Documented why the platform remains on Python 3.12 instead of the new Python 3.13 release across the README and About surfaces, highlighting missing Linux/ARM64 wheels for SciPy and pyttsx3 and the security patch workflow for the current runtime.
- Documented Debian 14 (Trixie) 64-bit as the validated Raspberry Pi host OS while clarifying that the container image continues to ship on Debian Bookworm via the
python:3.12-slim-bookwormbase. - Documented the release governance workflow across the README, ABOUT page, Terms of Use, master roadmap, and site footer so version numbering, changelog discipline, and regression verification remain mandatory for every contribution.
- Suppressed automatic EAS generation for Special Weather Statements and Dense Fog Advisories to align with standard activation practices.
- Clarified in the README and dependency notes that PostgreSQL with PostGIS must run in a dedicated container separate from the application services.
- Clarified the update instructions to explicitly pull the Experimental branch when refreshing deployments.
- Documented the expectation that deployments supply their own PostgreSQL/PostGIS host and simplified Compose instructions to run only the application services.
- Reworked the EAS Output tab with an interactive Manual Broadcast Builder and refreshed the README/HELP documentation to cover the browser-based workflow.
- Enhanced the Manual Broadcast Builder with a hierarchical state→county SAME picker, a deduplicated PSSCCC list manager, a live
ZCZC-ORG-EEE-PSSCCC+TTTT-JJJHHMM-LLLLLLLL-preview with field-by-field guidance, and refreshed docs that align with commercial encoder terminology. - Added a one-touch Quick Weekly Test preset to the Manual Broadcast Builder so operators can load the configured SAME counties, test status, and sample script before generating audio.
- Updated the Quick Weekly Test preset to omit the attention signal by default and added a “No attention signal (omit)” option so manual packages can exclude the dual-tone or 1050 Hz alert when regulations allow.
Fixed
- Inserted the mandatory display-position byte in LED sign mode fields so M-Protocol frames comply with Alpha controller requirements.
- Surface offline pyttsx3 narration failures in the Manual Broadcast Builder with the underlying error details so operators can troubleshoot configuration issues without digging through logs.
- Detect missing libespeak dependencies when pyttsx3 fails and surface installation guidance so offline narration can be restored quickly.
- Detect missing ffmpeg dependencies and empty audio output from pyttsx3 so the Manual Broadcast Builder can steer operators toward the required system packages when narration silently fails.
- Surface actionable pyttsx3 dependency hints when audio decoding fails so the Manual Broadcast Builder points operators to missing libespeak/ffmpeg packages instead of opaque errors.
- Added an espeak CLI fallback when pyttsx3 fails to emit audio so offline narration still succeeds even if the engine encounters driver issues.
- Count manual EAS activations when calculating Audio Archive totals and show them alongside automated captures so archived transmissions are visible in the history table.
- Moved the Manual Broadcast Archive card to span the full EAS console width, matching the builder/output layout and preventing it from being tucked under the preview panel on large displays.
- Corrected the Quick Weekly Test preset so the sample Required Weekly Test script populates the message body as expected.
- Standardised the manual and automated encoder timing so each SAME section includes a one-second
guard interval and the End Of Message burst transmits the canonical
NNNNpayload per 47 CFR §11.31. - Replaced the free-form originator/call-sign fields with a guarded originator dropdown listing the four FCC originator codes (EAS, CIV, WXR, PEP) and a station identifier input, filtered the event selector to remove placeholder
??*codes, and enforced the 31-location SAME limit in the UI. - Simplified database configuration by deriving
DATABASE_URLfrom thePOSTGRES_*variables when it is not explicitly set, eliminating duplicate secrets in.env. - Restored the
.envtemplate workflow, updated quick-start documentation to copy.env.example, and reiterated that operators must rotate the placeholder secrets immediately after bootstrapping the stack. - Streamlined
.env.exampleby removing unused settings and documenting optional location defaults leveraged by the admin UI. - Updated the GPIO relay control so it remains engaged for the full alert audio playback,
using
EAS_GPIO_HOLD_SECONDSas the minimum release delay once audio finishes. - Automatically generate and play an End-Of-Message (EOM) data burst sequence after each alert so receivers reliably return to normal programming when playback completes.
- Refactored the monolithic
app.pyinto cohesiveapp_coremodules (alerts, boundaries, database models, LED integration, and location settings) and slimmed the Flask entrypoint so shared helpers can be reused by CLIs and tests without importing the entire web stack. - Manual CAP tooling now validates inputs against the registry, surfaces friendly area names in CLI output and audit logs, and warns when CAP payloads reference unknown codes.
- Manual CAP broadcasts enforce configurable SAME event allow-lists and display the selected code names in CLI output and audit trails while the broadcaster consumes the resolved identifiers for header generation.
- Ensured automated and manual SAME headers include the sixteen 0xAB preamble bytes before each burst so the transmitted RTTY data fully complies with 47 CFR §11.31.
- Restricted automatic EAS activations to CAP products whose SAME event codes match the authorised 47 CFR §11.31(d–f) tables, preventing unintended broadcasts for unclassified alerts.
Fixed
- Corrected SAME/RTTY generation to follow 47 CFR §11.31 framing (seven LSB-first ASCII bits, trailing null bit, and precise 520 5⁄6 baud timing) so the AFSK bursts decode at the proper pitch and speed.
- Fixed admin location settings so statewide SAME/FIPS codes remain saved when operators select entire states.
- Corrected the generated End Of Message burst to prepend the sixteen 0xAB preamble bytes so decoders reliably synchronise with the termination header.
- Trimmed the manual and UI event selector to the authorised 47 CFR §11.31(d–e) code tables and removed placeholder
??*entries. - Eliminated
service "app" depends on undefined service "alerts-db"errors by removing the optional compose overlay, deleting the unused service definition, and updating documentation to assume an external database. - Ensured the Manual Broadcast Builder always renders the SAME event code list so operators can pick the desired code even when client-side scripts are blocked or fail to load.
- Fixed the Manual Broadcast Builder narration preview so newline escaping no longer triggers a browser-side "Invalid regular expression" error when rendering generated messages.
- Restored the
.env.exampletemplate and documented the startup error shown when the file is missing so systemd deployments no longer fail with "env file not found". - Skip PostGIS-specific geometry checks when running against SQLite and store geometry fields as plain text on non-PostgreSQL databases so local development can initialize without spatial extensions.
- Corrected manual CAP allow-all FIPS logic to use 6-digit SAME identifiers so alerts configured for every county pass validation and display proper area labels.
- Resolved an SQLAlchemy metadata attribute conflict so the Flask app and polling services can load the EAS message model without raising declarative mapping errors.
- Ensure the Flask application automatically enables the PostGIS extension before creating tables so startup succeeds on fresh PostgreSQL deployments.
- Rebuilt the LED sign M-Protocol frame generation to include the SOH/type/address header, compute the documented XOR checksum, and verify ACK/NAK responses so transmissions match the Alpha manual.
- Honored the Alpha M-Protocol handshake by draining stale responses, sending EOT after acknowledgements, and clamping brightness commands to the single-hex-digit range required by the manual.
- Fixed the Alpha text write command to send the single-byte "A" opcode followed by the file label so frames no longer begin with an invalid "AAA" sequence that the manual forbids.
- Prevented the LED fallback initializer from raising a
NameErrorwhen the optional controller module is missing so deployments without sign hardware continue to boot.
[2.7.5] - 2025-11-15
Fixed
- Allow first-time deployments to create the initial administrator from a dedicated setup wizard page so Portainer users without console access can finish onboarding without running CLI commands.
[2.7.2] - 2025-11-15
Fixed
- Restore SDR audio monitor adapters on-demand for all audio ingest APIs, eliminating the recurring 503 responses and broken playback streams reported on the radio settings page.
[2.7.1] - 2025-11-15
Fixed
- Backfill SDR squelch columns automatically when legacy deployments haven't run the latest Alembic migration so radio settings and monitoring pages load without column errors.
[2.7.0] - 2025-11-14
Added
- Added an audio-monitor provisioning API and UI workflow that auto-starts SDR Icecast streams, surfaces RBDS programme data, and exposes squelch/carrier telemetry directly from the radio settings page for immediate listening checks.
Changed
- Enabled configurable squelch thresholds, timing, and carrier-loss alarms for SDR receivers with service-specific defaults tuned for Raspberry Pi deployments, reducing false positives while keeping CPU usage low.
[2.4.16] - 2025-11-10
Fixed
- Removed the
APP_BUILD_VERSIONenvironment override so persistent.envfiles can no longer pin stale release numbers; the UI now always reflects the repositoryVERSIONmanifest.
[2.4.15] - 2025-11-10
Fixed
- Ensured the version resolver invalidates its cache when
APP_BUILD_VERSIONor theVERSIONfile changes so dashboards display the latest release metadata immediately after deployments. - Disabled caching on the built-in documentation viewer routes to prevent browsers and reverse proxies from serving outdated markdown content.
[2.4.14] - 2025-11-10
Fixed
- Added automatic cache-busting query parameters to all Flask-served static asset URLs so envoy/nginx layers fetch freshly deployed bundles instead of stale copies (Screenshot_7-11-2025_75931_easstation.com.jpeg).
[2.4.11] - 2025-11-09
Fixed
- Corrected the documentation viewer's Mermaid block detection to support Windows-style line endings so diagrams render instead of showing raw code.
- Refreshed system version metadata on each request so the footer and monitoring endpoints display the latest release after version bumps.
[2.4.1] - 2025-11-09
Fixed
- Resolved production nginx image regressions - Ensured HTTPS container bundles required tooling and static assets
- Copied repository
static/directory into the image to stop 404 errors for CSS, JS, and image assets - Updated nginx configuration to use the modern
http2 on;directive and silence deprecation warnings during startup
- Copied repository
[2.3.12] - 2025-11-15
Fixed
- Hardened admin location validation so statewide SAME/FIPS codes are always accepted and labelled consistently when saving.
[2.3.11] - 2025-11-14
Fixed
- Fixed admin location settings so statewide SAME/FIPS codes remain saved when operators select entire states.
[2.3.10] - 2025-11-03
Changed
- Reformatted SAME plain-language summaries to omit appended FIPS and state code suffixes, adopt the FCC county listing punctuation, and present the event description in the expected uppercase style.
[2.3.9] - 2025-11-03
Changed
- Display the per-location FIPS identifiers and state codes on the Audio Archive detail view so operators can confirm the targeted jurisdictions for each generated message without leaving the page.
[2.3.8] - 2025-11-02
Fixed
- Backfilled missing plain-language SAME header summaries when loading existing audio decodes so the alert verification and audio history pages regain their readable sentences.
[2.3.7] - 2025-11-02
Changed
- Linked the admin location reference summary and API responses to the bundled
SAME location code directory (
assets/pd01005007curr.pdf) and NOAA Public Forecast Zones catalog so operators see the authoritative data sources.
[2.3.6] - 2025-11-02
Added
- Added an admin location reference API and dashboard card that surfaces the saved NOAA zones, SAME/FIPS counties, and keyword filters so operators can review their configuration and confirm catalog coverage.
[2.3.5] - 2025-11-01
Fixed
- Prevented the public forecast zone catalog synchronizer from inserting duplicate zone records when the source feed repeats a zone code, eliminating startup failures when multiple workers initialize simultaneously.
[2.3.3] - 2025-11-13
Changed
- Documented Raspberry Pi 5 (4 GB RAM) as the reference platform across the README, policy documents, and in-app help/about pages while noting continued Raspberry Pi 4 compatibility.
[2.3.2] - 2025-11-02
Changed
- The web server now falls back to a guarded setup mode when critical
configuration is missing or the database is unreachable, redirecting all
requests to
/setupso operators can repair the environment without editing.envmanually first.
[2.3.1] - 2025-11-01
Added
- Added one-click backup and upgrade controls to the Admin System Operations panel, wrapping the existing CLI helpers in background tasks with status reporting.
[2.1.9] - 2025-10-31
Added
- Delivered a WYSIWYG LED message designer with content-editable line cards, live colour/effect previews, and per-line special function toggles so operators can see the final layout before transmitting.
Changed
- Refactored the LED controller to accept structured line payloads, allowing nested colours, display modes, speeds, and special functions per segment while keeping backwards compatibility with plain text arrays.
- Enhanced the LED send API to normalise structured payloads, summarise mixed-format messages for history records, and persist the flattened preview text for operator review.
[2.1.8] - 2025-10-30
Fixed
- Inserted the mandatory display-position byte in LED sign mode fields so M-Protocol frames comply with Alpha controller requirements.
[2.1.7] - 2025-10-29
Removed
- Purged IDE metadata, historical log outputs, unused static assets, and legacy diagnostic scripts that were no longer referenced by the application.
Changed
- Updated ignore rules and documentation so generated EAS artifacts and runtime logs remain outside version control while keeping the static directory available for downloads.
[2.1.6] - 2025-10-28
Changed
- Aligned build metadata across environment defaults, the diagnostics endpoints, and the
site chrome so
/health,/version, and the footer display the same system version. - Refreshed the README to highlight core features, deployment steps, and configuration guidance.
[2.1.5] - 2025-10-27
Added
- Added database-backed administrator authentication with PBKDF2 hashed passwords, login/logout routes, session persistence, CLI bootstrap helpers, and audit logging.
- Expanded the admin console with a user management tab, dedicated login page, and APIs for creating, updating, or disabling accounts.
- Introduced
.env.examplealongside README instructions covering environment setup and administrator onboarding. - Implemented the EAS broadcaster pipeline that generates SAME headers, synthesizes WAV audio, optionally toggles GPIO relays, stores artifacts on disk, and exposes them through the admin interface.
- Published
/admin/eas_messagesfor browsing generated transmissions and downloading stored assets.
Changed
- Switched administrator password handling to Werkzeug's PBKDF2 helpers while migrating legacy salted SHA-256 hashes on first use.
- Extended the database seed script to provision
admin_users,eas_messages, andlocation_settingstables together with supporting indexes.
[2.1.4] - 2025-10-26
Added
- Persisted configurable location settings with admin APIs and UI controls for managing timezone, SAME/UGC codes, default LED lines, and map defaults.
- Delivered a manual NOAA alert import workflow with backend validation, a reusable CLI helper, and detailed admin console feedback on imported records.
- Enabled editing and deletion of stored alerts from the admin console, including audit logging of changes.
- Broadened boundary metadata with new hydrography groupings and preset labels for water features and infrastructure overlays.
Changed
- Hardened manual import queries to enforce supported NOAA parameters and improved error handling for administrative workflows. mixed geometry types.
[2.1.0] - 2025-10-25
Added
- Established the NOAA CAP alert monitoring stack with Flask, PostGIS persistence, automatic polling, and spatial intersection tracking.
- Delivered the interactive Bootstrap-powered dashboard with alert history, statistics, health monitoring, and boundary management tools.
- Integrated optional LED sign controls with configurable presets, message scheduling, and hardware diagnostics. scripts for managing services.
[2.2.0] - 2025-10-29
Added
- Recorded the originating feed for each CAP alert and poll cycle, exposing the source in the alerts dashboard, detail view, exports, and LED signage.
- Normalised IPAWS XML payloads with explicit source tagging and circle-to-polygon conversion while tracking duplicate identifiers filtered during multi-feed polling.
Changed
- Automatically migrate existing databases to include
cap_alerts.sourceandpoll_history.data_sourcecolumns during application or poller start-up. - Surfaced poll provenance in the statistics dashboard, including the observed feed sources for the most recent runs.
[2.3.4]
Added
- Documented the public forecast zone catalog synchronisation workflow and prepared release metadata for the 2.3.4 build.
[2.3.0] - 2025-10-30
Changed
- Normalized every database URL builder to require
POSTGRES_PASSWORD, apply safe defaults for the otherPOSTGRES_*variables, and URL-encode credentials so special characters work consistently across the web app, CLI, and poller. - Trimmed duplicate database connection variables from the default
.envfile and aligned the container metadata defaults with the current PostGIS image tag. - Bumped the default
APP_BUILD_VERSIONto 2.3.0 across the application and sample environment template so deployments surface the new release number.
[2.4.9] - 2025-11-09
Fixed
- Switch certbot issuance to standalone HTTP-01 mode so the container itself binds to port 80 during startup, eliminating the connection reset failures that occurred before nginx began serving traffic.
- Log the standalone challenge server activation so operators can confirm ACME connectivity when debugging certificate renewals.
[2.4.8] - 2025-11-09
Fixed
- Verify existing certificates against the system trust store and expiration before skipping issuance, so stale self-signed chains are purged and a new ACME request runs on startup.
- Log detailed reasons when certificate validation fails and remove the associated material, making it obvious when fallback artifacts block public issuance.
[2.4.7] - 2025-11-09
Fixed
- Detect existing certificates issued by anything other than Let's Encrypt (including legacy self-signed chains) and automatically purge them so startup always retries public issuance instead of reusing stale fallbacks.
- Extend the certificate cleanup routine to treat unknown issuers as invalid, guaranteeing that deployments replace outdated self-signed material with a fresh ACME request on every boot.
[2.4.6] - 2025-11-09
Fixed
- Remove any lingering self-signed certificate directories (including suffixed variants) on container startup so stale fallbacks are purged before new issuance attempts.
- Extend the certificate purge routine to clean historical self-signed material before certbot runs, preventing nginx from reusing temporary chains across restarts.
[2.4.5] - 2025-11-09
Fixed
- Purge the domain's existing
/etc/letsencryptmaterial whenever a self-signed fallback is detected so administrators no longer need to manually delete leftover files before retrying ACME issuance. - Force certbot to request a fresh certificate for self-signed domains by assigning a stable certificate name and forcing renewal so nginx replaces fallback chains during the next startup sequence.
[2.4.4] - 2025-11-09
Fixed
- Detect legacy self-signed fallback certificates by inspecting the existing fullchain.pem and purge them before retrying Let's Encrypt so deployments stop serving stale fallback chains from earlier releases.
- Remove invalid certificate files prior to issuing new ones so nginx never launches with the leftover self-signed materials while ACME runs.
[2.4.3] - 2025-11-09
Fixed
- Detect previously generated self-signed certificates and automatically retry Let's Encrypt issuance so production domains replace fallback certs on the next start.
- Tag self-signed fallbacks with a marker file and clear it after successful issuance to avoid skipping renewal attempts on subsequent container restarts.
[2.4.2] - 2025-11-09
Fixed
- Provision certbot in the nginx container via Python's package manager so Let's Encrypt
requests no longer fail with
certbot: not found. - Replaced bash-specific
[[ ... ]]usage in the nginx initialization script with POSIX-compatible logic to maintain reliable self-signed fallback handling.
This document is served from docs/reference/CHANGELOG.md in the EAS Station™ installation.