Version 3.20.2

Professional Emergency Alert System — Emergency alert system for Putnam County, OH

Build Information

Commit: 781ae60a
Branch: main
Date: 2026-09-19T04:16:04Z
Message: Add HSTS preload flag + Certbot nginx re-sync action (#2664)

What's New in 3.20.2

Latest Release

Release History

v3.20.2 Current
v3.20.2 Current
2026-09-19
Added (1)
  • **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.sh` pulls 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 (like `ssl_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-certificate` route -- 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.
v3.20.1
2026-09-19
Added (1)
  • **`preload` added to the `Strict-Transport-Security` header** in both HTTPS server blocks of `config/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.
v3.20.0
2026-09-18
Added (4)
  • **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.conf` now sets `ssl_stapling on; ssl_stapling_verify on;` plus a `resolver` in 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 writes `ssl_trusted_certificate` (from the issued cert's `chain.pem`, alongside the `fullchain.pem`/`privkey.pem` it 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) if `chain.pem` is 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.
v3.19.2
2026-09-18
Fixed (1)
  • **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 the `TickstemSettings` model, 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.
v3.19.1
2026-09-18
Fixed (2)
  • **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 -6` timeouts against healthchecks.io, Tickstem's API, Google's public DNS, and Cloudflare alike -- not specific to any one of them). `socket.create_connection()` tries `getaddrinfo()`'s results in order, and RFC 6724 sorts IPv6 first, so every outbound `requests` call 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 at `app.py` import time, which makes `requests`/`urllib3` skip 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.
v3.19.0
2026-09-18
Added (4)
  • **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 per `app_core.config.get_eas_services()` entry (the same 11 EAS subsystems + poller Tickstem's per-service heartbeats already cover), each pinged by the shared `HeartbeatWorker` background 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." New `HealthchecksSettings` (encrypted account API key) and `HealthchecksServiceHeartbeat` (per-service check UUID/ping URL/interval/status) models, migration `20260918_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.py` is 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 existing `test_tickstem_service_heartbeats.py` shape, since both providers share `HeartbeatWorker._ping_one_service_heartbeat()`), the API client's request/error-handling shape, and the bulk-create route's subset/quota-stop/missing-key behavior.
v3.18.3
2026-09-18
Fixed (2)
  • **`Strict-Transport-Security`, `X-Frame-Options`, `X-Content-Type-Options`, and `X-XSS-Protection` were set in both `app.py` (Flask's `after_request` hook) and `config/nginx-eas-station.conf` (`add_header ... always;`), with nginx's `add_header` unaware of and not deduplicating against whatever the proxied Flask response already set.** Confirmed live via `curl -I` against the running deployment: the HTTPS response carried two separate `strict-transport-security` header fields with *different* `max-age` values (63072000 from Flask, 31536000 from nginx). Per RFC 6797 §8.1, a browser that receives more than one `Strict-Transport-Security` header 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 from `app.py`; nginx — the actual TLS-terminating edge — is now the single source of truth for all of them. `Content-Security-Policy` stays in `app.py` since 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 to `tests/known_failures.txt` as a documented xfail, matching the existing `test_support_smoke.py`/`test_upload_too_large_handler.py` entries: the `app_client` fixture forces `DATABASE_URL` to sqlite unconditionally, and any real request through it hits `db.create_all()` against a JSONB column sqlite can't compile. Verified manually with live `curl` instead, the same way those existing entries document doing.
v3.18.2
2026-09-18
Fixed (1)
  • **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 when `pre_alert_chime`/`post_alert_chime` is set to `mdc1200` — 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, when `pre_alert_chime`/`post_alert_chime` is `none`).
v3.18.1
2026-09-18
Fixed (2)
  • **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 same `Intersection` rows the map ignored). Added an optional `alert_id` query param to `GET /api/boundaries` that joins `Intersection` when 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" — `Boundary` rows 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" bug `tests/test_coverage_and_signature.py::TestCoverageCalculationLogic` was 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).
v3.18.0
2026-09-18
Changed (5)
  • Extracted 12 methods (plus the module-level `_serialize_alert_for_sig` helper and the `MESSAGE_TYPE_PRIORITIES` class constant) into `poller/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 touched `self.logger` (set once in `__init__`, never reassigned) or each other — never `self.db_session` or the poller's zone/SAME-code configuration — making them a genuinely low-risk collaborator, unlike the 50 methods that remain.
  • `logger` is threaded through as an explicit parameter rather than a fresh per-module `logging.getLogger(__name__)`, specifically to avoid the module-level-logger hazard `docs/development/AGENTS.md` documents from Phase 3e (a new logger here would silently rename every log record from `poller.cap_poller` to `poller.cap_geometry`). `cap_poller.py`'s 4 remaining call sites pass `self.logger` explicitly.
  • 59 characterization tests written against the pre-extraction bound methods first (`tests/test_cap_geometry.py`, superseding a since-deleted `test_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 in `cap_geometry.py` as free functions.
  • `cap_poller.py`: 4800 → 4244 lines. `CAPPoller` itself 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.
v3.17.0
2026-09-18
Changed (6)
  • 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__.py` shim re-exporting all 40 public names (and the `format_local_datetime`/`utc_now` pass-throughs some callers import from this module) exactly as before.
  • All 69 top-level definitions (57 functions + 12 constants, plus the `PrecedenceLevel`/`PRECEDENCE_AVAILABLE` try/except import block) verified `ast.dump()`-identical to their originals — no normalization needed this time, since nothing moved out of a class (unlike 4a-ii/4c's `self`-stripping).
  • Kept `collect_compliance_log_entries` and `collect_compliance_dashboard_data` in 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 as `eas.py`'s `build_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_DESCRIPTIONS` from `app_utils.eas`, imported but never referenced anywhere in the original file).
  • `compliance_log.py` lands 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.
v3.16.1
2026-09-18
Added (2)
  • **`app_core/minimal_app.py`**: a `create_minimal_app()` helper that builds a bare Flask app bound only to the shared `db` extension (`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 way `app.py` does (`CONFIG_PATH` or the default `.env`, both `override=True`), and sizes the SQLAlchemy engine pool for a one-shot process (`pool_size=1`) instead of `app.py`'s two-gunicorn-worker sizing (`pool_size=10`). `tests/test_minimal_app.py` covers the `DATABASE_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`, and `scripts/create_example_screens.py` at the new bootstrap instead of `app.py`'s `create_app()` — all three only ever needed `db.session` and 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 touch `app.py`.
Fixed (1)
  • **`scripts/ingest_security_perimeter_log.py` booted 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 via `security-perimeter-ingest.timer` — a `Nice=10`/`IOSchedulingClass=idle` mitigation (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 default `static` endpoint).
v3.15.0
2026-09-18
Added (6)
  • **Branch protection on `main`**: `CodeQL`, `analyze`, `lint`, and `pytest (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_merge`** enabled at the repo level, and **`delete_branch_on_merge`** enabled — 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.md` documenting 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 checklist `docs/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 a `config.yml` disabling blank issues and redirecting security reports to private vulnerability reporting instead.
Fixed (2)
  • **`.github/workflows/release.yml`'s "Validate release metadata" step was broken** and had been since 2026-09-17, silently: it only ever installed bare `pytest`, not the project's own dependencies. `tests/conftest.py`'s autouse `_isolate_eas_stream_injection` fixture (added that day, see the 3.10.3 entry above) imports `app_core.audio.redis_commands` for every test unconditionally, which needs Flask/SQLAlchemy/etc. importable even for `tests/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 installing `requirements.txt` before running pytest, matching `tests.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`.
v3.16.0
2026-09-18
Changed (5)
  • Extracted `CAPPoller`'s 9 stateless methods (zero `self` references, 143 lines) into `poller/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 internal `self.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 away `self` and one level of docstring indentation — the same two normalizations Phase 2d needed for `GPSManager`'s stateless methods).
  • Retargeted `tests/test_ipaws_event_code_extraction.py`'s `TestExtractCapEventCodes` off a live `CAPPoller` instance (`object.__new__(CAPPoller)`, built solely to reach the now-moved method) to call `_extract_cap_event_codes` directly.
  • Full test suite green.
  • `ruff` (not available in this environment by default) installed into a scratch venv for a real lint pass, catching two `F821`s that `py_compile` and even a real `import` of the new module had both stayed silent about: `ET.Element`/`CAPAlert` type hints relying on `from __future__ import annotations` to dodge real imports, which defers evaluation but doesn't exempt a name from needing to resolve. Fixed by importing `ET` the same way `cap_poller.py` itself does (`get_element_tree_module()`) and `CAPAlert` from `app_core.models`.
v3.14.0
2026-09-18
Changed (7)
  • 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.py` is now a package; the old single-file module is gone, and `app_utils/eas/__init__.py` is 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`) and `broadcaster.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()` calls `build_same_header()` and `clear_broadcast_active()` as same-module bare names today; `tests/test_gpio_centralized_keying.py` monkeypatches both at the `app_utils.eas` module level expecting to intercept that internal call. Once `EASBroadcaster` moved 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 with `KeyError: 'present'` (the recording wrapper never got called). Retargeted to `eas.broadcaster.build_same_header` / `eas.broadcaster.clear_broadcast_active`.
  • `subprocess` and `time` are re-exported from the package `__init__` (`import subprocess`, `import time`) so `tests/test_gpio_dump_broadcast.py`'s `eas_module.subprocess.Popen` patch and `tests/test_airchain_fringe_cases.py`'s `patch('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.eas` across 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.
v3.13.0
2026-09-18
Changed (4)
  • Extracted pre-flight validation into `obtain_validation.py` (`_validate_obtain_request`, `_check_certbot_installed`) and the three certbot methods (standalone, nginx plugin, webroot) into `obtain_methods.py`. `routes_obtain_execute.py` is 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.py` is no longer a known exception.
v3.12.0
2026-09-18
Changed (3)
  • 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.py` is now 191 lines of per-device orchestration.
  • `_collect_smart_health`'s public signature and return shape are unchanged; still resolves from `app_utils.system` and is used unchanged by `webapp/admin/api/routes_smart.py` and `scripts/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 existing `tests/test_smart_health.py` already covered the exit-code status inference in depth and needed only its `subprocess.run`/`os.path` patch 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 standby` for 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.
v3.11.0
2026-09-18
Changed (3)
  • 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 existing `network.py`. The overall-status computation moved to a new `status.py`. `snapshot.py` is now 158 lines of orchestration calling these collectors and assembling the response dict — every module in `app_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_snapshot` and `app_utils.system.build_system_health_snapshot` still 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 `/`").
v3.10.4
2026-09-17
Fixed (3)
  • **Root cause**: `templates/alert_detail.html`, `audio_detail.html`, and `manual_eas_print.html` spelled the "escape untrusted CAP text, then insert paragraph/line-break HTML" transform out inline as `{{ text | e | replace('\n\n', '</p><p ...>') | replace(...) | safe }}`. `| e` produces a Jinja/MarkupSafe `Markup` object; Jinja's `|replace` filter, given an already-`Markup` value, routes to `Markup.replace()` — which HTML-escapes its *own* replacement argument too, as part of the invariant that keeps a `Markup` value 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 `&lt;p ...&gt;` rather than a real tag, which a browser then displays as visible `<p ...>` text once the whole thing is marked `| safe` at the end.
  • Moved the transform into Python (`webapp/template_helpers.py`): fixed the existing, previously-unused `nl2br` filter to escape input before substituting (building the finished HTML as a plain `str`, never touching `Markup.replace()`, then wrapping the result in `Markup` exactly once at the end), and added a new `cap_paragraphs` filter 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.
v3.10.3
2026-09-17
Fixed (5)
  • **Root cause**: `EASBroadcaster.handle_alert()` (`app_utils/eas.py`) pushed generated broadcast audio into Icecast by calling `app_core.audio.eas_stream_injector.inject_eas_audio()` directly, in-process. That function is a no-op unless `eas_stream_injector.set_controller()` was called earlier *in the same process* — which only happens in `eas_monitoring_service.py`, the entry point of `eas-station-audio.service`. `handle_alert()` is also invoked from `poller/cap_poller.py` (the primary CAP/IPAWS ingest path, the gated-alert auto-release timer, and the forwarding catch-up sweep — all running as `eas-station-poller.service`) and from `webapp/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: the `EASMessage` DB 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 commit `ff640d3` (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 *named* `inject_eas_audio()`, so it could not detect that the call was a cross-process no-op.
  • `handle_alert()` now checks `eas_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`), asking `eas-station-audio.service` — the process that actually owns the live `IcecastStreamer` threads — 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 `EASMessage` row (`metadata_payload['icecast_injected']`) so a transient failure (audio-service down, Redis unreachable at exactly that moment) isn't a permanent, silent loss. A new `CAPPoller.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.py` and `tests/test_icecast_injection_retry_sweep.py`.
  • While verifying this fix, a test run that exercised `handle_alert()`'s new fallback without mocking it sent 7 real `inject_raw_eas_audio` commands to the actual live `eas-station-audio.service` on the dev box, each one audibly injecting a short synthetic test WAV into the live Icecast streams. The existing `REDIS_DB=15` test isolation (added after an earlier incident where the test suite keyed a live GPIO relay) did not protect against this: Redis `PUBLISH`/`SUBSCRIBE` are global across logical databases regardless of which DB a client has `SELECT`ed, unlike key-value commands. Added an autouse fixture (`tests/conftest.py::_isolate_eas_stream_injection`) that stubs `AudioCommandPublisher` for every test by default, so no test can reach the real audio-command channel without explicitly opting in.
v3.10.2
2026-09-17
v3.10.1
2026-09-16
v3.10.0
2026-09-16
v3.9.3
2026-09-16
v3.9.2
2026-09-14
v3.9.1
2026-09-14
v3.9.0
2026-09-14
v3.8.0
2026-09-14
v3.7.4
2026-09-14
v3.7.3
2026-09-14
v3.7.2
2026-09-14
v3.7.1
2026-09-14
v3.7.0
2026-09-14
v3.6.0
2026-09-13
v3.5.0
2026-09-13
v3.4.0
2026-09-12
v3.3.1
2026-09-12
v3.3.0
2026-09-12
v3.2.6
2026-09-12
v3.2.5
2026-09-12
v3.2.4
2026-09-12
v3.2.3
2026-09-11
v3.2.2
2026-09-11
Fixed (5)
  • `templates/admin/data_management.html`: removed the duplicate Zone Catalog tab and its now-unused `zone-catalog.js` include; added a link to the pre-existing `/admin/zones` page instead. The Boundaries + Manage tabs (a genuinely cohesive upload-then-browse/delete workflow) are untouched.
  • `webapp/admin/dashboard.py`: corrected the `data_management_page()` docstring's stale "confirmed not a duplicate" claim.
  • `webapp/navigation/registry_settings.py`: updated the Data Management `NavItem` description to drop the zone-catalog mention.
  • Noted but not fixed (see the roadmap doc): `data_management.html`'s generic boundary uploader also offers a `county` type, 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.
v3.2.1
2026-09-11
Fixed (3)
  • `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 dead `CUSTOM_DISPLAY_SCREENS.md` link 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-card` CSS; added a "Documentation" button in the page header linking to `/help`.
  • `tests/test_screens_help_docs_merge.py` (2 tests): `/screens` no longer embeds the removed tab or the dead link; `/help` has the merged reference content and the dead link is gone there too.
v3.2.0
2026-09-11
Changed (4)
  • New page **System Upgrade** (`/admin/system-upgrade`, `maintenance.system_upgrade_page`), with a new `NavItem` under Settings → Data & Storage. `operations.html` links to it instead of embedding the upgrade wizard/progress UI.
  • `operations.html`'s "Alert Boundary Coverage" card now links to `/admin/intersections` instead of duplicating its recalculation buttons.
  • `operations.html` keeps 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.
v3.1.0
2026-09-11
Added (3)
  • New page **Settings → Bad Actor Blocklist** (`/admin/security/bad-actors/`), on the existing `bad_actors` blueprint. `templates/admin/application_settings.html` links 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 `NavItem` in `registry_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.
v3.0.1
2026-09-11
Fixed (1)
  • `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-in` double 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-in` would see the mismatch immediately. Reworded to describe both opt-in paths accurately.
v3.0.0
2026-09-11
Changed (6)
  • **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`: new `sms_settings()` view and `update_sms_settings()` POST route (`/admin/notifications/sms/update`), split out of `notification_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" `NavItem` alongside 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 duplicate `docs/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.
v2.233.0
2026-09-11
Added (3)
  • `app_core/_models_sms_log.py` (`SmsMessageLog`, migration `20260911_add_sms_message_log`): one row per outbound SMS send attempt, recorded right next to each Twilio call in `app_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.
v2.232.1
2026-09-11
Added (3)
  • **Settings → Notifications**: a QR code (`/admin/notifications/sms-optin-qr.png`, gated behind `system.configure`) next to the existing "share this link" callout, for signage or printed material. Generated fresh per request from `url_for(..., _external=True)` -- never a fixed hostname -- so each deployment's QR code always points at itself.
  • `/sms-compliance` now links directly to `/sms-opt-in` in its Opt-In/Consent Disclosure section (instead of only describing the admin-added path), and renders the consent disclosure language from the same `CONSENT_TEXT` constant `/sms-opt-in` itself 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.
v2.232.0
2026-09-11
Added (6)
  • 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`, migration `20260911_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 nullable `verified_at` that'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`'s `LoginRateLimiter`) 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`, and `docs/policies/SMS_MESSAGING.md` updated 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.
v2.231.1
2026-09-11
Fixed (3)
  • `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 same `LoginRateLimiter` class `/login` already uses (5 attempts / 15-minute lockout), under a separate `mfa:`-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 the `otpauth://` URI its pixels represent -- without `Cache-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.
v2.231.0
2026-09-11
Added (6)
  • `config/nginx-eas-station.conf`: new `listen 8081` server block that proxies to pgweb only after an `auth_request` subrequest confirms the caller has a logged-in session with the `system.configure` permission -- 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 `/login` instead of reaching pgweb.
  • `app.py`: `/api/internal/pgweb-auth-check`, the endpoint that `auth_request` subrequest 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's `auth_request` module 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 to `127.0.0.1` only (an internal port nginx proxies to) instead of `0.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's `auth_request` contract needs (401 unauthenticated, 403 authenticated-without-permission, 200 authenticated-with-permission -- never a redirect) and the status page's rendering for both installed states.
v2.230.0
2026-09-11
v2.229.2
2026-09-10
Added (2)
  • `scripts/lib/ui.sh`: `ui_banner` and `show_celebration` now 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 --gauge` progress 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.
v2.229.1
2026-09-10
Added (2)
  • `scripts/lib/ui.sh`: every `whiptail` dialog install.sh/update.sh show (`--yesno`, `--msgbox`, `--gauge`, `--menu`, ...) now renders in a solid Turbo-Vision-blue `NEWT_COLORS` theme, 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-supplied `NEWT_COLORS` if one is already set.
  • The `--gauge` progress bar's filled portion is now green instead of red, which read as an error/danger color rather than progress.
Fixed (1)
  • `_DOS_GREY` used `\033[0;37m`, whose leading `0` resets 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).
v2.229.0
2026-09-10
Added (3)
  • 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`: `ProgressTracker` now tracks each operation's wall-clock `started_at` (recovered from the on-disk payload so a later phase's tracker doesn't reset the clock) and reports `elapsed_seconds`/`eta_seconds` alongside `percent`.
  • `webapp/admin/maintenance/routes_upgrade_progress.py`: `/admin/operations/upgrade/progress` now also reports `elapsed_seconds`/`eta_seconds`, derived from systemd's `ActiveEnterTimestampMonotonic` property paired with `/proc/uptime` -- immune to timezone/NTP wall-clock issues that a parsed `ActiveEnterTimestamp` string would have.
Fixed (2)
  • 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.Lock` deadlock in `ProgressTracker.update()`/`complete()`/`error()`: the new elapsed/ETA calculation could recover `started_at` via the same-named, also-locking `ProgressTracker.get()`, which deadlocks if called from inside the already-held lock. The timing calculation now always runs before the lock is acquired.
v2.228.23
2026-09-10
Fixed (2)
  • `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.
v2.228.22
2026-09-10
Fixed (1)
  • 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 like `tiles.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.
v2.228.21
2026-09-10
Fixed (1)
  • `requirements-sdr.txt`'s `numba` pin now matches `requirements.txt`'s (`>=0.67.0,<0.68.0`). Verified with a fresh dependency resolve: resolves cleanly to `numpy-2.5.3`, `numba-0.67.0`, `llvmlite-0.49.0`.
v2.228.20
2026-09-10
Fixed (6)
  • **`system_log.timestamp` had no index.** `app_core/_models_admin.py`. `SELECT * FROM system_log ORDER BY timestamp DESC LIMIT 20` cost 500ms-1s -- a parallel sequential scan across all 440k rows (810MB) plus a sort, just to fetch 20 rows. Hit every 10 seconds forever by `websocket_push.py`'s `_emit_logs_update` (inside the persistent slow-loop session), plus the `/logs` page. Verified live: `EXPLAIN (ANALYZE, BUFFERS)` went from **1023.8ms** to **0.055ms** after adding the index -- roughly 18,600x. New migration `20260910_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_update` and `_emit_audio_health_update` both 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 one `CAPAlert.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. New `tests/test_import_alert_batching.py` covers 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 a `received_eas_alerts` row. Batched into one query for the whole id list.
v2.228.19
2026-09-10
Fixed (1)
  • Added the same `_UI_HAS_CONTROLLING_TTY` guard already used everywhere else in the file to the six call sites above. Verified with a real before/after run under `setsid ... </dev/null` (no controlling tty): the unfixed version throws 3 `/dev/tty: No such device or address` errors from a two-line smoke test; the fixed version is silent.
v2.228.18
2026-09-10
Fixed (1)
  • `eas_monitoring_service.py` never had `services/common/bootstrap.py`'s `init_runtime()` applied — the exact fix already proven on `eas-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. Added `init_runtime("audio")` as the first statement in `main()`, before any thread spawns (arena caps only bind threads created afterward), and mirrored `eas-station-displays.service`'s systemd env vars onto `eas-station-audio.service`: `MALLOC_ARENA_MAX=2`, `MALLOC_TRIM_THRESHOLD_=131072`, and `MEMDIAG_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).
v2.228.17
2026-09-10
Fixed (1)
  • `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's `except` block. A purely successful pass -- the common case -- never committed, so every `SELECT`-only emit (e.g. `_emit_pending_alerts_update`'s `gated_alerts` query, the `count(CAPAlert.id)` in the system-health snapshot) left its read transaction open indefinitely. Added one `db.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 (2)
  • The rotated `eas_station.log` backups (`app.py`'s `RotatingFileHandler`, 10 MB × 5 backups) are now gzip-compressed on rotation via a custom `rotator`/`namer` pair, instead of sitting on disk as plain text.
  • The retention sweep (`app_core/retention.py`) now also prunes the `*.json` metadata sidecar that `sdr_hardware_service.py` writes next to every `*.npy` IQ capture -- previously only the `.npy` itself matched the prune pattern, so every capture left a small orphaned sidecar behind forever. Also added a new fixed-age (30 day, not exposed in `retention_settings` -- these are internal diagnostics, not a sized data category) sweep step for `eas-memdiag-*.txt` snapshots and `eas-station-*-startup-error-*.log` crash dumps in `EAS_LOG_DIR`, neither of which previously had any cleanup mechanism at all.
v2.228.16
2026-09-09
Fixed (4)
  • `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 called `scipy.signal.lfilter(..., [1.0], ...)` -- the same pure-FIR case that unconditionally takes scipy's slow `np.apply_along_axis(...) -> np.convolve` fallback fixed in `drivers.py` back 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-chunk `np.convolve` (stateless, so every chunk boundary produced a transient that flooded the RBDS bit-sync with garbage) to `lfilter` with a persisted `zi` delay line. That fix was correct, but the developer had no way to know `lfilter`'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 same `np.convolve` routine being moved away from, just wrapped behind a state-carrying API. Both filters now use overlap-add via `oaconvolve` (FFT-based, and its convolution "tail" carries state across chunks exactly like `zi` did -- same technique as 2.228.14's fix and `FMDemodulator._mono_audio_lowpass`). The lowpass filter also drops its real/imag `lfilter` split entirely -- `oaconvolve` handles 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 identical `lfilter(..., 1.0, ...)` pure-FIR pattern. Not currently exercised by the live `wbks` receiver (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`'s `scipy.signal.iirnotch`-derived filter (a genuine multi-tap IIR, `len(a) == 3`) and `FMDemodulator._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.
v2.228.15
2026-09-09
Fixed (1)
  • `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), and `hset()` + `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-transactional `redis_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.
v2.228.14
2026-09-09
Fixed (1)
  • Reading scipy's actual `lfilter` source (`_signaltools.py`) showed the real condition: the fast C path (`_sigtools._linear_filter`) only activates when `len(a) > 1` -- a true IIR filter. This anti-alias filter is pure FIR (`a=1.0`), so **any** `lfilter` call on it -- real or complex input, split or not -- unconditionally takes the slow `np.apply_along_axis(...) -> np.convolve` fallback. 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_loop` now replaces `lfilter` entirely with overlap-add via `scipy.signal.oaconvolve` (FFT-based, handles complex input natively, no split needed) -- the same technique `FMDemodulator._mono_audio_lowpass` already uses successfully. Benchmarked directly: `oaconvolve` on 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-up `py-spy record` after deploying shows `numpy.convolve` gone entirely from the profile (the only remaining FFT-related cost is legitimate `oaconvolve` work at ~8.4% combined), and `sdr_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 in `tests/test_early_decimation.py`: `test_oaconvolve_hot_path_matches_single_complex_lfilter_call` (numerical equivalence against the filter's mathematical ground truth) and `test_chunked_hot_path_matches_single_continuous_call` (proves the tail-carry/phase state stitches irregular real-world USB-read chunk boundaries seamlessly).
v2.228.13
2026-09-09
Fixed (1)
  • `_capture_loop`'s early-decimation anti-alias filter (`app_core/radio/drivers.py`) called `scipy.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 generic `numpy.apply_along_axis(...) -> numpy.convolve` path, 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). `RBDSWorker` had already discovered and worked around this exact scipy behavior (`_apply_interference_notch` and 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 splits `to_decimate` into `.real`/`.imag`, filters each independently (two real-valued `lfilter` calls, each hitting the fast path) with separate `zi` delay-line state, and recombines as `real_out + 1j*imag_out` -- exact, not an approximation, since the filter coefficients are real and the two components are independent linear systems. New `test_real_imag_split_matches_single_complex_lfilter_call` in `tests/test_early_decimation.py` proves numerical equivalence (rtol=1e-5) against the old single-complex-call path on a multi-tone test signal; the existing `test_alias_image_is_rejected` and `test_rbds_passband_is_flat` tests (which exercise actual filter *behavior*, not just call shape) continue to pass unchanged.
v2.228.12
2026-09-09
Fixed (1)
  • Root cause: `_SoapySDRReceiver`'s device-open path (`app_core/radio/drivers.py`) called `device.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 whenever `modulation_type` is 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 in `tests/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`.
v2.228.11
2026-09-09
Fixed (2)
  • `FMDemodulator._decode_stereo()` (`app_core/radio/demod/fm.py`) recomputed `pilot_rms = np.sqrt(np.mean(pilot_filtered ** 2))` even after the previous fix started passing in `pilot_filtered` itself -- `demodulate()` already computes that identical mean+sqrt reduction over the same array to derive `stereo_pilot_strength`, before ever calling `_decode_stereo`. Now accepts `pilot_rms` as an optional parameter, passed through from `demodulate()`; only recomputed when omitted. New `test_precomputed_pilot_rms_is_numerically_equivalent` in `tests/test_fm_stereo_decoder.py` proves bit-for-bit equivalence.
  • `demodulate()`'s stereo call site built `stereo_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`'s `sample_indices` parameter, 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_indices` is now optional (default `None`) and the call site no longer builds it. New `test_decode_stereo_no_longer_requires_sample_indices` confirms the method works without it.
v2.228.10
2026-09-09
Fixed (6)
  • `FMDemodulator.demodulate()` (`app_core/radio/demod/fm.py`) already computes the 19 kHz-bandpass-filtered multiplex once, to decide `stereo_pilot_locked`, *before* it ever calls `_decode_stereo` -- which then recomputed the identical `oaconvolve(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_stereo` now accepts the already-computed value as an optional parameter and only recomputes it when a caller doesn't supply one (every direct-call test in `tests/test_fm_stereo_decoder.py` still exercises the original internal-compute path unchanged). New `test_precomputed_pilot_filtered_is_numerically_equivalent` proves the two paths produce bit-for-bit identical output. Full audio/demod/RBDS/FM/stereo/SDR test surface (467 tests) run clean.
  • `/api/gpio/status` — the `vfd_gpio_status` default 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, so `scripts.screen_renderer.ScreenRenderer`'s unauthenticated `localhost` requests 401'd on every single render cycle; confirmed live in the service's own logs. Added `require_permission_or_local_network()` (`app_core/auth/roles.py`, mirrors the existing `require_permission_or_setup_mode` pattern) so an anonymous local-network caller is let through -- the same case `app.py`'s `LOCAL_API_GET_PATHS` already exempts from login app-wide -- while a signed-in session without `gpio.view` is still denied. `/api/gpio/status` registered in `LOCAL_API_GET_PATHS` to match.
  • `scripts/screen_renderer.py`'s `evaluate_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 configured `expected` value as whatever raw type its JSON stored, typically a bare int (`{"value": 0}`) -- mixing `str` and `int` on a `>`/`<`/`>=`/`<=` comparison and raising `TypeError`, caught by the outer handler and logged on every cycle. Both sides now fall back to strings together.
  • - turned out to still be firing roughly every 30 seconds, continuously,
  • `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" -- but `DemodWorker._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.2`s throttle on the status write only; the audio `publish()` call right next to it -- the actual signal data -- is untouched and still fires every chunk. New tests in `tests/test_demod_service.py` covering both the throttling and that it resumes after the window elapses.
  • `_default_public_source()` (`webapp/routes_now_playing.py`) picked the first enabled, Icecast-published source ordered by `priority` descending -- but that `priority` field is `source_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`'s `UnifiedEASMonitorService`) uses -- that class ignores `priority` entirely 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 in `tests/test_now_playing_api.py`.
Changed (1)
  • Dependabot dependency bump (minor release, no CVE). Synced the three tech-stack badges (`README.md` x2, `templates/partials/tech_stack_badges.html`) and a stale `requirements.txt` comment that referenced the old pinned version -- Dependabot only ever touches the pin itself, not the badges or comments describing it.
v2.228.5
2026-09-09
Fixed (2)
  • Restructured all 89 affected entries (v2.193.9 through v2.228.4) into proper `### Added`/`### Changed`/`### Fixed`/`### Removed`/`### Deprecated`/`### Security` sections. 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 via `app_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`/`### Changed` from the start instead of drifting back to flat prose.
Changed (2)
  • **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`'s `subprocess.run()` was running inside `_run_off_worker`'s `gevent.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. Split `video_export.py` into `render_alert_video_frames()` (CPU/network-bound Pillow work, safe on the threadpool) and `encode_frames_to_mp4()` (the ffmpeg subprocess call, which must run back on the request's own greenlet so gevent's cooperative `subprocess.run()` actually works); `routes_alert_export_video.py` now 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 a `broadcast_state_update` push/poll to report `active: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/abort` returns a 409 ("No broadcast is currently active"), and reconciles with the server on `visibilitychange` so a stale overlay self-corrects on tab focus.
v2.228.3
2026-09-09
Changed (1)
  • Dependabot dependency bump (patch release, no CVE). Synced the three tech-stack badges (`README.md` x2, `templates/partials/tech_stack_badges.html`) that `tests/test_tech_stack_badges.py` checks against `requirements.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.
v2.228.2
2026-09-07
Fixed (2)
  • **Fixed**: `security-perimeter-ingest.service` (new in 2.227.0, run every 2 minutes by `security-perimeter-ingest.timer`) boots the full Flask app via `create_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 same `create_app()`-for-a-CLI-script pattern `scripts/create_example_screens.py` and `scripts/fix_admin_roles.py` use, 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's `Nice=-3` fix below).
  • Added `Nice=10` and `IOSchedulingClass=idle` to `security-perimeter-ingest.service` so 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.
v2.228.1
2026-09-07
Fixed (3)
  • **Fixed**: the `wbks` SDR 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`/`WNCI` don'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 at `INFO` instead of `DEBUG` -- 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 to `DEBUG`. This alone did not measurably reduce CPU or the drop rate; kept as a legitimate cleanup, not the fix.
  • `eas-station-demod.service` and `eas-station-audio.service` now run at `Nice=-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 existing `Nice=-5` on `eas-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.
v2.228.0
2026-09-09
Added (5)
  • **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 (ICY `StreamTitle`) 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/sources` carries -- mount/server/port, bitrate, device params, priority -- is exposed, matching the existing public/local/private API tiers documented in `app.py`'s `PUBLIC_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_fields` into a standalone `app_core/audio/now_playing_metadata.py` so both the audio-service process (which still pushes `StreamTitle` updates to Icecast itself) and the webapp process (this new endpoint) share one implementation instead of two independently-drifting copies. `IcecastStreamer._extract_metadata_fields` is now a thin backward-compatible wrapper.
  • Surfaced in the UI: `/audio_monitoring` now 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 change `IcecastStreamer`'s own extraction behavior (`tests/test_icecast_metadata_url_decoding.py`, unchanged, still passing).
v2.227.1
2026-09-09
Fixed (2)
  • **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.
v2.227.0
2026-09-04
Added (5)
  • **New**: nginx-level rate limiting (`/api/` 20r/s, `/login` 5r/min, both `limit_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 `/login` page 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 via `bad-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 existing `IPFilter` blocklist (new `IPFilterSource.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 new `security_perimeter_events` table; required adding the `eas-station` service user to the `adm` group (it couldn't read the nginx log at all before).
  • Repeatable across deployments: `install.sh` and `update.sh` both 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 (1)
  • **Fixed**: `dashboard_status.js`, `health.js`, and `system_health.html` kept retrying `/api/eas-monitor/status`, `/api/system_status`, and `/api/system_health` forever on 401, even though those endpoints are intentionally restricted to local-network/authenticated callers (`app.py`'s `LOCAL_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.
v2.226.0
2026-09-04
Added (4)
  • **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 static `API Reference` page, 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 existing `before_request`/`after_request` hooks into `WebRequestLog` (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; `WebRequestLog` gained a nullable `endpoint` column (Flask's dotted view-function name) captured alongside it, in the same namespace `compute_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-side `percentile_cont`, since the same code needs to run on PostgreSQL in production and SQLite in tests) and `webapp/routes_api_dashboard.py`. Latency percentiles use nearest-rank over sorted per-route response times.
  • New tests in `tests/test_api_stats.py`.
v2.225.3
2026-09-04
Changed (1)
  • **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.
v2.225.2
2026-09-04
Fixed (2)
  • **Fixed**: a "Stream"-type audio source (`app_core/audio/sources.py`'s `StreamSourceAdapter`) 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_fatal` and `test_restart_retries_on_404_instead_of_stopping`.
v2.225.1
2026-09-04
Fixed (2)
  • **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_downscale` in `tests/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.
v2.225.0
2026-09-04
Changed (4)
  • **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.py` switches 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_box` primitive in `drawing.py`; `app_utils/image_export/layout.py`'s landscape preset resized accordingly.
  • New tests in `tests/test_image_export_broadcast_panels.py`.
v2.224.0
2026-09-04
Added (4)
  • **New**: Settings -> Map Tiles (`/admin/map-tiles`) lets an operator switch the alert share-card
  • New `MapTileSettings` model (`carto_api_key` encrypted at rest like every other stored
  • `app_utils/image_export/tiles.py`: tile cache keys (both the in-memory LRU and the on-disk
  • New tests: `tests/test_map_tile_settings.py` (including a regression test reproducing the exact
v2.222.0
2026-09-03
Changed (4)
  • **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.py` replaced by `video_export.py` -- reuses the same per-frame `generate_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.py` replaced by `routes_alert_export_video.py`.
  • New tests in `tests/test_image_export_video.py` exercise 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, which `yuv420p` cannot encode without an explicit even-dimension filter.
v2.221.1
2026-09-03
Fixed (4)
  • **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 own `sessionmaker()` session with no Flask app ever pushed. Decrypting the column raised `RuntimeError: Working outside of application context` deep 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 the `SECRET_KEY` environment variable when there's no Flask app context, rather than exclusively depending on `current_app.secret_key`. This derives the identical key those processes would get if a Flask app *were* pushed -- systemd's `EnvironmentFile=/opt/eas-station/.env` already puts `SECRET_KEY` in every service's environment, poller included.
  • New regression tests in `tests/test_secret_encryption.py` reproduce the actual bug shape (a real `EncryptedString` column read via a raw `sessionmaker()` session with zero Flask app context, not a mock) -- the existing `test_airchain_fringe_cases.py` coverage for this code path used a `MagicMock()` session that never touched real column decryption, which is why it didn't catch this.
v2.221.0
2026-09-03
Changed (5)
  • **New: animated GIF share card** for weather (`category='Met'`) alerts, alongside the existing static PNG/WebP export -- `/api/alerts/<id>/export-image.gif` (`ratio` query 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 `sent` timestamp -- never earlier. A GIF can never imply a warning was active before it really was.
  • `app_utils/image_export/radar_loop.py`: added `RADAR_LOOP_LEADIN_MINUTES` (15) and a `show_polygon`/`issued` flag threaded through `build_radar_loop()` and `maps.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`: reuses `generate_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 to `tests/test_image_export_radar_loop.py` for the lead-in window.
v2.220.0
2026-09-03
Changed (6)
  • **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 an `isinstance(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`.
v2.219.1
2026-09-03
Changed (5)
  • **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 `/settings` hub 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`'s `inject_navigation()` now also exposes a flat, already permission-filtered `nav_settings_items` list; `templates/components/navbar.html` embeds it as JSON; `nav-enhance.js` merges 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.
  • `/admin` and `/search` (no query) were confirmed as correctly breadcrumb-less: both just redirect (to `/settings` and `/` respectively) rather than rendering a page of their own.
v2.219.0
2026-09-03
Fixed (11)
  • **Fixed Icecast auto-streaming silently disabled since credential encryption shipped (2.218's predecessor, #2552):** `eas_monitoring_service.py` builds its own minimal Flask app for the standalone audio/EAS process, and never set `SECRET_KEY` on it. `app_core.crypto` derives the encryption key for encrypted-at-rest settings (e.g. `IcecastSettings.source_password`) from `current_app.secret_key`, so reading Icecast settings from that process raised `SECRET_KEY is not configured` on 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) into `app_utils/secret_key.py` and had `eas_monitoring_service.py` use it too.
  • Fixed a related, pre-existing gap: `tests/test_radio_audio_monitoring.py`'s `DummyAdapter` test double didn't implement `is_quarantined()` or set `_start_time`, so the real `IngestController`'s background health-monitor thread (which the test registers the double into) threw and logged an `AttributeError` on 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 manual `sudo ufw allow 8000/tcp` nobody 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/configure` endpoint 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/0` for 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 of `webapp/admin/ntp_server.py` so both features share one implementation instead of two copies drifting apart.
  • `config/sudoers-eas-station` gained the Icecast-port `ufw allow`/`ufw delete allow` entries (tagged `eas-station-icecast`, mirroring the existing NTP entries) -- **run `update.sh` (or manually redeploy `/etc/sudoers.d/eas-station` from 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.
  • ...1 more
v2.218.1
2026-09-02
Changed (5)
  • 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."
  • `TickstemAPIError` now carries `status_code`, so `create_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_names` list, scoping the attempt to a specific subset instead of always going for every unmonitored critical service.
  • `/admin/tickstem` now 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.py` covering the `service_names` subset and the stop-on-402 behavior.
v2.218.0
2026-09-02
Changed (6)
  • New `tickstem_service_heartbeats` table and `TickstemServiceHeartbeat` model (`app_core/_models_tickstem.py`): one row per critical service from `app_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.py` gained `create_heartbeat()`, `set_heartbeat_status()`, and `delete_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 own `interval_secs`) and its matching systemd service is currently active, read from the same cached snapshot `get_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 every `get_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 new `tickstem_client` functions.
v2.217.1
2026-09-02
Fixed (3)
  • 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 the `chronyc clients` call, and updated the scoped sudoers entry in `config/sudoers-eas-station` to 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.
v2.217.0
2026-09-02
Changed (3)
  • `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.html` adds 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-responsive` and added `.text-break-anywhere` to the IP/hostname cells while touching this template, per the mobile-friendly requirement in `docs/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.conf`s 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.
v2.216.3
2026-09-02
Fixed (4)
  • 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 is `drwx------`, owned by `_chrony`. `eas-station-web.service`'s `CapabilityBoundingSet=` caps what the `sudo`-escalated root process inside its sandbox can do, and it was missing `CAP_DAC_OVERRIDE` -- so that "root" can't traverse a directory it doesn't own. chronyc silently fell back to the legacy cmdmon protocol and got `501 Not authorised`.
  • This failed on every single page load, not just before a client's first sync: `_client_summary()` in `webapp/admin/ntp_server.py` treated any `chronyc clients` failure 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_OVERRIDE` to `CapabilityBoundingSet=` in `systemd/eas-station-web.service`. Confirmed via the same sandbox-reproduction method as 2.216.2: `chronyc clients` returns `501 Not authorised` inside 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 in `journalctl -u eas-station-web` instead 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.
v2.216.2
2026-09-02
Fixed (4)
  • Corrects the diagnosis in 2.216.1: the `sudo tee` write of `/etc/chrony/conf.d/eas-station-ntp-server.conf` was never transient. `eas-station-web.service` runs with `ProtectSystem=strict`, which bind-mounts the whole filesystem read-only inside that service's own mount namespace except for the paths listed in its `ReadWritePaths=`. `/etc/chrony` was never added to that list when the LAN NTP Server feature shipped in 2.216.0, so every write from inside the running service hits `Read-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-web` showed the identical `tee: ...: Read-only file system` failure 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/chrony` to `ReadWritePaths=` in `systemd/eas-station-web.service`, alongside the existing `/etc/nginx`/`/etc/letsencrypt`/`/etc/icecast2` entries 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 10` and chrony restarted before this fix landed.
v2.216.1
2026-09-02
Changed (3)
  • Confirmed live: `webapp/admin/ntp_server.py`'s `sudo tee` write of the chrony conf.d fragment hit a transient `Read-only file system` error 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 `/configure` route as a whole succeeds when the underlying write recovers on its retry.
v2.216.0
2026-09-02
Added (5)
  • 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.conf` grants 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 something `install.sh` could 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 from `chronyc clients`). Deliberately stateless like `webapp.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 fixed `eas-station-ntp-server` UFW 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 two `ufw ... comment eas-station-ntp-server` entries. `update.sh` already 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).
v2.215.3
2026-09-02
Fixed (2)
  • **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 no `smart_status` block) 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/vda` has 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 to `overall_status: "unknown"` with a real `error` message (smartctl's own JSON `messages`, 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. Added `tests/test_smart_health.py` (8 tests: the execution-failure paths, plus regression coverage that the bits-3-7 "passed"/"failed" inference and the NVMe `critical_warning` path 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`/`-demod` during the hardware subsystem split) sitting in `systemctl --failed` as a stale `not-found`/`failed` unit, killed by a stop timeout two weeks earlier -- `update.sh` never 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_services` only ever checked a fixed allowlist (`EAS_SERVICES`/`POLLER_SERVICES` in `app_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 the `systemctl reset-failed` command to clear it). Cleared the stale record on the deployment it was found on. Added `tests/test_orphaned_services.py` (6 tests, including a `_collect_systemd_services` integration test verifying the orphan flows through to `summary`/`issues`).
v2.215.2
2026-09-02
Fixed (4)
  • Admin -> Operations' "System Upgrade" progress panel (`get_upgrade_progress` in `webapp/admin/maintenance/routes_operations.py`) read exclusively from `journalctl -u eas-station-update.service`, but `update.sh` redirects its own stdout/stderr to `/var/log/eas-update.log` right 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 (every `echo_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 for `get_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_line` logic. 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 address` noise visible in that same captured run's log: three of `scripts/lib/ui.sh`'s TTY-write helpers (`whiptail()`'s wrapper, `ui_gauge_stop()`, and the `cleanup_on_exit()` trap) touched `/dev/tty` unconditionally instead of checking the existing `_UI_HAS_CONTROLLING_TTY` flag every other TTY write in the file already checks -- irrelevant interactively, but update.sh's one-click path runs via `systemd-run` with no controlling terminal at all, where `2>/dev/null` on 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 its `EXIT` trap, 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 treating `get_systemd_logs` as authoritative, added direct tests for `_tail_update_log()` (missing file, ordering, `max_lines` truncation), and added a regression test for the stale-journal-line-vs-fresh-log-file ordering fix.
v2.215.1
2026-09-02
Fixed (2)
  • The Checkup tab added in 2.215.0 (`webapp/admin/security_checkup.py`) reported "UFW is installed but not active" on a host where `sudo ufw status verbose` run interactively worked fine and UFW genuinely was active, default-deny-incoming, with the baseline ports allowed. Root cause: `eas-station-web.service`'s `CapabilityBoundingSet` grants `sudo` enough to reach uid 0, but doesn't include `CAP_NET_ADMIN`, which `iptables-nft` needs 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 with `Could 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_ADMIN` to `systemd/eas-station-web.service`'s `CapabilityBoundingSet`. Reproduced the failure and confirmed the fix with `systemd-run` transient 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.
v2.215.0
2026-09-02
Fixed (4)
  • Found (on a real deployment, by hand) a host running with no firewall at all: `install.sh` only configures UFW automatically on a *fresh* install (v2.19.7+), and `update.sh` never 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 that `Fail2banSettings.enabled` can be `true` in the database while the `eas-station` jail 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 reuses `webapp.admin.fail2ban`'s already-accurate live jail state (it distinguishes the stored "enabled" flag from the real `actuator_jail_loaded` check) rather than duplicating that logic. A "Fix now" button reproduces `install.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.py` covers 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.
v2.214.0
2026-09-02
Changed (6)
  • 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`: an `EncryptedString` SQLAlchemy column type (Fernet, keyed via HKDF-SHA256 derived from the app's `SECRET_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; a `SECRET_KEY` rotation fails closed (empty string, logged) instead of crashing.
  • Applied it to all nine columns above. Added migration `20260902_encrypt_stored_secrets` widening them from `VARCHAR` to `TEXT`, since Fernet ciphertext runs longer than the plaintext it replaces.
  • Also found `TailscaleSettings.auth_key` had the same browser-exposure bug already fixed for the TTS key: pre-filled in plaintext in both the page template and the `/api/tailscale/settings` JSON response. Fixed the same way (masked in `to_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_password` and 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).
v2.213.4
2026-09-02
Added (1)
  • `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 pages `docs_index.html`, `docs/search.html`, and `docs/rbac_visual.html`, which all use the standard `components/page_header.html` component. Added the same header, using the page's resolved title.
v2.213.3
2026-09-02
Changed (3)
  • The Test TTS and Pronunciation Preview `<audio>` players had no `error` event 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 with `ffprobe`/`ffmpeg` (clean `pcm_s16le`, 16kHz mono, decodes with no errors).
  • Added a shared `wireAudioErrorReporting()` listener on both `ttsAudioElement` and `previewAudioElement` that reads the element's `MediaError` code 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.
v2.213.2
2026-09-01
Fixed (3)
  • 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 like `https://evil.example/weather.gov` or `https://weather.gov.evil.com`, not just the real NOAA API.
  • The same anti-pattern existed at five other call sites classifying `self.cap_endpoints` entries 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 with `urllib.parse.urlparse` and compares the actual hostname (exact match or subdomain) instead of doing a substring search, and switched all six call sites to use it.
v2.213.1
2026-09-01
Changed (5)
  • 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 — because `poll_and_process()`'s main per-alert loop had only one `try` around 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.py` with 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/except` that 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.
v2.213.0
2026-09-01
Changed (8)
  • The setup wizard at `/setup` rendered 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_SECTIONS` in `app_utils/setup_wizard.py`). A first-time user got a wall of inputs before being told what any of it meant.
  • `templates/setup_wizard.html` now 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_KEY` was blanked in the form when it already held a real, install.sh-configured value — the four `POSTGRES_*` 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.py` reads `DATABASE_URL` directly 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, but `app.py` never calls it), and `install.sh`/`.env.example` both only ever write `DATABASE_URL`. So on every current install, the four `POSTGRES_*` fields never held a value at all, and the wizard could never recognize them as configured no matter what. Replaced them with a single `DATABASE_URL` field (`_validate_database_url()`, matching the field the already-working Admin → Environment page manages), which now correctly shows "Already configured" like `SECRET_KEY` does.
  • The same stale-variable assumption was live in a second place: the Diagnostics page's `check_environment_config()` checked `POSTGRES_PASSWORD` as a "critical" variable, which was therefore *always* reported "not set" on every current install — a permanent false-positive warning. Fixed to check `DATABASE_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_KEY` and `DATABASE_URL`. Checked at a 360px mobile viewport with no new horizontal overflow introduced.
  • Documented the new section layout in `docs/guides/SETUP_INSTRUCTIONS.md`.
v2.212.1
2026-09-01
Added (5)
  • 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 into `config/nginx-eas-station.conf` via `error_page 502 503 504` pointing at an `internal`-only `location` block, so nginx serves it directly without proxying to the (unreachable) backend.
  • Added `static/errors/upload-too-large.html` for 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 413` doesn't render *either* custom page over HTTP/2 — a documented nginx limitation where the oversized-body check happens at the protocol/framing layer, bypassing `error_page`/`location` entirely, 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.py` now sets `MAX_CONTENT_LENGTH = 100 MB` and a `@app.errorhandler(413)` that renders `error.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 own `client_max_body_size` is raised to 110 MB as a hard backstop above the app's real limit, so the static `upload-too-large.html` page (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.service` and 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.
v2.212.0
2026-09-01
Changed (4)
  • 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 as `time.sleep()` calls by every caller that drives the airchain — immediately after `set_broadcast_active()` and before real playout begins (lead-in), and immediately before `clear_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_seconds` passed to `set_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 both `build_files()` and `build_manual_components()` (the default case when no post-alert chime is configured), and removed `build_manual_components()`'s now-dead `silence_before_header` parameter along with its lead-in silence branch. No GPIO-subprocess code changes were needed — `services/gpio/alert_indicators.py` already keys/releases the relay purely off the `broadcast_active` marker's edges, so the callers controlling when that marker flips is exactly the right layer for this.
v2.211.3
2026-09-01
Added (3)
  • `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 the `broadcast_active` marker, 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 unlike `build_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 so `header_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_active` marker's edges, so extending the audio symmetrically on both ends was sufficient to extend the relay hold symmetrically too.
v2.211.2
2026-09-01
Fixed (4)
  • 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,sent` triples, 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) crashed `AttributeError: '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 bare `str(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 in `tests/test_cap_references_cancellation.py` for both shapes, including the exact real-world payload that crashed production, and confirmed all existing tests (CAP-string format, Update-supersession) still pass unchanged.
v2.211.1
2026-08-31
Fixed (5)
  • Continuation of the same load-time investigation as 2.210.3/2.210.4: profiling every section of `/stats`'s data pipeline individually found `collect_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_history` rows carry a `details` JSON blob and an `error_message` Text 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 only `timestamp`/`status`/`error_message`/`execution_time_ms` are ever read. `EXPLAIN ANALYZE` showed 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`'s `collect_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 `/stats` page'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 — all `test_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.
v2.211.0
2026-08-31
Added (4)
  • Found while auditing database size during the load-time investigation (2.210.3/2.210.4): `system_log` had **no retention policy at all** — confirmed by checking both `app_core/retention.py`'s field list and `alert_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. Added `system_log_max_age_days` (default 90 days, matching the existing `audio_alert_max_age_days` precedent for operational logs) and wired `SystemLog` into `RetentionScheduler`'s sweep.
  • `audio_metrics_max_age_days` lowered from 30 to 3 (on both the model default and the already-persisted settings row, via migration). Nothing in the codebase reads raw `audio_source_metrics` samples older than a short troubleshooting window — the "latest value" and recent-trend endpoints only ever need current data, and `app_core/analytics/aggregator.py` already rolls raw samples into the separate, much smaller, permanent `MetricSnapshot` table 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.
v2.210.4
2026-08-31
Fixed (5)
  • Same load-time investigation as 2.210.3: `/api/audio/sources` had a 64-second worst case in the site's own request timing data, and the server log showed real `psycopg2.errors.QueryCanceled: canceling statement due to statement timeout` failures. Root cause: `audio_source_metrics` is 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 via `EXPLAIN ANALYZE` at 21+ seconds with a 400+MB disk-spilled sort.
  • Tried Postgres's native `DISTINCT ON` first (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 ON` with an `IN` list still has to walk every matching row before deduplicating. Replaced it with N separate `ORDER BY timestamp DESC LIMIT 1` queries 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 via `20260831_audio_metrics_latest_index`, built `CONCURRENTLY` so 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 in `tests/test_audio_source_listing.py` pass 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.
v2.210.3
2026-08-31
Fixed (5)
  • 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_alerts` has under 1,000 rows; `EXPLAIN ANALYZE` showed 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` → `4` in `systemd/eas-station-web.service` (idle CPU headroom confirmed: 4 cores, only 2 in use). `MemoryMax` raised `1500M` → `4000M` to 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 that `MemoryMax` was 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 explicit `db_session` (used by the CAP poller's notification-email images) — reused that instead of inventing a new pattern, with a dedicated `sessionmaker`-backed session per render (the request's own `db.session` isn'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/state` polls 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 up `db.engine` (which needs `current_app`) *inside* the threaded closure instead of on the calling greenlet, so it failed every real request with `RuntimeError: Working outside of application context`.
v2.210.2
2026-08-31
v2.210.1
2026-08-31
Fixed (4)
  • `static/js/accessibility-utils.js`'s `setupHeadingHierarchy()` 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 bare `h5`/`h6` regardless 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 `.hN` class (`h1, .h1`/`h2, .h2`/etc. are already paired in `static/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`, previously `h6`) 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 — zero `Heading hierarchy jump` warnings remain anywhere, and no new console errors were introduced.
v2.210.0
2026-08-31
Added (3)
  • 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.
v2.209.0
2026-08-31
Changed (5)
  • New `scripts/load_municipality_boundaries.py` loads US Census incorporated-place (city/village) boundaries from the TIGER/Line cartographic "Places" file into the existing generic `boundaries` table (type `villages`, already a recognized, colored, grouped type in `app_core/boundaries.py`'s `BOUNDARY_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` (not `AlertFilterSettings.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_Intersects` test against the already-loaded `us_county_boundaries` geometry 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.py` as a regression test for the normalization.
v2.208.0
2026-08-31
Added (7)
  • 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-in `radar_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`'s `render_frame()`/`_plot_ppi()` now take a `field` parameter; velocity uses cmweather's `NWSVel` colormap over a ±32 m/s range (the practical base-velocity Nyquist limit) with its own `VELOCITY_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.py` checks 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.
v2.207.4
2026-08-31
Changed (5)
  • `zigpy` now 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.0` API 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`'s `permit_join()`/`close_join()` (the pairing-mode open/close methods) called the old name; `close_join()`'s call is wrapped in a broad `except 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`, the `device_joined`/`device_initialized` listener callbacks, and the `Device.ieee`/`.nwk`/`.model`/`.manufacturer` attributes 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 asserts `permit_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.
v2.207.3
2026-08-31
Fixed (4)
  • **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 restructured `hlapi` into arch-specific, asyncio-native submodules and dropped the old flat `pysnmp.hlapi` module (`CommunityData`, `SnmpEngine`, `sendNotification` as a sync-flavored generator) this code imported from. Both `HealthAlertWorker._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-async `send_notification()` via `asyncio.run()`, use `add_varbinds` (the renamed, non-deprecated method), and wrap the trap payload in an explicit `OctetString` (pysnmp 7 no longer auto-coerces a raw Python `str` varbind value). Each call's `SnmpEngine` is now explicitly closed via `close_dispatcher()` in a `finally` block — 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.29` actually installed.
  • Added `tests/test_snmp_trap_pysnmp7.py` — the first test coverage this code path has ever had.
v2.207.2
2026-08-31
Changed (3)
  • `numba` now 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), confirmed `app_core/radio/demod/kernels.py` JIT-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.
v2.207.1
2026-08-31
Fixed (3)
  • Bumped `Flask-Caching` to 2.5.0 (a Dependabot PR for this had failed CI: `flask_caching.backends.redis` no longer exists in that release). `Cache._set_cache()` builds an import path from `CACHE_TYPE` and imports it; 2.5.0 dropped the lowercase short aliases (`redis`, `simple`, `filesystem`, `null`) that `flask_caching.backends` used to expose, keeping only the actual class names (`RedisCache`, `SimpleCache`, `FileSystemCache`, `NullCache`). Passing the old alias straight through raised an `ImportError` from `init_cache()`, which runs unconditionally during app creation — this would have crashed the whole web service on boot, not just broken caching.
  • `app_core/cache.py` now translates the lowercase alias to the class name right at the Flask-Caching boundary. Everything else — the `CACHE_TYPE` env var, the Settings → Environment dropdown, already-deployed `.env` files — keeps using the lowercase form; only the value actually handed to Flask-Caching changed.
  • Added `tests/test_app_cache_type_resolution.py` covering all four aliases plus the "already a resolvable class name" passthrough case.
v2.207.0
2026-08-31
Changed (7)
  • **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-lts` marker 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 (now `1.18.1`) and removed `audioop-lts`'s `python_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']`); the `lint` job 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`: ruff `target-version` is now `py313`; removed the now-dead `audioop` deprecation-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`, and `tests/README.md` to state the new floor.
v2.206.0
2026-08-31
Fixed (2)
  • 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/tags` endpoint), 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 via `install.sh`/`update.sh`. It now accurately documents the real `update.sh`-via-systemd-unit mechanism.
v2.205.0
2026-08-31
Changed (3)
  • 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()` gained `silence_before_header`, `lead_announcement_samples`, and `trail_announcement_samples` parameters; `RWTScheduleConfig` gained `pre_announcement_enabled`/`pre_announcement_text`/`post_announcement_enabled`/`post_announcement_text` (migration `20260828_rwt_test_announcements`).
v2.204.0
2026-08-31
Added (2)
  • New `GET /admin/operations/upgrade/check` route: `git fetch`s the target branch (defaulting to whatever branch is currently checked out, usually `main`) and compares local `HEAD` against `origin/<branch>`, reporting the current and remote `VERSION` file contents and how many commits behind. `git fetch` only updates this checkout's own remote-tracking refs -- the same thing `git status` implicitly 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.
v2.203.7
2026-08-31
Fixed (1)
  • `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`) then `eas.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 behind `current_user.is_authenticated` (already available in the navbar's Jinja context). Anonymous visitors now skip the call entirely.
v2.203.6
2026-08-30
Fixed (1)
  • The app-wide `.form-switch` fix (2.203.5) didn't actually take effect on the dashboard: live-checked via the browser's own `document.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 in `templates/index.html` with higher specificity (four classes) than the app-wide fix (two classes), still setting `background-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 `#495057` used everywhere else tonight. Grepped every other template for a similarly-scoped override and found none.
v2.203.5
2026-08-30
Changed (5)
  • `security_settings.html`'s `.role-badge.viewer` (bg `--text-muted`,
  • `alert_detail.html`'s `.coverage-badge.bg-secondary` (text
  • `displays_preview.html`'s `.display-status.disabled` was marginal
  • `static/css/styles.css`'s `.form-switch .form-check-input` unchecked
  • `alert_detail.html` also carried its own copy of the radar-legend chips
v2.203.4
2026-08-30
Fixed (1)
  • `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).
v2.203.3
2026-08-30
Changed (1)
  • `displayHistoricalAlerts()` now resolves each alert's own colour via `EASMap.severityColor()` -- the same resolver `hazardLayer()` uses for active alerts -- instead of one fixed `--map-reference-line` value. 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.
v2.203.2
2026-08-30
Fixed (2)
  • `#date-filters` paired `background: var(--light-color)` with the
  • `displayHistoricalAlerts()` drew every historical alert polygon in
v2.203.1
2026-08-30
Fixed (1)
  • No single ink works for all six -- the range runs from light green through saturated red and magenta. Set each chip's `color` individually to whichever of black/white actually clears 4.5:1 against its own background (computed, not eyeballed): `#123` for 5/20/30/40, white for the red "50" chip, black for the magenta "60+" chip.
v2.203.0
2026-08-30
Added (1)
  • 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 same `excludedSeverities`/`excludedEventTypes` state and redraw from the already-cached data rather than re-fetching.
v2.202.2
2026-08-30
Fixed (1)
  • 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 on `var(--text-muted)`, 3.78:1 in eight themes against the 4.5:1 target): `.severity-unknown` and the "EXPIRED" alert-popup badge, both now `#adb5bd` background (8.4:1 with `#1a1a1a` text) instead of the theme variable.
v2.202.1
2026-08-30
Fixed (2)
  • 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 calls `loadHistoricalAlerts()` 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 of `alertLayer.clearLayers()`, since both lived in the same group. Historical alerts now get their own `historicalLayer`, cleared at the top of every `displayHistoricalAlerts()` call.
v2.202.0
2026-08-30
Changed (4)
  • **Radar "as of" timestamp** (`maps.py`) — `_fetch_radar_overlay()` now
  • **Card lift** (`drawing.py`'s new `_apply_card_lift()`) — a thin, low-alpha
  • **Bolded hazard numbers** (`panels_text.py`'s new `_draw_emphasized_line()`)
  • **Pill glow** (`drawing.py`'s new `_draw_pill_glow()`) — the tier/severity
v2.201.0
2026-08-30
Changed (4)
  • **Section-header icons** (`icons.py`'s new `_SECTION_ICON_FN`, wired into
  • **Tornado detection as a stepped gauge** (`panels.py`) — tornado detection
  • **Storm-position ping** (`storm_overlay.py`) — two fading outward rings
  • **North arrow** (`maps.py`'s new `_draw_north_arrow()`) — small two-tone
v2.200.1
2026-08-30
Fixed (1)
  • 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_h` bumped 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).
v2.200.0
2026-08-30
Added (3)
  • **Header film grain** (`weather_fx.py`) — a subtle monochrome noise layer
  • **Expiration countdown badge** (`text.py`'s new `_format_countdown()`,
  • **Storm-threat gauge meters** (`panels.py`) — the Wind/Hail cards now plot
v2.199.2
2026-08-30
Fixed (1)
  • 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. Added `joint='curve'` to the county-outline, glow, and casing/core `line()` calls in `app_utils/image_export/maps.py` so every vertex rounds smoothly instead.
v2.199.1
2026-08-30
Changed (1)
  • `_RADAR_OPACITY` (`app_utils/image_export/maps.py`) and the matching `radarLayer()` 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.
v2.199.0
2026-08-30
Added (2)
  • Each of the six `services/*/__main__.py` split-hardware entry points plus `sdr_hardware_service.py` now call `sd_notify("READY=1")` once startup finishes and kick a `Watchdog()` (`app_utils/system/sd_notify.py`, the same helper `eas_monitoring_service.py`/`cap_poller.py` already used) from their existing ~1 Hz main loop; the matching unit files gained `Type=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 `--config` in `eas-station-web.service`) starts a background thread in the arbiter's `when_ready` hook 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).
v2.198.0
2026-08-28
Changed (2)
  • `[ -w /dev/tty ]`, used throughout as the "is a real terminal available"
  • `_ui_ensure_gauge` returns 1 (an expected, ordinary result meaning "no
v2.197.0
2026-08-28
Added (1)
  • `app.py` now runs `_auto_configure_usb_audio_device()` once per process start: when exactly one non-onboard ALSA card is present, it creates an enabled `alsa`-type `AudioSourceConfigDB` row (unless one already exists) and points `EASSettings.audio_player` at the same device (unless it has already been customized away from its `aplay` default). Zero or more than one external card is left alone as ambiguous. Documented in help.html.
v2.196.3
2026-08-28
Fixed (3)
  • 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 no `docker-compose.yml` exists 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 --upgrade` against this venv's `requirements.txt`, `alembic upgrade head`, then `sudo 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.py` had a live bug: any bare-metal deployment pointing at a non-`localhost` PostgreSQL host (a perfectly normal remote-database setup) was misrouted into running `docker 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 the `system.configure` permission 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.
v2.196.2
2026-08-28
Fixed (1)
  • `_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 (its `REFLECTIVITY_LEGEND` still 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 in `radar_loop.py` that 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 (1)
  • Widened the Attribution page's Py-ART/boto3/Cartopy/cmweather/Matplotlib card (`.stack-item-wide` in `static/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.
v2.196.1
2026-08-27
Fixed (2)
  • **Projection mismatch**: `plot_ppi_map` was rendering in plain
  • **Coarse color banding**: the hand-rolled 6-color ramp was flattening
v2.196.0
2026-08-27
Changed (4)
  • **Site selection**: nearest WSR-88D site by haversine distance, from the
  • **Data source**: NOAA's public Level II archive on AWS Open Data
  • **Cost, accepted deliberately**: `arm_pyart` pulls in a heavy transitive
  • Attribution added throughout (`about.html`, `attribution.html`,
v2.195.2
2026-08-27
Added (1)
  • 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`.
v2.195.1
2026-08-27
Added (1)
  • 2.195.0 shipped the radar reflectivity toggle and Radar Loop card without touching `templates/help.html` or `templates/about.html`, missing the "Documentation Updates Required" step in `docs/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) in `about.html`'s acknowledgments, `attribution.html`'s Data Sources table, and `docs/reference/dependency_attribution.md`.
v2.195.0
2026-08-27
Changed (4)
  • `app_utils/image_export/radar_loop.py` (new) — lazy, disk-cached frame
  • `static/js/core/map_theme.js` — new `easRadar` pane and `EASMap.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.
v2.194.1
2026-08-27
Changed (5)
  • `services/gpio/__main__.py::_make_active_alert_counter` — drives the
  • `webapp/routes_monitoring.py::api_broadcast_state` (`/api/broadcast/state`)
  • `app_core/websocket_push.py::_emit_broadcast_state_update` — the
  • `app_core/websocket_push.py::_emit_alerts_update` — feeds the
  • `scripts/screen_manager.py::_has_active_alerts` — the OLED/LED display's
v2.194.0
2026-08-27
Fixed (3)
  • 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 marked `superseded_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 CAP `msgType` enum for other exposure: `Ack`/`Error` are network-handshake types public feeds don't emit in practice, so they weren't specifically handled. Added `tests/test_cap_references_cancellation.py` and `tests/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. Added `tests/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.py` into `app_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.py` now just re-exports `resolve_stream_url` for the existing manual "resolve" API route. Added regression cases to `tests/test_stream_metadata_parsing.py`.
v2.193.11
2026-08-27
Fixed (1)
  • 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-web` access 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.
v2.193.10
2026-08-27
Fixed (1)
  • 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/mpeg` `MediaFile`. Root cause: `resolve_stream_url()` (`webapp/audio_archive/metadata.py`) searched for `root.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 extracts `AdTitle`, `AdSystem`, and `Duration` from the VAST payload when present, and the player bar shows them instead of the generic "Ad URL" placeholder when available. Added `tests/test_audio_archive_vast_resolve.py`.
v2.193.9
2026-08-27
Fixed (2)
  • Root cause: `ArgonOLEDController.__init__` (`app_core/oled.py`) opens the I2C bus via `smbus2.SMBus` (a raw file descriptor with no `__del__`) *before* the `ssd1306` handshake; 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-1` file 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. Added `tests/test_oled_init_fd_leak.py`.
  • iHeartRadio ad breaks send `StreamTitle=adContext="<base64 VAST url>"`, which didn't match any of the known `text=`/`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 existing `stream_url` field, which the Song History UI already renders as a clickable "Ad URL" badge. Added a regression test to `tests/test_stream_metadata_parsing.py`.
v2.193.8
2026-08-26
Added (4)
  • **`tests/test_docs_link_integrity.py`**: 4 tests enforcing no broken
  • `.github/workflows/docs-pages.yml` now runs `mkdocs build --strict`
  • Live in Chromium: light and dark theme contrast/readability across
  • `mkdocs build --strict` exits 0 (was 61 warnings + 13 info-level anchor
v2.193.7
2026-08-26
Added (1)
  • **`docs/hardware/CAPACITY_AND_SIZING.md`**: reference-deployment
v2.193.5
2026-08-26
Added (2)
  • **`AudioCommandSubscriber.reconcile_orphaned_radio_sources()`**
  • The WNCI/ERN-LUC "Underrun" warnings themselves are confirmed benign: the
v2.193.4
2026-08-26
Added (1)
  • **`tests/test_broadcast_reaches_icecast_audit.py`**: an AST-based
v2.193.3
2026-08-26
Added (1)
  • **`scripts/resend_eas_broadcast.py`**: each resend now clones the source
v2.193.1
2026-08-26
Fixed (4)
  • **`app_core/audio/redis_commands.py`**: added `inject_raw_eas_audio`, a
  • **`webapp/eas/workflow.py`** (Manual Send) and **`app_core/rwt_scheduler.py`**
  • **`eas_monitoring_service.py`**: added `_reconcile_broadcast_metadata()`,
  • **`app_utils/eas.py`** (`EASBroadcaster.handle_alert`) and
v2.193.0
2026-08-26
Added (1)
  • **`services/demod/`**: a new `eas-station-demod.service` subprocess that
Fixed (2)
  • **`app_core/audio/redis_sdr_adapter.py`**: `RedisSDRSourceAdapter` no
  • **`eas_monitoring_service.py`**: a `numpy.float32` -> psycopg2
v2.192.1
2026-08-24
Fixed (4)
  • **Redis**: badge/README claimed 7.1; the running server is 8.0.2
  • **PostGIS**: badge/README/Mermaid diagrams/architecture docs claimed 3.4;
  • **Nginx**: badge said "Alpine", implying a Docker `nginx:alpine` image.
  • **Chart.js**: badge/README claimed a single version, 3.9.1. Two vendored
v2.192.0
2026-08-24
Added (1)
  • **Structured `Body:`/`Query:`/`Path:`/`Returns:` docstring sections**,
Fixed (4)
  • **The 28 routes with no docstring at all now have one** -- audio
  • New tests for `_parse_docstring_sections()` (narrative/section
  • Full local test suite: 2655 passed, 0 failed.
  • Re-verified against the live app's real `app.url_map` after fixing the
v2.191.0
2026-08-24
Added (3)
  • **`/api-reference`** (and its JSON sibling `/api/api-reference`) -- a live
  • **`eas_auth_requirement` introspection marker** on every permission
  • New `webapp/navigation/registry.py` entry (Analytics section) and
Fixed (2)
  • `docs/README.md`'s developer table no longer labels the JavaScript-globals
  • Corrected a changelog authoring mistake from the 2.189.x/2.190.x entries
v2.190.1
2026-08-24
Changed (6)
  • **`redis` 7.1.0 → 8.1.0** and **`gunicorn` 23.0.0 → 26.1.0** (both major
  • **`numpy` stays at 2.3.5**, not bumped to the latest 2.5.2: `numba`
  • Synced every version number this changed in `README.md`'s tech-stack
  • Full local test suite against a venv built from the fully upgraded
  • `redis` upgrade verified against a real, running local Redis server, not
  • `gunicorn`'s `gevent` worker class confirmed importable under 26.1.0.
v2.190.0
2026-08-24
Added (2)
  • **`abort_injected_audio()`** (`app_core/audio/eas_stream_injector.py`) --
  • **New `abort_injected_audio` Redis command** (`app_core/audio/redis_commands.py`):
Fixed (3)
  • **`abort_current_broadcast()` no longer treats "no local PID" as "nothing
  • **`POST /api/broadcast/abort` no longer 409s when there's no trackable
  • Extended `tests/test_gpio_dump_broadcast.py` and
v2.189.1
2026-08-24
Fixed (3)
  • **A resend ("Resend on Air") was never actually reaching the tamper-evident
  • **A resend's local audio playback — and therefore its PID, which is what
  • New `tests/test_eas_resend_logging.py` pins both fixes: `audio_player_cmd`
v2.189.0
2026-08-24
Added (1)
  • **Git history tile on `/repo-stats`** -- commit count, distinct
Fixed (2)
  • **Blueprints undercounted on `/repo-stats`.** `count_components()` only
  • **Scripts undercounted on `/repo-stats`.** The Scripts tile only counted
v2.188.1
2026-08-23
Changed (2)
  • **`/admin` now redirects to `/settings` for authenticated visitors**
  • Removed the "Admin Dashboard" entry from Settings -> Configuration
v2.188.0
2026-08-23
Added (3)
  • **New `/admin/location-settings` page** -- station jurisdiction, timezone,
  • **New `/admin/eas-encoder-settings` page** -- SAME/EAS encoder
  • Both new pages share `static/js/admin/location-settings.js`
v2.187.4
2026-08-23
Fixed (1)
  • **GeoJSON/Shapefile upload forms on `/admin/data-management` did a plain
v2.187.3
2026-08-23
Added (2)
  • **New `/admin/data-management` page** (`templates/admin/data_management.html`,
  • Extracted **structurally verbatim** rather than redesigned: same
v2.187.2
2026-08-23
Added (1)
  • **New `/admin/alert-management` page** (`templates/admin/alert_management.html`,
Fixed (1)
  • **`alert-management.js` would have crashed on any page without the Data
v2.187.1
2026-08-23
v2.187.0
2026-08-23
Added (2)
  • **"Trigger Poll Now" and "Manual Alert Import"** on Admin -> Alert Poller
  • **New `/admin/user-accounts` page** (`templates/admin/user_accounts.html`,
v2.186.2
2026-08-23
Fixed (1)
  • **Admin Dashboard's stat cards didn't match the rest of the app.**
v2.186.1
2026-08-23
Fixed (1)
  • **Tickstem Uptime Monitor didn't appear on the Settings hub.** #2453 added
v2.186.0
2026-08-22
Added (1)
  • **New Admin -> Tickstem Uptime Monitor page.** Manages an inbound Tickstem
v2.185.8
2026-08-22
Fixed (2)
  • **Album art blocked by CSP on `/audio-monitor`** -- station metadata (ICY
  • **Uptime heartbeat pings 401'd against some healthchecks.io-alternative
v2.185.7
2026-08-22
Fixed (11)
  • **Icecast audio playback was silently blocked everywhere** -- the CSP had
  • **The `/audio-monitor` live players never rendered** -- `getIcecastStreamUrl()`
  • **`/admin/audio-sources` cards rendered correctly for ~30s, then flipped
  • - three compounding bugs: (1) the periodic WebSocket push
  • **Peak/RMS level labels were permanently stuck on their "-- dBFS"
  • **`bootstrap is not defined` crashed `/settings/stream-profiles`** on
  • **`/audio/health/dashboard` threw "Canvas is already in use"** on a
  • - a race between `theme.js`'s own `DOMContentLoaded` listener
  • Removed two dead `<script src="https://cdn.socket.io/...">` tags on
  • Removed a premature `typeof showLoading !== 'function'` check on
  • ...1 more
v2.185.6
2026-08-22
Fixed (2)
  • **Four modals rendered completely unusable** -- visible, even appearing
  • Added `tests/test_modal_stacking.py`: a static, `{% include %}`-resolving
v2.185.5
2026-08-22
Fixed (5)
  • **`.status-badge.success/danger/warning/info` rendered under WCAG AA (as low as
  • **SAME/FIPS code chips on the EAS broadcast workflow page
  • **Inactive tabs in the admin panel's tab bar (`.nav-tabs .nav-link`) read
  • **The navbar's Help/Reports/Settings dropdown menus opened off the right
  • **~150 icons across 6 pages (Security Center, Certbot/SSL admin, TTS
v2.185.4
2026-08-22
Fixed (1)
  • **`eas-station-displays.service` leaked memory at ~17 MB/s whenever the
v2.185.3
2026-08-22
Fixed (2)
  • **The Live Waterfall / Spectrum Scope toggle buttons on Radio Diagnostics
  • **Modals with content taller than the viewport had no internal scrollbar**
v2.185.2
2026-08-21
Added (1)
  • New `tests/test_cap_poller_skip_db_init.py` -- a structural regression
Fixed (2)
  • **`eas-station-poller.service` was accidentally running a second,
  • `app.py`'s `SKIP_DB_INIT` comment updated to document that it's a
v2.185.1
2026-08-21
Added (4)
  • New `app_core/http_defaults.py::get_default_user_agent()` -- single
  • New `tests/test_http_defaults.py` (6 tests): the DB/env/hardcoded
  • Verified against a real local HTTP server: called the actual
  • - rather than the bare `python-requests/2.32.5` reported before the fix.
Fixed (1)
  • **Outbound "health check" requests were going out with the bare
v2.185.0
2026-08-21
Added (4)
  • **Broadcast phase indicator on the full-screen countdown overlay.** The
  • **"Hold to Abort Broadcast" button on the countdown overlay.** The
  • New `tests/test_broadcast_phase_and_web_abort.py` (9 tests): phase
  • Verified against real hardware in the lab: triggered a genuine RWT,
v2.184.1
2026-08-21
Fixed (6)
  • **`_run_command()`'s PID tracking only ever covered the live/auto-forwarded
  • **A GPIO-forced abort could end a broadcast without ever sending the
  • **The EOM audio Redis key needed base64 encoding.** `get_redis_client()`
  • Corrected an inaccurate claim in the 2.184.0 entry below: `_run_command()`
  • `tests/test_gpio_dump_broadcast.py` grew from 11 to 20 tests: EOM
  • Verified against real hardware in the lab: triggered a genuine RWT
v2.184.0
2026-08-21
Added (7)
  • **New GPIO input action: `Dump / Abort Broadcast`** — the fourth and
  • **Safety default: requires a sustained 3-second hold, not a tap**
  • `app_utils/eas.py::_run_command()` — used by the live/auto-forwarded
  • New `app_core/audio/gpio_input_actions.py::abort_current_broadcast()`
  • New Pin Map UI control: "Hold time to confirm (seconds)", shown only
  • New tests in `tests/test_gpio_dump_broadcast.py` (11 tests) — the
  • `docs/architecture/THEORY_OF_OPERATION.md`'s Broadcast Orchestration
v2.183.0
2026-08-21
Added (6)
  • **New GPIO input action: `Acknowledge Dead Air`.** A physical button
  • **Refactor: the acknowledge logic moved out of the Flask route** into a
  • - extracted so the GPIO input action and the web UI's Acknowledge
  • 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
v2.182.0
2026-08-21
Added (4)
  • **The `Forward Last Alert` GPIO input action is now implemented** (was a
  • New `app_core/audio/gpio_input_actions.py` (`forward_most_recent_alert()`)
  • - finds the most recent `EASMessage` with stored audio via the
  • New tests in `tests/test_gpio_forward_last_alert.py` (4 tests): correct
v2.181.0
2026-08-21
Added (6)
  • **GPIO pins can now be configured as INPUTs**, not just relay outputs
  • **First implemented action: Run RWT Now** — a physical button wired to
  • The action enum ships complete (`Forward Last Alert`, `Dump / Abort
  • Validation at save time: an input pin cannot also carry an output
  • No migration required — `direction`/`input_action`/`input_bounce_ms`
  • New tests in `tests/test_gpio_input_watcher.py` (14 tests): button/queue
v2.180.0
2026-08-21
Added (4)
  • **Relay Interlock Groups** (Admin → GPIO → Interlocks, `/admin/gpio/interlocks`):
  • A save-time warning (surfaced on both the Interlocks page and the GPIO
  • New tables `relay_interlock_groups` / `relay_interlock_members`
  • New tests in `tests/test_gpio_relay_interlock.py`, including a regression
v2.179.1
2026-08-21
Fixed (2)
  • **The GPIO Pin Map showed a conflict on BCM 14 whenever GPS or Zigbee was
  • **The conflict banner also under-explained itself**: it enumerated the
v2.179.0
2026-08-20
Added (5)
  • **systemd hang watchdog** for `eas-station-audio` and `eas-station-poller` (`Type=notify` +
  • **Outbound dead-man's-switch heartbeat**: every existing health check was inward-facing (a page
  • **Scheduled backup-restore verification**: Admin → Backups now has a "Backup Restore
  • **Clock/NTP drift monitoring**: the system health snapshot (`/system_health`) now has a Clock
  • **Combined NOAA+IPAWS feed-loss alarm**: the existing poller-liveness check looked at the single
v2.178.0
2026-08-20
Added (1)
  • **The main dashboard (`/`) now shows a live System Status strip** above the
v2.177.2
2026-08-20
Fixed (2)
  • **The WBKS SDR receiver was logging ~1 `SOAPY_SDR_OVERFLOW` (buffer
  • Added `Nice=-5` to `systemd/eas-station-sdr.service` so the CFS scheduler
v2.177.1
2026-08-20
Fixed (2)
  • **The GPIO Pin Map's conflict detector (added in 2.177.0) only checked the
  • `_dynamic_hardware_reservations()` now recognizes `/dev/serial0`,
v2.177.0
2026-08-20
Added (3)
  • **The GPIO Pin Map only knew about relay behaviors and the fixed Argon OLED
  • `/admin/gpio/pin-map` now computes these reservations live from
  • This entry was dropped from the changelog when PR #2429 was merged shortly
v2.176.2
2026-08-20
Fixed (1)
  • **Running `pytest` on a box that also hosts the live `eas-station-gpio`
v2.176.1
2026-08-20
Fixed (3)
  • **Automatic certificate renewal was silently doing nothing.** The deployed
  • **Renewal also failed whenever it did target the right certificate**,
  • **A stale `.certbot.lock` left by a crashed run blocked every later
v2.176.0
2026-08-20
Added (2)
  • **The received-alert detail page now shows which ENDEC model produced
  • Documented in `templates/help.html`'s Received Alerts section.
v2.175.4
2026-08-20
Fixed (4)
  • **`scripts/database/recover_split_location_settings.py` was rewinding
  • The actual schema was never affected -- confirmed directly against
  • Fixed by walking the actual Alembic migration graph
  • Live database restamped to the correct head (`20260818_dead_air_per_source`).
v2.175.3
2026-08-19
Fixed (2)
  • **RBDS History always showed "No stored RBDS snapshots in this window
  • **The RBDS History chart's time-axis labels rendered at a steep diagonal
v2.175.2
2026-08-19
Fixed (1)
  • **The Edit Audio Source modal (and every other scrollable modal that wraps
v2.175.1
2026-08-18
Fixed (2)
  • **The Live Waterfall's and Spectrum Scope's zoom controls looked normal
  • - `.diagnostic-container` (plain text/tables, no canvas, no listeners)
v2.175.0
2026-08-18
Changed (6)
  • **Dead-air (silence) alarming moved from one station-wide policy to a
  • Detection policy (enabled, hold-off, silence level, open-carrier
  • The physical output side (rack buzzer GPIO pin, tower-light colour) is
  • `/api/audio/dead-air/settings` (GET/POST) is removed; `/api/audio/dead-air/status`
  • Database: the five station-wide detection columns
  • Fixed 7 failing tests in `tests/test_gpio_alert_indicators.py` that read
v2.174.0
2026-08-18
Changed (2)
  • The legacy instantaneous `silence_detected` metric took its thresholds
  • Renumbered the database-initialisation log steps in `app.py`, which were
v2.173.1
2026-08-18
Fixed (5)
  • **Operator-supplied source names were interpolated into `innerHTML`.**
  • **An active dead-air alarm was invisible to the people meant to watch for
  • **An acknowledgement could mute a later, unrelated outage.** The ack was
  • **A failed settings load could silently rewrite stored thresholds.** The
  • Completed the page-exclusivity tests, which checked fewer fields than
v2.173.0
2026-08-18
Added (3)
  • `webapp/admin/audio_ingest/routes_dead_air.py` -- `GET`/`POST
  • **Navigation entries for two pages that never had them.** Neither
  • Regression tests pinning the split: detection fields on the audio page
Changed (3)
  • **Dead-air monitoring is no longer configured entirely on the Hardware
  • **Acknowledging moved off the settings form entirely.** It is an
  • No migration: the settings columns are unchanged, only which page edits
v2.172.1
2026-08-18
Fixed (1)
  • `_dead_air_buzzer_pin()` in `services/gpio/__main__.py` referenced a
v2.172.0
2026-08-18
Added (10)
  • **Dead-air (silence) monitoring for monitored audio sources**, wired to
  • **`app_core/audio/silence.py`** -- the detector. Classifies on two
  • *level*: RMS below a floor (default -65 dBFS) catches true digital
  • *flatness*: spectral flatness (Wiener entropy) catches an unmodulated
  • Timing reuses `SilenceDetector` in `app_core/audio/metering.py` rather
  • **Tower light**: a new `silence` state in `resolve_tower_state()`,
  • **Rack alarm buzzer** on a configurable GPIO pin, with an operator
  • **Admin UI** at Admin -> Hardware (existing "Station Hardware" page):
  • New endpoints `GET /admin/hardware/dead-air/status` and
  • Redis key `eas:dead_air` (30 s TTL) carries the aggregate state from the
Fixed (3)
  • `SilenceDetector` reported silence *immediately* when its first observed
  • Dead-air thresholds are a station-wide policy, installed once via
  • The pre-existing `AudioMetrics.silence_detected` flag is unchanged. It
v2.171.1
2026-08-18
Fixed (3)
  • **A null frequency axis rendered as `0.00000 MHz` instead of blank.**
  • **The mouse wheel swallowed the page scroll at both zoom limits.**
  • **The Spectrum Scope's peak-hold trace clipped against the top edge.**
Changed (5)
  • Corrected an overstated claim in the 2.171.0 notes and the template's
  • Pan repaints are coalesced to one per animation frame. Pointer events can
  • The two status lines are built by shared `waterfallStatusText()` /
  • Rebuilt zoom controls seed their readout and reset-button state from live
  • `tests/test_spectrum_frequency_axis.py` reads the template with an
v2.171.0
2026-08-18
Added (6)
  • **Zoom and pan on the Live Waterfall and Spectrum Scope**
  • Zoom in / out / reset buttons in each panel header, with a live
  • Mouse wheel to zoom about the cursor, holding whatever is under the
  • 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
  • The frequency axis and status line now describe the **visible** window
Changed (2)
  • **The live waterfall's history buffer now stores raw per-bin power
  • The Spectrum Scope's y-axis auto-scale now follows the visible crop
v2.170.5
2026-08-18
Added (3)
  • `app_core/radio/decimation.py` -- the early-decimation threshold, factor
  • The Live Waterfall and Spectrum Scope status lines now show the RF span
  • `tests/test_spectrum_frequency_axis.py` -- regression coverage pinning
Fixed (2)
  • **The Live Waterfall and Spectrum Scope on `/admin/radio_diagnostics`
  • **Capture requests asked the SDR service for decimation-factor times more
v2.170.4
2026-08-18
Fixed (2)
  • **The per-receiver "Historical Trends" charts on `/admin/radio_diagnostics`
  • **The whole SDR Diagnostics page -- including the Live Waterfall and
v2.170.3
2026-08-18
Fixed (1)
  • **The Live Waterfall and Spectrum Scope both still looked "zoomed in"
v2.170.2
2026-08-18
Fixed (2)
  • **Spectrum Scope drew directly against a fixed 0-1 range** instead of
  • **Historical Trends charts grew without bound ("kept scrolling").**
v2.170.1
2026-08-18
Fixed (6)
  • **`CAPPoller._convert_cap_alert()` never extracted `<eventCode>` from
  • A county "Natural gas leak" shelter-in-place warning (severity
  • Three days earlier, an alert whose `event` field read exactly like a
  • **`app_utils.eas._collect_event_code_candidates()` silently dropped a
  • **`app_core.audio.auto_forward._resolve_event_code()` was an
  • Added `tests/test_ipaws_event_code_extraction.py` (19 tests) covering
v2.170.0
2026-08-18
Added (3)
  • **Spectrum Scope**, a classic frequency-vs-power line trace with a
  • **Historical Trends**: a new 2-tier Redis archive (`app_core/radio/trends.py`,
  • **"Run Full Diagnostics"** button: a pass/warn/fail checklist
Fixed (1)
  • **The Live Waterfall's fast path was silently broken.** `sdr_hardware_service.py`
v2.169.2
2026-08-18
Fixed (3)
  • **`/api/eas/decoder-stream` ("Listen to EAS Decoder Feed") produced zero
  • **VU meters didn't follow the actual playing audio.** Three separate
  • Investigated general per-source live audio playback (item reported
v2.169.1
2026-08-18
Fixed (3)
  • **SDR service polled Redis 10x/second forever for a queue that's empty
  • **Audio service's metrics loop silently ran at half its documented
  • **Two admin routes recalculated intersections with a per-boundary query
v2.169.0
2026-08-17
Added (1)
  • **Event Lifecycle panel on the alert trail page**
Fixed (4)
  • **VTEC chain links silently dropped for alerts with an unparsed
  • **`vtec_year` came back NULL for any product whose VTEC segment carries
  • Added a self-healing sweep the poller runs every poll cycle —
  • Audit against the live database found these bugs left roughly a third
v2.168.3
2026-08-15
Fixed (5)
  • **Orphaned Redis publish loop.** `eas_monitoring_service.py`'s
  • **No-op command stub.** `eas_monitoring_service.py`'s `process_commands()`
  • **Wrong systemd unit name in three diagnostics scripts.**
  • **Stale references to the already-retired `eas-station-hardware.service`**
  • Added an explanatory comment to `uninstall.sh`'s legacy
v2.168.2
2026-08-15
Fixed (3)
  • **Two independent processes were decoding the same live EAS/SAME audio.**
  • Deleted `eas_service.py`, `systemd/eas-station-eas.service`, and
  • Corrected several docs that had drifted from reality around this area:
v2.168.1
2026-08-15
Fixed (1)
  • **A single DB error during broadcast forwarding could wedge the poller's
v2.168.0
2026-08-15
Added (1)
  • **Shared-secret auth on the network/zigbee/gps/displays hardware services.**
Fixed (4)
  • **OLED screen-editor preview used a hardcoded 128x64 panel size.**
  • **OLED preview rendering could race under concurrent requests.** The
  • **`/screens` list-page cards showed raw, unresolved template syntax**
  • **`/screens` OLED canvas thumbnails were hardcoded to 128x64** client-side,
v2.167.0
2026-08-14
Added (3)
  • **Configurable cross-source deduplication windows.** The two suppression
  • **Minimum confidence floor for headerless audio detections.**
  • New idempotent migration `20260814_eas_dedup_settings` adds the three
Fixed (1)
  • Reviewing a user-reported spike of "false decodes" on the Received Audio
v2.166.0
2026-08-14
Added (5)
  • **LED graphics/Dots mode in the screen editor.** A "Message Type" toggle
  • **`compass` and `segments` element types**, and VFD added to
  • **Pixel-accurate live preview.** `POST /api/screens/preview` renders a
  • Icon picker now includes `satellite`, `gps_pin`, `bolt` (added to the
  • The text element's Font Size dropdown is now display-aware: VFD and LED
Fixed (1)
  • `_render_led_elements()` (`scripts/screen_renderer.py`) silently
v2.165.0
2026-08-14
Added (4)
  • **LED graphics/Dots mode.** `render_led_elements()`
  • **3 new LED graphics screens** — `led_status_graphic` (clock, large
  • **LED sign Memory Configuration allocation.** A file's type
  • **LED elements preview** — `render_led_elements_preview()`
Fixed (2)
  • **The LED sign's default screens were never actually seeded.**
  • **LED rotation froze solid during an active alert** — the same bug
v2.164.0
2026-08-14
Added (5)
  • **`vfd_status`** — a new default VFD screen showing the time, date and
  • **`vfd_alert_status`** — a new default VFD screen showing the active
  • **`vfd_gpio_status`, `vfd_eas_decoder`, `vfd_audio_health`,
  • **Text truncation on the VFD** (`max_width`/`overflow`, "trim" or
  • **A second, larger VFD font** (`"font": "large"`, 14pt bold, vs. the
Fixed (2)
  • **VFD rotation froze solid during an active alert.** None of the VFD's
  • **`vfd_system_meters`' DSK row clipped 1px off the bottom of the
v2.163.0
2026-08-14
Added (3)
  • **The VFD display now has the same icon/gauge/compass/bar-chart engine as
  • **The 3 default VFD screens were reflowed onto the new engine**, and a
  • **README screenshot gallery**: pixel-accurate renders of all 11 OLED
Fixed (7)
  • **OLED element collisions and clipping**, found by rendering every default
  • The `compass` primitive's cardinal labels sat on top of its own tick
  • `AUDIO HEALTH` and `IPAWS POLLER`'s header banners overlapped their
  • The GPIO Status header's `"{count} active"` overlapped its title;
  • The Clock Face's IP address was squeezed into a ~60px column next to
  • The `satellite` icon (rectangles + ellipse + diagonal line) was
  • **VFD custom screens were completely non-functional.** Both render paths
v2.162.0
2026-08-14
Added (7)
  • **New OLED render primitives**: `compass` (an N/E/S/W heading dial with an
  • **Three new OLED icon glyphs**: `satellite`, `gps_pin`, and `bolt`
  • **New GPS status OLED screen** ("GPS Status") added to the default OLED
  • **Restyled the GPIO Status OLED screen** to match every other default
  • **Pending Alerts now show on the USB tower/stack light.** A non-empty
  • **Website navbar stack light mirrors the same state**, subscribing to the
  • **Pending Alerts now show on the OLED, LED sign, and VFD displays.** A new
Fixed (2)
  • A migration seeding a new screen into an existing `screen_rotations` row
  • **Audio service could silently stop reporting itself as healthy for
v2.160.0
2026-08-13
Added (2)
  • **Boundaries now auto-recalculate on upload.** Uploading a GeoJSON or
  • **Background retry sweep for intersection calculation**, mirroring the
Fixed (5)
  • **Root cause found for "boundaries aren't calculated automatically, I have to run a command":**
  • `poller/cap_poller.py::process_intersections()` — the automatic,
  • `poller/cap_poller.py::_has_geometry_changed()` — silently defeated an
  • `app_core/alerts.py::calculate_alert_intersections()` — used by the
  • Confirmed against a real PostGIS database: identical failure with the
v2.159.0
2026-08-13
Added (1)
  • New **Gated Alerts** section on `/stats`: a headline "Alerts Gated" count,
Fixed (4)
  • **"Reliability" card silently showed a fabricated 99%** when there was no
  • **"Avg Broadcast Latency" showed a literal `N/A` card** when there were no
  • **"System Performance" section header could float over an empty grid** —
  • Fixed the "EAS Received" card using an undefined `yellow` stat-card color
v2.158.1
2026-08-13
Fixed (4)
  • **CAP-sourced gated alerts (2.158.0) could be held and manually approved,
  • Added `_CapPollerGatedAlertScheduler` to `poller/cap_poller.py`,
  • Releases expired CAP-sourced holds by re-invoking
  • Added regression tests in `tests/test_alert_gating.py` covering the
v2.158.0
2026-08-13
Added (8)
  • **New optional feature: lower-priority CAP/OTA alerts can now be held for
  • **Immediate urgency or Extreme severity alerts always bypass the gate**
  • Applies to both ingest paths: the CAP poller (NOAA/IPAWS feed alerts)
  • New `gated_alerts` / `alert_gating_settings` tables and a `GatedAlert`
  • Background release schedulers run in the poller and EAS-monitor
  • New admin pages: **Settings → Alert Gating** (enable/disable, hold-off
  • New optional GPIO behavior, **Gated Alert Pending**, for a lamp or
  • See `docs/guides/GATED_ALERTS.md` for the full operator guide.
v2.157.1
2026-08-13
Fixed (6)
  • **The dashboard map drew every active alert in raw API-return order with
  • `displayAlerts()` in `templates/index.html` now sorts alerts before
  • Broad/county-wide alerts are restyled as context rather than a second
  • Added `EASMap.severityRank()` to `static/js/core/map_theme.js`,
  • Added an optional `casing` flag to `EASMap.hazardLayer()` (default
  • Verified the sort/styling logic against a realistic four-alert overlap
v2.157.0
2026-08-13
Added (4)
  • **`install.sh`/`update.sh` now drive an actual `whiptail --gauge` for the
  • The gauge widget only renders **one line** of message text (confirmed
  • Falls back automatically, in order, when whiptail can't drive the show:
  • **Two real bugs this testing caught, both the kind that only show up
v2.156.0
2026-08-13
Added (7)
  • **`install.sh`/`update.sh` now render on a genuinely static screen instead
  • Banner stays fixed at the top. Below it, three rows are established
  • `ui_stream()` (wraps `pip install`, `apt-get install`, `alembic
  • `ui_apt_install()`'s live per-package percentage now redraws the
  • Falls back to the previous scrolling append-only behavior whenever the
  • Verified with a real terminal emulator (`pyte`), not by eyeballing escape
  • Known pre-existing gap, not introduced by this change: the scrolling
v2.155.0
2026-08-13
Added (8)
  • **Restyled `install.sh`/`update.sh`'s terminal chrome to an old-school DOS-installer look** — entirely contained to `scripts/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 (`i` info, `*` ok, `!` warn, `X` error) 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:
  • Verified via a real `script`-captured pty run (not just visual inspection of the source): `bash -n` on all three files, `shellcheck` shows 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 the `NO_COLOR=1` plain-text fallback path (unchanged) still renders correctly.
v2.154.3
2026-08-13
Fixed (4)
  • **A Numba disk-cache race could make the poller silently drop every NWS
  • **Root cause of the import failure**: `_dll_drain_bits_numba()` in
  • Hardened both `CAPAlert` VTEC attribute-access call sites in
  • Added `tests/test_cap_alert_fallback_model_parity.py`: statically parses
v2.154.2
2026-08-13
Fixed (4)
  • **TTS/embedded/relay narration was mixed into broadcasts at whatever raw
  • `build_manual_components()` (the manual/uploaded-narration broadcast path)
  • **Also hardened `_normalize_audio_amplitude()` itself**: pure RMS matching
  • Added `tests/test_narration_loudness_normalization.py`: unit coverage for
v2.154.1
2026-08-13
Fixed (1)
  • **Every `url_for('static', ...)` link double-appended `?v=`.** The
v2.154.0
2026-08-13
Added (2)
  • **PWA manifest for "Add to Home Screen."** `static/manifest.json` (name,
  • Along the way, fixed `apple-touch-icon` pointing at the 2080×325 wordmark
v2.153.5
2026-08-12
Fixed (1)
  • **The Alert Verification page loaded every audio blob in the display window just to show delivery status.** `collect_alert_delivery_records()` queried whole `EASMessage` ORM rows, which drags along all six `LargeBinary` audio columns even though the page only reads `same_header`, `created_at` and `metadata_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 to `EASMessage.without_audio()`, which defers all six blob columns in SQL. Added `tests/test_alert_verification_queries.py`, which inspects the emitted SQL to pin this query shape and catch a regression back to `EASMessage.query`.
v2.153.4
2026-08-12
Fixed (1)
  • **`idle_in_transaction_session_timeout` was unlimited (0) in production.**
v2.153.3
2026-08-11
Added (1)
  • `tests/test_client_clock_rendering.py` fails the build if a template
Fixed (2)
  • **The navbar and footer clocks were hardcoded to Eastern.** Every page
  • **System Health showed the wrong "Last Updated" value and a dead footer
v2.153.2
2026-08-11
Fixed (1)
  • **Filter pull-downs were sliced off at the card's edge.** On the Received
v2.153.1
2026-08-11
Added (1)
  • `tests/test_local_time_rendering.py` — covers the DST and naive-column
Fixed (1)
  • **Alert times displayed UTC while claiming to be local.** On the Received
v2.153.0
2026-08-10
Added (1)
  • **A shared, theme-aware Leaflet skin** — `static/css/map.css` plus
Fixed (1)
  • **Vector layers in a custom Leaflet pane rendered nothing.** Leaflet ships
Changed (3)
  • **Every Leaflet map in the app now goes through the skin**: the alert
  • **`base.html` stamps `data-theme-mode` in its anti-flash script.** It was
  • **The Leaflet chrome moved out of `styles.css`** (loaded on every page)
v2.152.0
2026-08-10
Fixed (3)
  • **The storm-motion callout no longer covers the arrow it labels.** The pill
  • `maps.py` (904 lines after the changes above) was split: the storm-motion
  • Removed `_best_zoom()`, dead once the map started framing its own crop. It
Changed (4)
  • **The alert now fills the map instead of sitting in it.** The renderer
  • **The basemap is toned down so the hazard is what reads.** A raw OSM tile
  • **County names are back on the map, with collision avoidance.** Labels were
  • **Counties inside the alert are drawn brighter than the surrounding
v2.151.0
2026-08-10
Changed (6)
  • **Share-card descriptions render as the outline NWS actually wrote, not a
  • **Segments the card already shows elsewhere are dropped.** `WHERE` is the
  • **Bare URLs no longer consume a line of copy.** NWS descriptions end with
  • **`areaDesc` no longer repeats the state on every entry.** NWS sends
  • The prose section drawers (headline, description, action) moved from
  • Added `_lighten()` to the image-export palette so accent-coloured text stays
v2.150.2
2026-08-10
Added (2)
  • `tests/test_audio_command_delivery.py` — the receiver-count contract, both
  • Two cases in `tests/test_audio_source_listing.py` covering the production
Fixed (4)
  • **A dead audio service rendered as three deliberately-stopped sources.**
  • **Audio commands reported success when nothing received them.** Redis
  • **`POST /api/eas-monitor/control` returned 500 for a down dependency.** An
  • **The Start/Stop Monitor buttons gave no feedback on success.**
v2.150.1
2026-08-10
Added (1)
  • `tests/test_maintenance_package.py` gains two guards. One runs a real
Fixed (1)
  • **One-click backup and one-click upgrade never ran.** The worker that
v2.150.0
2026-08-08
Added (6)
  • **`tests/test_alert_verification_package.py`** — 14 tests. The
  • **A flaky test that made every "suite green" claim unreliable.**
  • **An import cycle was created and caught during the split.** The call chain
  • **No `__file__` hazard here**, unlike 3e and 3f: `repo_root` is derived from
  • **`eas_detection.py` (316) holds one 276-line function.** Within the
  • **Verification.** 27 of 28 definitions (top-level *and* nested) are
Fixed (2)
  • **Four mutable globals are deliberately *not* re-exported from the package.**
  • **One dead import dropped out**: `struct` was imported and never used.
Changed (2)
  • **`webapp/routes/alert_verification.py` (1,668 lines) is now the
  • **The closure was reproduced from `symtable`, not guessed.** Four helpers
v2.149.0
2026-08-08
Added (4)
  • **`tests/test_maintenance_package.py`** — 20 tests, the first this module
  • **The blueprint takes no `url_prefix`** — `/admin` is written into each
  • **`_OPERATION_STATE` is mutated in place, never rebound.** That is what
  • **`limit` is allow-listed but never sent upstream.** `build_noaa_alert_request`
Fixed (2)
  • **`repo_root` would have silently moved, taking backup, upgrade and the
  • **`get_operation_status` is re-exported even though `__all__` does not list
Changed (1)
  • **`webapp/admin/maintenance.py` (1,802 lines) is now the
v2.148.0
2026-08-08
Added (4)
  • **`tests/test_certbot_package.py` — the first tests this module has ever
  • **`routes_obtain_execute.py` is 449 lines and knowingly over the guidance.**
  • **Verification.** 22 of 23 top-level definitions are `ast.dump()`-identical.
  • **Four pre-existing F541 warnings** (f-strings with no placeholders) moved
Fixed (3)
  • **The `certbot_data` directory would have silently moved.**
  • **Log records keep the name `webapp.admin.certbot`.** The single-file module
  • **Two dead imports dropped out**: `os` and `datetime.datetime` were imported
Changed (1)
  • **`webapp/admin/certbot.py` (1,946 lines) is now the
v2.147.0
2026-08-08
Added (3)
  • **`tests/test_api_package.py`** — the structural guards for the split, all
  • **The blueprint's `root_path` moved** from `webapp/admin` to
  • **Verification.** 21 of 21 top-level definitions are `ast.dump()`-identical
Fixed (1)
  • **`tests/test_api_field_fixes.py` and
Changed (3)
  • **`webapp/admin/api.py` (2,105 lines) is now the `webapp/admin/api/`
  • **Import blocks are derived, not hand-written.** Guessing them produced 127
  • **Three dead imports dropped out of the split**: `flask.current_app`,
v2.146.1
2026-08-08
Fixed (3)
  • **The live audio-source list stopped updating over WebSocket.** The Phase 3a
  • `_emit_audio_sources_update()` imports at the top of the function, outside
  • `_refresh_config_cache()` imports inside a `try`/`except Exception` that
Changed (1)
  • **`tests/test_audio_ingest_package.py` now derives the shim's required
v2.146.0
2026-08-08
Fixed (3)
  • Nothing user-visible. 34 of 34 moved definitions are `ast.dump()`-identical,
  • **Three functions import `get_redis_client` from `app_core.redis_client`
  • `tests/test_8khz_stress_test.py::test_8khz_with_increasing_noise[0.2]` is
Changed (2)
  • **`webapp/routes_settings_radio.py` (2,781 lines) is now the
  • **Test seams are now reachable in one place.** The names the radio tests
v2.145.0
2026-08-08
Fixed (2)
  • Removed a dead `per_page` capture in the PDF export that had been flagged by
  • **The PDF export applies a strict subset of the page's filters.** It honours
Changed (2)
  • **The 385-line `alerts()` handler and its ~175-line PDF export are now the
  • **Added `tests/test_public_alerts_page.py` (73 tests).** Neither handler had
v2.144.0
2026-08-08
Fixed (2)
  • Nothing user-visible. The rendered payload was compared key-by-key against
  • **29 of the handler's 31 trailing `setdefault` calls were dead.** Every
Changed (2)
  • **The 645-line `stats()` handler is now the `webapp/public/stats_sections/`
  • **Added `tests/test_public_stats_sections.py` (32 tests).** The handler had
v2.143.0
2026-08-08
Fixed (1)
  • Nothing user-visible. The split is behaviour-preserving: the loader's full
Changed (2)
  • **`_load_logs_data` (1,057 lines in a single function) is now the
  • **Added `tests/test_public_logs_data.py` (79 tests).** The loader previously
v2.142.0
2026-08-07
Fixed (3)
  • `docs/guides/SMART_SETUP.md` pointed at line ranges in the old monolith
  • `smart.py` (429) and `snapshot.py` (478) are still over the 400-line
  • One pre-existing lint finding was left alone deliberately: `smart.py` has an
Changed (2)
  • **`app_utils/system.py` (2,580 lines) is now the `app_utils/system/`
  • **`DEVICE_TREE_CANDIDATES` is deliberately not re-exported from the package.**
v2.141.0
2026-08-07
Changed (1)
  • **`webapp/routes_public.py` (2,849 lines) is now the `webapp/public/`
v2.140.2
2026-08-07
Fixed (6)
  • **A dead audio source restarted forever instead of backing off, filling the
  • `restart()` no longer clears the breaker. A successful launch marks the
  • Quarantine now backs off exponentially (60s → 120s → … → 900s cap)
  • Repeat alerts are deduplicated by severity (`stall` < `disconnected` <
  • **A flapping SDR receiver wrote an `ERROR` and a matching `INFO` row per
  • **The Logs page "Copy" button omitted the message from every row.** It read
v2.140.1
2026-08-07
Added (1)
  • **`tests/test_audio_credential_redaction.py`** (15 tests). The central one is
Fixed (4)
  • **`GET /api/audio/sources` returned the stored stream password in cleartext.**
  • **Every endpoint emitting `device_params` returned the stored `Authorization`
  • **`_restore_audio_source_from_db_config` copied credentials into
  • **`GET /api/audio/icecast/config` returned the Icecast source and admin
Changed (1)
  • **The stream edit form treats the Authorization header as write-only**, the way
v2.140.0
2026-08-06
Added (1)
  • **`tests/test_audio_source_listing.py`** (12 tests) characterizes
Changed (3)
  • **`api_get_audio_sources` (327 lines) became a 15-line handler plus two
  • **The write endpoints moved to `routes_sources_write.py`** (389 lines). The
  • **Three shapes the endpoint has always tolerated are now named rather than
v2.139.0
2026-08-06
Added (1)
  • **`tests/test_audio_ingest_package.py`** (16 tests) pins the split: the
Fixed (1)
  • **`api_get_rbds_history` and the source start/stop endpoints keep working in
Changed (2)
  • **`webapp/admin/audio_ingest.py` → `webapp/admin/audio_ingest/`.** Helpers
  • **`register_audio_ingest_routes` now fans the caller's logger out to every
v2.138.1
2026-08-06
Fixed (1)
  • **A satellite could silently disappear from `satellites_in_view`.** `apply_gsv`
v2.138.0
2026-08-06
Added (3)
  • **`tests/test_gps_nmea_sentences.py` (19 tests).** These rules were
  • A characterization harness was built **before** the refactor: a 28-sentence
  • Deferring the effects until after the parse was checked, not assumed: none of
Changed (2)
  • **`GPSManager._handle_sentence` (246 lines, 50 `self` references) became
  • **`_FIX_QUALITY` and `_safe_int` moved to `app_core/gps/nmea.py`**, the NMEA
v2.137.0
2026-08-06
Changed (4)
  • **The stateless half of `GPSManager` moved out of
  • **The GPS stability tests now import the functions directly.** 20 call sites
  • **`LARGE_FILE_REFACTOR_PLAN.md` corrected: Phase 2 is not complete.** The
  • The CI fixes released in 2.136.0 (repository-relative test paths, CI schema
v2.136.0
2026-08-06
Fixed (3)
  • **Naming the new module `types.py` shadowed the stdlib `types` module.** The
  • `controller.py` (1003), `behavior.py` (546) and `config_loaders.py` (440)
  • `app_core/radio/demod/types.py` (added in 2.134.0) carries the same
Changed (2)
  • **`app_utils/gpio.py` (3149 lines) became the `app_utils/gpio/` package.**
  • **The GPIO tests now patch the module that *uses* each name.** 31
v2.135.0
2026-08-06
Fixed (3)
  • **The brand logo would have disappeared from every share image, silently.**
  • `maps.py` (731), `panels.py` (577) and `render.py` (492) remain over the
  • Two pre-existing issues were found and deliberately left alone, since fixing
Changed (3)
  • **`app_utils/image_export.py` (3391 lines) became the `app_utils/image_export/`
  • **`tests/test_image_export_themes.py` was updated for the package split** —
  • **`app_core/radio/demod/` now uses relative intra-package imports**, matching
v2.134.0
2026-08-06
Added (1)
  • **`docs/development/LARGE_FILE_REFACTOR_PLAN.md`** — the running plan for the
Fixed (3)
  • **`scripts/rbds_diagnose.py` would have silently reported stale pipeline
  • `scripts/rbds_diagnose.py` cannot locate the `_costas_pysdr` /
  • `demod/rbds_worker.py` (2293) and `demod/rbds_decoder.py` (1238) are each a
Changed (2)
  • **`app_utils/fips_codes.py`: 3887 → 673 lines.** 3,236 lines of it were a
  • **`app_core/radio/demodulation.py` (5355 lines, the largest module in the
v2.133.1
2026-08-06
Fixed (3)
  • **Archiving settings were wiped on every service start — i.e. every upgrade.**
  • **The silence threshold was saved but never applied.** The Audio Archives page
  • **Malformed stored settings could stop archiving from coming up.** The save
Changed (3)
  • **The Audio Archives page now looks like the other admin pages.** It moved
  • **Page JavaScript moved to `static/js/pages/audio_archives.js`**, bringing the
  • **`webapp/routes_audio_archive.py` (928 lines) became the `webapp/audio_archive/`
v2.133.0
2026-08-06
Added (2)
  • **Language logos.** `static/img/logos/` gained `yaml.svg`, `shell.svg`,
  • **`.ti-logo` is now a shared utility** in `styles.css`. It was scoped to
Fixed (1)
  • **Chart axis and tick labels were invisible on every dark theme.** Charts were
Changed (1)
  • **The page uses the shared dashboard idiom.** Live `.status-pill` chips ride
v2.132.1
2026-08-06
Added (1)
  • **`tests/test_mobile_overflow_css.py`** — a static guard (no browser needed)
Fixed (1)
  • **Every page using the standard `.page-header` scrolled horizontally on
v2.132.0
2026-08-06
Added (3)
  • **`app_utils/repo_stats/` — live repository analysis.** `scanner.py` finds
  • **`/api/repo-stats`** returns the same snapshot as JSON. It sits behind the
  • **Repository Statistics is now in the navigation registry** (Reports →
Fixed (1)
  • **The page rendered invisible stat tiles in every theme.** It painted with
Changed (4)
  • **The page is a real template.** `templates/repo_stats.html` extends
  • **Route counting is now authoritative.** Counts come from the running app's
  • **Vendored code no longer inflates the headline numbers.** `static/vendor/`,
  • **Python line counting no longer miscounts one-line docstrings.** The old
v2.131.0
2026-08-06
Added (3)
  • **`webapp/navigation/` — the navigation registry.** `registry.py` holds the
  • **`docs/frontend/NAVIGATION.md`** documenting the registry, the node types,
  • **`tests/test_navigation_registry.py`** (20 tests): every link resolves to a
Fixed (4)
  • **Dead permission gate.** Part of the Tools menu was gated on
  • **Site map / settings hub drift.** The site map linked `/admin/users` while
  • **Pages reachable only from the site map.** LED Sign, VFD Display and OLED
  • **Stale menu paths in the docs.** `templates/help.html` and
Changed (8)
  • **All tests and diagnostics are in one place.** Test pages were spread across
  • **The *Tools* junk drawer is gone.** Its four unrelated groups
  • **Settings is a direct link, not a two-item dropdown.** It previously held
  • **Display Units moved to the user menu.** Unit preferences are per-browser
  • **Top-level sections are now** Dashboard · Monitor (what comes in) ·
  • **`templates/components/navbar.html` went from 1268 lines to ~200.** The
  • `templates/settings_hub.html` and `templates/site_navigation.html` render
  • `AGENTS.md` now directs agents to the registry — the "Changing the Navbar"
v2.130.0
2026-08-05
Added (1)
  • `tests/test_monitoring_pages_uniform_chrome.py` — 39 tests pinning the shared
Fixed (1)
  • **Drift between the two copies.** The strips disagreed on their background
Changed (3)
  • **The GNSS dashboard uses the standard page header.** It was the last page
  • **Status strip, pills and heartbeat dot are now one shared component**
  • **The GNSS page's view toggle and refresh selector** were restyled for the
v2.129.1
2026-08-05
Added (2)
  • **A "Full GNSS Dashboard" link on the System Health GPS card.** The
  • `tests/test_system_health_gps_ui_fixes.py` — 13 regression tests, each
Fixed (5)
  • **`formatUptime` was declared twice in `system_health.html`.** Both
  • **System Health never refreshed after a hidden tab regained focus.**
  • **The GNSS dashboard's trends timer ignored `document.hidden`.** Its
  • **A failing GNSS telemetry feed was reported only to the console.**
  • **All 15 `<canvas>` elements on System Health now carry an accessible
v2.129.0
2026-08-05
Fixed (3)
  • **Meters for replaced audio elements ran forever.** The source list
  • **Duplicate listeners accumulated on every re-render.**
  • New `tests/test_realtime_vu_meters.py` pins the static guarantees (no FFT
Changed (6)
  • **One shared `AudioContext` instead of one per source.** Every
  • **Time-domain sampling instead of an FFT.** `getByteFrequencyData()` runs a
  • **Frame-rate-independent ballistics.** Attack and release are now time
  • **DOM writes only on change.** Bar widths are quantised to 0.1% and written
  • **Paused, fully decayed sources are skipped entirely** rather than being
  • The loop remains `requestAnimationFrame` — display-capped, and suspended
v2.128.7
2026-08-05
Fixed (5)
  • **A decoder feed that died mid-listen reported nothing.** `onPlaying()`
  • **Stopping deliberately no longer raises a "stream ended" alert.**
  • **A stream that never produced a frame hung the button on "Loading…"
  • **A missing ffmpeg was reported as an audio-source problem.** The decoder
  • New `tests/test_audio_monitor_decoder_feed.py` pins the stream labelling
v2.128.6
2026-08-05
Fixed (2)
  • **The Services diagnostic failed every unit it could not query.**
  • **The alert self-test could not fail.** `run_alert_self_test` computed
Changed (5)
  • Raised the audio pipeline suite's run budget from 180s to 240s and made the
  • The audio pipeline suite genuinely runs pytest and parses its JUnit XML:
  • The remaining diagnostic checks are honest — Audio Service, Audio Devices
  • New `tests/test_diagnostics_service_check.py` covers the unreachable-bus,
  • Extended `tests/test_alert_self_test_routes.py` with the decode-error,
v2.128.5
2026-08-05
Fixed (3)
  • **System Health "Service Details" never appeared.** The refresh script built
  • **The radio receiver's "Service Configuration" summary never appeared.**
  • New `tests/test_health_and_radio_panels.py`: asserts every id the two
v2.128.4
2026-08-05
Fixed (4)
  • **Role denials returned 500 instead of a redirect.** `_role_denied_response`
  • **The per-boundary Delete button had no endpoint.** The boundary-management
  • **The audio detail page's Delete button 404'd.** It called
  • **The storm-track map legend never rendered.** `alert_detail.html` called
v2.128.3
2026-08-05
Fixed (6)
  • **`.text-warning` was illegible on cards in all 11 light themes** —
  • **Every hyperlink failed AA in 11 of 20 themes.** `a { color:
  • **Muted text in the default Cosmo theme measured 2.56:1** (`#94a3b8` on
  • **Dark theme's `--text-secondary`** measured 4.23:1 on its own surface.
  • **Coffee's page header title measured 1.91:1** — white ink on a pale tan
  • **Aurora, Orange, Sunset and Dark page header titles** sat under the 3.0
Changed (3)
  • Added a **semantic ink layer**: each theme now carries `--success-ink`,
  • `.eas-hero-lead` is now full-opacity white with a stronger shadow.
  • `scripts/diagnostics/check_theme_contrast.py` gained **9 strict probes** for
v2.128.2
2026-08-04
Added (4)
  • `EASMessage.summary_query()` / `summary_to_dict()`, plus
  • `tests/test_blob_free_list_views.py` — pins the query shapes and asserts
  • Every changed endpoint was diffed before and after against seeded data:
  • `webapp/admin/audio.py` is dead code — the `webapp/admin/audio/` package
Fixed (10)
  • **`/logs` detail tabs loaded every audio blob to compute a checkbox.**
  • **`/audio` (audio history) was the worst case: 19.7 s and 2715 MiB.**
  • **`/audio/received` listing** deferred `raw_audio_data` and
  • **`/api/logs/recent`** — polled every 10 s by the live log viewer — no
  • **Bulk exports loaded geometry they never wrote.** `/export/alerts`,
  • **The active-alert WebSocket push** (`alerts_update`, every 5 s) loaded 50
  • **Expired-alert sweeps** (`cleanup_expired()` plus the two admin
  • **Smaller listings** — the admin dashboard's recent-messages panel, the
  • **EAS message purge loaded every blob it was about to delete.** Purging
  • **Manual activation purge** had the same shape — it reads only `id` and
v2.128.1
2026-08-04
Added (1)
  • `tests/test_fcc_report_queries.py` — asserts the report queries never
Fixed (2)
  • **FCC Reports returned a 502 gateway error.** Reports → FCC Reports
  • **Report queries are now bounded.** The export routes accept arbitrary
v2.128.0
2026-08-04
Added (2)
  • `docs/security/PUBLIC_ROUTES.md` — the route inventory, the rule for deciding
  • `tests/test_public_route_audit.py` — asserts documentation stays public
Changed (2)
  • **Documentation and licence pages no longer require a login.** `/attribution`
  • **Machine-describing diagnostics are no longer readable from the internet.**
v2.127.0
2026-08-04
Added (1)
  • The MFA code field is now autofillable. It carried `autocomplete="off"`,
Fixed (3)
  • **`/security/center` took a very long time to open.** Two causes:
  • **Automated RWTs could leave the transmitter unkeyed even with the GPIO
  • GPIO behaviour-matrix warnings (e.g. "no pin is assigned a transmit-capable
Changed (2)
  • **The RWT now fires once per week, on one of the selected days — not on every
  • The traffic dashboard's 60-second auto-refresh only runs while it is actually
v2.126.2
2026-08-04
Fixed (2)
  • **MFA login rejected valid codes for 90 seconds after every sign-in.**
  • **GPIO relays never fired for automated RWTs or forwarded alerts.** Two
Changed (1)
  • The GPIO subsystem's active-alert count is cached for 5 seconds. It was
v2.126.1
2026-08-04
Added (1)
  • `.claude/hooks/session-start.sh` — provisions a remote session end to end in
Fixed (1)
  • `app_core/migrations/env.py` set `SKIP_DB_INIT` inside `_get_configured_url()`,
v2.126.0
2026-08-04
Added (2)
  • The CI test job now provisions a **PostGIS** service container and exports
  • The horizontal-overflow warnings on `.page-header` are a false positive: the
Fixed (6)
  • **Every page title in the application was close to illegible.**
  • **The "Map Layers" and map-legend panel titles on the dashboard were white
  • **The Dashboard navbar item wrapped onto two lines below ~1440px**,
  • **Alerts could disappear from the UI entirely.**
  • **The VFD Display Control page was entirely non-functional.** It injected
  • `README.md` referenced `docs/screenshots/system-health.jpg`, which did not
Changed (2)
  • Removed the "🔍 Ctrl K" search pill from the navbar by request. It occupied
  • Refreshed the README screenshot tour from the running application and added a
v2.125.0
2026-08-04
Added (3)
  • `tests/test_frontend_consistency.py` — static guards so the duplication
  • `tests/css_collisions_allowlist.txt` — the 33 CSS classes currently defined
  • `.spinner-lg`, `.empty-state-plain`, `.status-badge-plain`,
Fixed (5)
  • **`escapeHtml` had 16 behaviourally different implementations across 22
  • **`showToast` was redefined in 8 templates and one module**, despite being
  • **The August 2026 CSS consolidation was additive but never subtractive.**
  • **Bootstrap primitives were being restyled per page.** The Environment
  • `.stat-label` was defined unscoped by three pages with three different
Changed (3)
  • **Every page header now comes from `components/page_header.html`.** Four
  • The empty-state icon on the Screens page was harmonised from 4rem/0.35
  • Deleted `templates/privacy.html`. The `/privacy` route renders
v2.124.0
2026-08-04
Added (6)
  • **CI now runs the full test suite.** `.github/workflows/tests.yml` runs
  • **A lint gate.** New `pyproject.toml` carries pytest and ruff configuration.
  • `AudioSourceManager.remove_source()` — the manager could add sources but had
  • `AudioSourceManager(monitor_interval=...)` — the health-check interval was
  • `tests/known_failures.txt` — tests that need real hardware or a live SDR
  • A shared `authenticated_user` pytest fixture covering all three auth
Fixed (13)
  • **`pip install -r requirements.txt` failed outright on Python 3.11 and 3.12,
  • **Twenty undefined names that raise `NameError` at runtime**, found by the
  • `webapp/routes_security.py` used `logger` in three `except` handlers
  • `webapp/routes_backups.py` used `os` without importing it, in the backup
  • `app_core/audio/eas_monitor.py` read `full_alert_json` roughly 25 lines
  • `webapp/routes_monitoring.py` called `get_redis_client()` without
  • Missing `Any` / `Optional` typing imports and several unresolvable forward
  • **`AudioSourceManager.start()` could never succeed.** The guard read
  • **The app could not be instantiated against SQLite at all.** `app.py` passed
  • **`scripts/database/check_schema.py` silently skipped three column checks.**
  • ...3 more
Changed (3)
  • `tests/conftest.py` seeds `DATABASE_URL`, `SECRET_KEY`, `SKIP_DB_INIT` and
  • The `audio`, `gpio` and `radio` pytest marks are registered in
  • Roughly 28 MB of scratch bug artifacts (two PDFs and an unreferenced IQ
v2.123.2
2026-08-01
Added (2)
  • **`app_utils/gpio_logs.py`** — shared level/message/duration formatting for
  • **`tests/test_gpio_activation_logging.py`** — 11 tests covering audit writes
Fixed (6)
  • **The GPIO activation log stopped recording anything once relay keying
  • **An activation was only written to the audit trail when the relay
  • **A relay keyed through the `activate_all` fallback was never released,
  • **A hold on an already-energised pin was dropped on the floor**, leaving
  • **A finishing `INCOMING_ALERT` pulse could un-key a transmitter mid-alert.**
  • **Failed GPIO activations were indistinguishable from successful ones in
v2.123.1
2026-08-01
Added (1)
  • **`scripts/diagnostics/check_theme_contrast.py`** — audits text/background
Fixed (5)
  • **Table headers rendered dark text on a dark gradient in every theme.**
  • **`.eas-table` headers fell below WCAG AA in the dark theme** (4.04:1,
  • **ENDEC Device Feeds had an unwrapped `<table>`**, violating the
  • **"All Logs" silently omitted half the log categories.** The unified view
  • **Documentation pointed at a `/system-logs` page that does not exist.**
v2.123.0
2026-08-01
Fixed (2)
  • **Hero banner titles were unreadable in light themes.** The global
  • **Page headers were unreadable in the Yellow theme.** The standard
Changed (10)
  • **Every page now renders its header through one shared component**
  • **Modernized header visuals for all pages and themes.** The component
  • **Live Display Preview** no longer overrides the global `.page-header`
  • **Style Guide** (`/style-guide`) now documents the component-include
  • **Hero pages standardized too.** About, Attribution, and Support
  • **Admin section (32 pages) standardized.** Every `templates/admin/*`
  • **Remaining stragglers standardized.** Help, Privacy, SMS Policy, and
  • **Orphaned templates deleted.** `alerts_new.html`,
  • **Style Guide is now reachable.** `templates/style_guide.html` had no
  • **Per-page CSS consolidation.** Byte-identical rule blocks that were
v2.122.2
2026-08-01
Fixed (4)
  • **install.sh no longer aborts when a distro-specific package is missing**
  • **Databases initialized by the `db.create_all()` fallback are no longer
  • **`scripts/database/check_schema.py` no longer cascades into
  • **ALSA and PulseAudio source adapters are no longer silently unavailable on
v2.122.1
2026-08-01
Fixed (8)
  • **A stalled capture could freeze the whole audio service** (all sources
  • `AudioIngestController.start_source()/stop_source()/remove_source()/
  • The health monitor restarted stalled sources **serially in its single
  • The 30-second source watchdog in `eas_monitoring_service.py` ran
  • The watchdog's auto-start lookup queried `AudioSourceConfigDB`
  • **Startup auto-start is now parallel**: sources start in concurrent
  • `UnifiedEASMonitorService._discover_sources()` now uses the controller's
  • Regression coverage in `tests/test_stalled_capture_isolation.py`
v2.122.0
2026-07-03
Added (4)
  • **Multi-select Audio Source and Event Type filters** on the Received Alerts
  • **Clickable statistics cards**: the Total / Forwarded / Ignored / Errors
  • Filter parsing, query building, and chip/pagination query-string helpers
  • Help page documents the new filtering workflow.
Fixed (1)
  • Filter chips and pagination links are now built with proper URL encoding;
v2.121.0
2026-07-02
Added (4)
  • **Off-air narration quality detection** (`app_utils/audio_quality.py`). A
  • **Relay Narration Audio setting** (`EASSettings.relay_narration_source`,
  • **Quality verdict surfaced in the UI**: the received-alert detail page now
  • Regression coverage in `tests/test_narration_quality.py` (8 tests).
Fixed (4)
  • **The 16 kHz EAS ingest stream carried a filter-edge glitch at every
  • **Dropped audio chunks were invisible.** `BroadcastQueue` logged
  • **Recorded alerts were shredded once the unified monitor fell behind —
  • **Live listen streams stuttered by construction under any timing jitter.**
v2.120.0
2026-06-24
Fixed (3)
  • **Broadcast relays (transmitter PTT, audio mute, duration-of-alert holds, and
  • **A forwarded alert could leave its `FORWARDING_ALERT` hold relay energised
  • **An overlapping broadcast could release the relay early.** Broadcasts share
Changed (2)
  • **The web app, poller, RWT scheduler, and resend helper no longer build a
  • **Operator-initiated manual relay control** (the GPIO Control page test
v2.119.3
2026-06-22
Changed (2)
  • **Transferred all project copyright, licensing, and attribution from the
  • **Updated operational contacts and policy text for the LLC.** The security
v2.119.2
2026-06-19
Fixed (1)
  • **The Security Center reported SSH/web bans as perpetually "not synced" and
v2.119.1
2026-06-19
Fixed (1)
  • **fail2ban SSH bans only reached the Global Ban List while the Security Center
v2.119.0
2026-06-19
Fixed (1)
  • **fail2ban restarts (including every Security Center "Save & Apply") silently
v2.118.0
2026-06-19
Added (1)
  • **Adding an allowlist entry that excludes your own IP is now blocked by
v2.117.1
2026-06-19
Changed (1)
  • **The `Release` GitHub Actions workflow no longer fires automatically when
v2.117.0
2026-06-19
Added (1)
  • **"Clear All" button for unresolved audio alerts.** Silence/health alerts can
Fixed (1)
  • **Long modals (e.g. the Add/Edit Audio Source stream form) could not be
v2.116.3
2026-06-18
Fixed (3)
  • **"Save & Apply Configuration" could report success (and leave enforcement
  • returns an actionable error when a privileged write is denied, pointing at
  • **verifies the `eas-station` jail is actually loaded after restart**, failing
v2.116.2
2026-06-18
Added (1)
  • **The fail2ban error is now shown in the UI** when the jail won't load. The
Fixed (1)
  • **The `eas-station` firewall jail still failed to load (0 mirrored) because its
v2.116.1
2026-06-18
Fixed (1)
  • **SSH bans imported from fail2ban became permanent instead of inheriting the
v2.116.0
2026-06-18
Added (6)
  • **Ban `source` field + badges** (`IPFilterSource`): Manual, Login Brute Force,
  • **Enforcement Status card** — application gate, host firewall, fail2ban
  • **Security Metrics** — failed logins (24h), IPs banned (24h), active bans, SSH
  • **Firewall synchronization health** — detects "N active bans, M mirrored"
  • New read-only endpoint `GET /security/overview` (`webapp/routes_security.py`)
  • Preserves all existing functionality: manual bans, allowlist, SSH/login
Changed (2)
  • **Refactored the Security Center around one ban list with multiple enforcement
  • The **Banned IPs** tab is now the **Global Ban List**, and the **fail2ban**
v2.115.2
2026-06-18
Added (9)
  • **The Banned IPs list now shows where each IP is from** — country flag, city,
  • `GET /security/ip-filters` enriches each entry with a `location` block via a
  • The Banned IPs / Allowlist tables gained a **Location** column rendering the
  • Works for IPv4 and IPv6, matching the rest of the ban pipeline.
  • Documented in `templates/help.html`, `templates/about.html`, and
  • **SSH (`sshd`) jail offenders are now automatically added to the unified ban
  • New `_import_ssh_bans()` in `webapp/admin/fail2ban.py`: reads
  • The **SSH Jail Bans** unban action now also removes the IP from the unified
  • Updated the fail2ban tab copy, `docs/security/SECURITY.md`, and
Fixed (3)
  • **App bans weren't reaching the host firewall (showed "Mirrored to host
  • **Self-heal:** the status endpoint now re-pushes the ban list into the
  • **The Banned IPs action buttons were unreadable** — they were icon-only and
Changed (2)
  • The fail2ban tab now **warns** when enforcement is on but the `eas-station`
  • **The fail2ban tab's "SSH Jail Bans" list now uses the same card/table layout
v2.113.0
2026-06-18
Added (9)
  • **fail2ban now enforces the existing application ban list at the host
  • **fail2ban ships pre-installed** via the base package list in both
  • Every application ban/unban — manual *and* automatic (malicious,
  • The fail2ban tab exposes: service status, a **Mirror application bans to the
  • New `Fail2banSettings` database model (`app_core/_models_settings.py`) with
  • New `webapp/admin/fail2ban.py` blueprint (`/admin/fail2ban/*`): status,
  • Rewrote the fail2ban tab in `templates/security/security_center.html` and
  • Added the required `fail2ban` sudoers entries to `config/sudoers-eas-station`
  • Documentation updated: `docs/security/SECURITY.md` (one list, two
v2.112.0
2026-06-18
Changed (1)
  • **The Attribution page now uses the same polished "stack list" layout as the
v2.111.0
2026-06-18
Added (2)
  • **Self-hosted project logos on the Attribution page** (`templates/attribution.html`).
  • The About page already renders these marks inline via its `stack_icon`
v2.110.0
2026-06-18
Added (2)
  • **"Configuration Changes" quick-link** in the navbar Logs menu
  • **Audit-action filter on the Logs → Audit page** (`/logs?type=audit&action=…`).
v2.109.0
2026-06-18
Added (1)
  • **"Purge all traffic data" control.** A new Danger Zone in Security Center →
Changed (3)
  • **Faster dashboard loads.** The Traffic dashboard endpoint assembles ~35
  • **Clearer automatic purge-by-age.** The retention setting (which already
  • `tests/test_traffic_analytics.py` — added coverage for the purge-all route +
v2.108.1
2026-06-17
Changed (3)
  • **Purging manual EAS activations is now fail-closed on the audit write.**
  • `AuditLogger.log()` gained a `raise_on_error` flag (default `False`) so
  • `tests/test_audit_config_changes.py` — added coverage for `raise_on_error`
v2.108.0
2026-06-17
Added (12)
  • **Settings changes are now audited.** Every settings-save route writes a
  • Environment variables (`webapp/admin/environment.py`,
  • Application settings, including the password policy and retention policy
  • Hardware settings (`webapp/admin/hardware.py`).
  • TTS settings (`webapp/admin/tts.py`) — API keys/passwords redacted to field
  • Poller settings (`webapp/admin/poller.py`).
  • Icecast settings and password regeneration (`webapp/admin/icecast.py`) —
  • 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`).
  • ...2 more
Changed (2)
  • **EAS transmissions and purges now land in the tamper-evident audit ledger.**
  • `tests/test_audit_config_changes.py` — covers `log_config_change` chaining,
v2.107.0
2026-06-17
Added (4)
  • **Security Center** (`/security/center`, `templates/security/security_center.html`,
  • The full Traffic Analytics dashboard is rendered **natively** in the Traffic
  • **Visitor map now plots US states, not just countries.** A new
  • The Traffic tab's heavy full-width dark hero header was replaced with a slim
Fixed (1)
  • **Banning now actually blocks access.** Previously `IPFilter.is_ip_allowed`
Changed (2)
  • The legacy `/security/malicious-logins` and `/traffic` routes now redirect to
  • Added `tests/test_ip_ban_enforcement.py` covering `IPFilter.is_ip_blocked`
v2.106.0
2026-06-16
Added (1)
  • **IPv4 vs IPv6 breakdown panel** on the Traffic Analytics dashboard
Fixed (2)
  • **IPv6 reverse-DNS lookups no longer get stuck unresolved.** `resolve_hostname`
  • Added IPv6 reverse-DNS regression coverage to `tests/test_traffic_analytics.py`
v2.105.0
2026-06-16
Added (5)
  • **Custom date ranges.** The Traffic Analytics window selector gains a
  • **Drill-down filtering.** Clicking a country, path, status family, browser,
  • **Anomaly detection** (`app_core/analytics/traffic_anomalies.py`). A dashboard
  • **Privacy / GDPR tools** (`app_core/analytics/traffic_privacy.py`).
  • New settings columns on `traffic_analytics_settings` (`anonymize_ip`,
Changed (1)
  • `traffic_stats` aggregation helpers now accept an optional `filters=` argument
v2.104.1
2026-06-16
Added (1)
  • **`tests/test_rbac_route_coverage.py`** — a static-analysis regression test
Security (6)
  • **80 state-changing routes had no authorization check and were reachable by
  • **User & session management** (`/admin/users` create/update/delete,
  • **Manual EAS** (`/manual/generate`, `/manual/events/<id>/send`, purge,
  • **System maintenance** (`/admin/operations/backup`, `/admin/operations/upgrade`,
  • **Radio receivers** (`/api/radio/receivers/*` CRUD/restart/diagnostics) and
  • **Four permission decorators referenced permissions that do not exist** in
v2.104.0
2026-06-16
Added (2)
  • **True-colour brand/language logos for file types.** The Traffic Analytics
  • **Reverse-DNS hostnames + country flags in the Login Security tables.** "Top
Fixed (2)
  • **"Export → PDF report" failed with "PDF export failed".** The export renders
  • **README "Stratum 1 — GPS/PPS" badge linked to a dead anchor.** The badge
v2.103.2
2026-06-16
Fixed (1)
  • **Export dropdown only showed "Excel / CSV"; the "PDF report" item was hidden.**
v2.103.1
2026-06-16
Fixed (1)
  • **`/api/broadcast/state` returned 401 on every unauthenticated poll.**
v2.103.0
2026-06-16
Added (1)
  • **Top Error Sources now shows *what* each noisy IP errors on.** Every row in
Changed (1)
  • **Reverse-DNS hostnames now backfill over time.** Hostnames were resolved only
v2.102.0
2026-06-16
Added (2)
  • **Dedicated, branded `/attribution` page** consolidating all open-source
  • **Canonical `docs/reference/dependency_attribution.md`** reference document.
Fixed (1)
  • **Broken attribution link on the About page.** The "dependency attribution
v2.101.1
2026-06-16
Fixed (1)
  • **Traffic Analytics page rendered as tall vertical streaks.** The new per-tile
v2.101.0
2026-06-16
Added (5)
  • **Self-hosted logos across every report.** Traffic Analytics now renders brand
  • **Visitor world map.** A new Leaflet map plots a proportionally-sized marker
  • **New breakdown reports:** Device Types (Desktop/Mobile/Tablet), HTTP Methods,
  • **Bounce rate** tile (single-page visits) and **"vs previous period" deltas**
  • **State / region for cities.** `classify_location` now reads the most-specific
Changed (1)
  • **IPv6 visitor counting.** Unique-visitor and time-series visitor counts now
v2.100.0
2026-06-16
Added (2)
  • **Stream URL preflight test** — a new `POST /api/audio/sources/test-stream`
  • **Stream authentication** — stream sources accept optional credentials via new
Changed (1)
  • Stream sources no longer retry forever on HTTP `401/403/404`. FFmpeg now only
v2.99.0
2026-06-16
Added (5)
  • **Visits, entry & exit pages.** Non-bot requests are sessionised per host
  • **File Types** report (hits grouped by extension), **Search Keyphrases**
  • **GeoLite2 City & ASN support.** The dashboard now uses City databases
  • **One uploader for all three GeoIP databases.** The upload control
  • **Open-source attribution surfaced in-app.** The About page's Software Stack
v2.98.0
2026-06-16
Added (2)
  • **Internal-traffic noise filtering (awstats-style SkipHosts/SkipFiles).** New
  • **GeoIP status indicator** in Settings — shows at a glance whether the reader
Fixed (2)
  • **Country flags now render on every OS.** They were emitted as Unicode emoji,
  • **Referrers are now meaningful.** The Top Referrers report grouped raw full
v2.97.4
2026-06-16
Fixed (1)
  • Traffic Analytics browser/OS logos 404'd/403'd because the static URL helper
v2.97.3
2026-06-15
Changed (1)
  • Traffic Analytics now renders **true multicolor brand logos** for browsers
v2.97.2
2026-06-15
Changed (2)
  • Traffic Analytics now tints the browser and operating-system logos their
  • Added a **Browsers** table (with logos) to the Visitor Environment section so
v2.97.1
2026-06-15
Fixed (2)
  • **GeoIP upload failed with "No module named 'maxminddb'".** `geoip2` (which
  • **Upload no longer hard-fails when the reader is missing.** The upload route
v2.97.0
2026-06-15
Added (6)
  • **Upload the GeoIP database from the browser.** Traffic Analytics → Settings
  • **Browser version + User-Agent reporting.** `classify_user_agent()` now parses
  • **Graphics for OS & Browser** — Operating Systems is now a doughnut chart
  • **Errors & scanners report** — Top Error URLs (4xx/5xx by path + status) and
  • **Bandwidth + when-they-visit metrics** — a Bandwidth tile (total + avg/req
  • **Export reports to CSV (Excel) and PDF.** A new Export menu produces a
v2.96.0
2026-06-15
Added (3)
  • **Reverse-DNS hostnames (awstats-style "Hosts").** A new opt-in toggle
  • **Country flags.** When a MaxMind GeoLite2 database is configured, public
  • `app_core/analytics/geo.py` gains `classify_location()` (label + ISO code) and
Changed (1)
  • `get_top_visitors()` and `get_country_breakdown()` now carry hostname and
v2.95.0
2026-06-15
Added (6)
  • **Traffic Analytics dashboard** (`/traffic`, Tools → Analytics → Traffic
  • **In-app request logging.** A new `web_request_logs` table records each
  • **Screen-resolution capture** via a tiny per-session client beacon
  • **Optional GeoIP country resolution.** Public visitor IPs resolve to country
  • **Web-UI collection settings** (`traffic_analytics_settings` table): enable or
  • only mode, bot exclusion, and the GeoIP database path — all from the dashboard.
Fixed (1)
  • **Logins/sessions no longer all show as `localhost`.** The app runs behind
v2.94.2
2026-06-15
Added (1)
  • **`Cleanup Old Workflow Runs` workflow** (`.github/workflows/cleanup-runs.yml`)
v2.94.1
2026-06-15
Changed (2)
  • **`Update Repository Statistics` workflow no longer commits to the repo.** It
  • The GitHub Pages documentation build now sets `retention-days: 1` on its
v2.94.0
2026-06-15
Added (2)
  • **Explicit cancellation tracking for CAP alerts.** When a watch or warning is
  • New `is_cancellation()` and `terminal_chain_updates()` helpers in
Changed (3)
  • The CAP poller now flags cancellations whether they arrive as a follow-on
  • The alert detail page shows a distinct **CANCELLED** badge (with the lift
  • Cancelled alerts are excluded from the active-alerts query alongside expired
v2.93.2
2026-06-15
Added (1)
  • **Screenshot tour in the README.** Added a `## 📸 Screenshot Tour` section
v2.93.1
2026-06-14
Fixed (1)
  • **Documentation, help and sponsorship pages no longer require sign-in.** The
v2.93.0
2026-06-14
Added (3)
  • **Session heartbeat tracking.** The global `before_request` hook now refreshes
  • **Lazy expiry on view.** `GET /api/admin/sessions` now sweeps stale rows
  • **Bulk "Terminate All Others" control.** A new button on the Active Sessions
Fixed (1)
  • **Active Sessions no longer accumulate forever.** An `admin_sessions` row was
v2.92.0
2026-06-14
Fixed (18)
  • **Removed duplicate "Display Units" entry** that appeared in both the Settings
  • **Removed duplicate "Backup Manager" entry** from the navbar; backups remain
  • **Lightning theme readability:** card-header titles/icons rendered white on
  • **Undefined CSS surface variables caused contrast bugs across every theme.**
  • **GPS & Time dashboard** (`templates/admin/gps_dashboard.html`) painted its
  • **Display Units popover** (`.eas-units-popover`, `static/css/styles.css`)
  • **`--card-bg` typo** — certbot, tailscale, icecast, screens and
  • **`--text-primary` typo** on the dashboard highlight chip
  • **`network.html`** security/interface badges used `var(--bg-secondary)`
  • **Full codebase sweep for the same undefined-variable class of bug.** Audited
  • ...8 more
Changed (3)
  • **Navbar Settings dropdown slimmed down.** The single "Settings" menu had
  • **New top-level "Tools" menu.** Observability (System Diagnostics, Health
  • **New top-level "Logs" menu.** Per request, logging is now its own category
v2.91.1
2026-06-14
Added (1)
  • Regression tests in `tests/test_alert_purge.py` covering multi-batch full
Fixed (1)
  • **Large purges no longer silently fail.** A purge covering thousands of
Changed (2)
  • **Alert Purge moved from the Settings navbar dropdown to the Admin panel.**
  • The Alert Purge page surfaces request failures clearly instead of swallowing
v2.91.0
2026-06-14
Added (8)
  • **A single Alert Purge admin page** (`/admin/alert-purge/`,
  • **Filter by** age (older than N days), source, forwarding decision
  • **Scope selector:** *Audio only* strips the stored `raw_audio_data` WAV but
  • **Preview** shows the matching record count and reclaimable audio size before
  • **Automatic purge** (`AutoPurgeSettings` model, `AutoPurgeScheduler`) that runs
  • **Purge service** `app_core/alert_purge.py` with preview/stats/execute
  • **Migration** `20260614_add_auto_purge_settings.py` adds the
  • Purging alerts never purges **logs** — every purge writes a `SystemLog` audit
v2.90.0
2026-06-13
Added (2)
  • **A dedicated `/support` page** (`templates/support.html`,
  • **Navigation entry points** so the page isn't buried: a "Support the
Changed (2)
  • **Footer support button now links to the new `/support` hub** instead of
  • **Ko-fi cup icon renders as a clean white monochrome mark on the blue
v2.89.0
2026-06-13
Changed (4)
  • **Ko-fi support link is now featured prominently instead of being buried
  • **Footer** (`templates/base.html`): added a styled "Support on Ko-fi"
  • **README**: added a Ko-fi badge to the badge block at the top and a
  • The existing "Support the Project" card on the About page is unchanged.
v2.88.1
2026-06-13
Fixed (1)
  • **Footer app logo now renders reliably on every browser/OS**
v2.88.0
2026-06-13
Added (3)
  • **TDOP capture** in `app_core/gps/gps_manager.py` — the gpsd `SKY` handler
  • **TDOP trend sampling** in `services/gps/trends.py` — added to the per-sample
  • **TDOP on the dashboard** (`templates/admin/gps_dashboard.html`) — the hero
Changed (1)
  • **Fix-quality gotcha documented** — added a note on the `_FIX_QUALITY` map
v2.87.0
2026-06-13
Added (6)
  • **Breathing "aurora" sheen** behind the navbar — a slow-drifting translucent
  • **Live header clock** in the brand cluster (US/Eastern), a NOC-style time +
  • **Active-page indicator** — the current top-level nav item now lights up
  • **Scroll-aware navbar** — wired up the previously-unused `.navbar.scrolled`
  • **"On Air" navbar glow** — when a broadcast is live the whole header gains a
  • **Brand wordmark shimmer** — a one-time diagonal light sweep across the logo
Changed (1)
  • **Frosted-glass navbar on light themes** — light-theme navbar gradients are
v2.86.0
2026-06-13
Added (1)
  • **Header / navbar visual polish.** Brand wordmark gains a soft drop shadow
Changed (1)
  • **Navbar stack light now matches the physical ANDONT 7-color light.** Replaced
v2.85.5
2026-06-13
Added (1)
  • **`inject_eas_audio` Redis command** (`app_core/audio/redis_commands.py`):
Fixed (1)
  • **Resending an alert produced no audio on the Icecast air-chain.** The resend
v2.85.4
2026-06-13
Added (1)
  • **Guard against stacking broadcasts.** The resend endpoint now returns `409`
Fixed (2)
  • **Resending an alert froze the web UI for minutes.** The resend endpoint
  • **GPIO relay duration did not match the alert length.** Because the worker
v2.85.3
2026-06-13
Fixed (2)
  • **Running `update.sh` left 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.sh` now refreshes the timing stack as part of the update: it stops the EAS GPS service to free the serial port, restarts `gpsd.socket` + `gpsd.service`, restarts chrony so its refclock re-locks, then lets the `eas-station.target` restart 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.service` could lose the gpsd-vs-serial race at boot.** Added `gpsd.service`/`gpsd.socket` to the unit's `After=` (ordering only — no `Wants`/`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 via `source=gpsd` instead of falling back to grabbing the serial port directly (`source=serial`), which starves gpsd. Existing installs pick this up via `update.sh` (it copies the unit files and runs `systemctl daemon-reload`).
v2.85.2
2026-06-13
Fixed (1)
  • **Resend button POSTed to a non-existent URL.** All three templates
Changed (1)
  • **Action buttons are now labelled.** The previously icon-only buttons in the
v2.85.1
2026-06-13
Fixed (3)
  • **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 with `timeout=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), and `clear_broadcast_active()` didn't run until the worker unblocked, so the overlay and tower light lingered. Both paths now bound the player to `playback_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 called `GPIOController.deactivate()` *without* `force=True`, so the controller honoured each pin's anti-chatter min-hold by `time.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 the `deactivate_all()` fallbacks in all three send paths now pass `force=True`, so the air chain drops the instant playout ends regardless of `hold_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 derives `active` from the broadcast's own `start_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.
v2.85.0
2026-06-12
Added (6)
  • **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 migration `20260612_tower_light_states`; state table documented in the [GPIO guide](../hardware/GPIO_GUIDE.md); 14 new tests across `tests/test_gpio_alert_indicators.py` and `tests/test_gpio_controller.py`.
v2.84.2
2026-06-12
Fixed (1)
  • **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): `0x02` sounds the buzzer and `0x01` silences 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.
v2.84.1
2026-06-12
Fixed (2)
  • **The GPIO service could not open a USB tower light on any port other than `/dev/ttyUSB0`** — `eas-station-gpio.service` whitelisted exactly `DeviceAllow=/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 the `char-ttyUSB` / `char-ttyACM` device groups (the pattern `eas-station-web.service` already documents). The same latent bug is fixed in `eas-station-displays.service` (VFD/LED serial) and `eas-station-zigbee.service` (coordinator), and the groups were added to `eas-station-gps.service` for USB GPS receivers. Existing installs need the updated unit files installed (`update.sh` does this; or copy from `systemd/` and `systemctl 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.
v2.84.0
2026-06-12
Added (2)
  • **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> AA` frame 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`, and `tower_light_alert_color` columns (migration `20260612_tower_light_protocol_colors`); the tower-light section of the [GPIO guide](../hardware/GPIO_GUIDE.md) documents both protocols. New protocol/color coverage in `tests/test_gpio_controller.py` (frame layout, color clamping, config loading).
v2.83.1
2026-06-12
Fixed (1)
  • **Hardware Settings save (and therefore "Save & Restart") failed with `Object '<HardwareSettings …>' is already attached to session 'N' (this is 'M')`.** The module-level cache in `app_core/hardware_settings.py` stored a live `HardwareSettings` ORM instance and only checked `detached` before 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 raised `InvalidRequestError`, 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, and `update_hardware_settings()` no longer re-adds an already-persistent instance. Regression coverage in `tests/test_hardware_settings_cache.py` reproduces the two-session scenario with a holder thread.
Changed (1)
  • **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).
v2.83.0
2026-06-12
Added (3)
  • **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 new `GET /security/audit-logs/verify` endpoint (`logs.view` permission, optional `?limit=N`) that also returns `total_rows` and `verified_at`. Every verification run is itself recorded into the chain as a new `audit.chain.verified` action — 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=audit` now include all three chain fields (`prev_hash`, `entry_hash`, `signature`; previously only `entry_hash`), and the Audit tab gained the description blurb it was missing. Endpoint regression coverage added to `tests/test_audit_chain.py` (intact chain, tamper detection through the route, `limit` handling, and the self-recording `audit.chain.verified` row).
  • **The Ed25519 signing key is now actually provisioned.** `app_core/auth/_audit_signing_key.py` documented that `install.sh` installs 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.sh` now generates the key (`openssl genpkey -algorithm ed25519`, dir `0700`, key `0600`, owned by the service user) and writes `AUDIT_SIGNING_KEY_PATH` into the generated `.env`; `update.sh` does the same idempotently for existing installs (an existing key is never overwritten — rotation would orphan old signatures) and appends the missing `AUDIT_SIGNING_KEY_PATH` to `.env` when absent. `AUDIT_SIGNING_KEY_PATH` is documented in `.env.example` and editable under Settings → Environment → Core Settings. `secrets/` added to `.gitignore` so the key can never be committed from the install-dir git checkout.
  • **Thorough integrity documentation.** New [`docs/security/AUDIT_LOG_INTEGRITY.md`](../security/AUDIT_LOG_INTEGRITY.md) (registered in `mkdocs.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.md`](../guides/AUDIT_LOG_REVIEW.md) gained a "Verifying the Log Hasn't Been Tampered With" walkthrough plus the automatic alert-lifecycle and `audit.chain.verified` event types; `docs/security/SECURITY.md` and the README's "Tamper-Evident Audit Ledger" section now point at the verification UI and the new doc; `templates/help.html` gained a "Verifying the Audit Log (Tamper Evidence)" accordion entry under Routine Operations.
v2.82.0
2026-06-12
Added (24)
  • **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_s` config, `0` disables) 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 new `config/sudoers-eas-station` entries (plain-`systemctl` fallback 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](../hardware/GPS_HAT_SETUP.md). New coverage in `tests/test_gps_serial_watchdog.py` and `tests/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_settings` table and `RetentionScheduler` daemon thread (first sweep ~2 min after startup, then every 6 h) now also cover: IQ capture `.npy` files in `RADIO_CAPTURE_DIR`, debug audio in `/tmp/eas-audio`, and the `stream_metadata_log` / `audio_alerts` / `audio_source_metrics` tables. Received-alert rows are **never deleted** — only their `raw_audio_data` blobs 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 the `GET/PUT /admin/application/retention` API. Migration `20260612_add_retention_settings`; sweep results logged in a single summary line; failures in one step never block the others. New coverage in `tests/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](https://ko-fi.com/easstation) with a vendored Ko-fi cup logo (`static/img/kofi.svg`, no external asset fetch), and the `ko_fi` entry in `.github/FUNDING.yml` is fixed so the repository's Sponsor button works.
  • **Edge-case test coverage for the audio fan-out path.** `tests/test_broadcast_queue_overflow.py` pins 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 to `docs/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_image` gains a `scale` parameter (1–3, Lanczos upscale just before encode) and the `/alerts/<id>/export-image.png` endpoint now defaults to `scale=2` (2400×1260 for landscape), which shrinks the platform's compression artefacts below visibility on screen; pass `scale=1` for 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.
  • ...14 more
Fixed (29)
  • **The TTS pronunciation dictionary is now applied to actual broadcast audio, not just the web preview.** `_load_pronunciation_rules()` in `app_utils/eas.py` required 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 in `load_eas_config()`, and it gets the same fix: `EASBroadcaster` now hands its raw `db_session` to `EASAudioGenerator`, 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 in `tests/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_image` silently skipped the map whenever `cap_alerts.geom` was 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 from `us_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 — and `send_alert_notifications` logs 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 in `tests/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`): in `poller/cap_poller.py::_insert_new_alert`, `process_intersections()` — which re-raises database errors by design — ran *between* the alert save and the `auto_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 with `eas_forwarded=False` and a NULL `eas_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_alert` now makes the forwarding decision (and sends notifications) before boundary-intersection analytics, and both poller call sites of `process_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 needs `geom`).
  • **Catch-up sweep.** A new `CAPPoller.retry_unevaluated_forwards()` runs every poll cycle and re-evaluates recent (≤ 60 min), unexpired alerts whose `eas_forwarding_reason` is still NULL — the signature of a pipeline that died (PostGIS error, OOM kill, service restart) between the save and the forwarding decision. All of `auto_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_alert` records 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/dependencies` gains 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) and `cap_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_reason` showed *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).
  • ...19 more
Changed (4)
  • **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 `/terms` route renders `docs/policies/TERMS_OF_USE.md`, but `templates/terms.html` (unreferenced dead code — nothing renders it, and the policy-page fallback targets `TERMS_OF_USE.html`, not `terms.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-compliance` route, 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.
v2.81.1
2026-05-24
Fixed (1)
  • **`GET /api/alerts/historical` returned 500 whenever any alert in the result window had `description = NULL`.** The serializer ran `alert.description[:500] + '...'` unconditionally, so a single null-description row poisoned the entire page response (`TypeError: 'NoneType' object is not subscriptable`). The handler now coerces with `alert.description or ''` before slicing and extracts the truncated value into a named local for readability. (PR #2180.)
Changed (1)
  • **Historical alerts query parameters accept `start` / `end` aliases** in addition to the existing `start_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 uses `request.args.get('start_date') or request.args.get('start')` so existing integrations keep working byte-for-byte. (PR #2180.)
v2.81.0
2026-05-24
Added (2)
  • **Operator-customizable dashboard headline and subtitle.** Two new fields — `dashboard_headline` (≤120 chars) and `dashboard_subtitle` (≤160 chars) — were added to `application_settings` so 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-side `maxlength` enforcement, and helpful placeholder text. The settings update endpoint enforces the same character limits server-side. The `ApplicationSettings` model 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) and `templates/index.html` consumes 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 (1)
  • **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.
v2.80.1
2026-05-24
Changed (1)
  • **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 full `role` / `aria-label` / `<title>` / `<desc>` accessibility attributes and the standard `?v={{ static_asset_version }}` cache-bust query string. `templates/base.html` swaps the `<i class="fa-...">` for an `<img>` and `static/css/styles.css` drops the per-mark gradient (now on the SVG itself), adds `overflow: hidden` to `.footer-logo-mark`, and introduces `.footer-logo-mark-img` to make the SVG fill the container. The `eas-system-wordmark.svg` / `.png` were re-exported from the same design source so the navbar wordmark and footer mark stay visually paired. (PR #2178.)
v2.80.0
2026-05-23
Added (7)
  • **Adaptive jitter histogram bucketing on the GPS &amp; 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_ns` so the front-end can colour-grade bars by distance-from-zero (≤1 bucket = green, ≤3 = amber, &gt;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 µs` legend. (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, optional `snapX` vertical 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 (1)
  • **Allan-deviation Y-axis labels render in proper scientific notation** (`10⁻⁷` via Unicode superscript digits) instead of `1e-7`, matching the rest of the project's timing displays. (PR #2177.)
v2.79.2
2026-05-23
Fixed (1)
  • **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 `aplay` returns immediately if the ALSA card is busy) released the relay before the downstream encoder finished framing — the composite was being trimmed mid-EOM. Both `webapp/eas/messages.py` (resend) and `webapp/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) capture `time.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.)
v2.79.1
2026-05-23
Changed (1)
  • **Repo Stats page (`static/repo_stats.html` and its `scripts/generate_repo_stats.py` generator) 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.)
v2.79.0
2026-05-23
Added (1)
  • **In-app documentation search backed by an on-demand index.** `webapp/documentation.py` gained `_build_doc_search_index()` (walks the docs root, parses out the H1 title of every `.md` file, 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 highest `mtime` across all scanned files, so an admin editing a doc and refreshing the page sees the change without a service restart. (PR #2174.)
Changed (1)
  • **Standardised `EAS Station™` (no space before the symbol) across the entire repository.** The mixed `EAS 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.)
v2.78.0
2026-05-23
Changed (3)
  • **Replaced the single fixed-size SAME audio ring with two operating modes.** The old single `_ring_max_samples` budget 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.
v2.77.0
2026-05-23
Added (3)
  • **Marine zone DBF upload now actually works** (`/admin/zones/upload` previously 500'd on every `mz_*.dbf` with `ValueError: DBF is missing required fields: STATE, CWA, TIME_ZONE, FE_AREA, ZONE, STATE_ZONE, SHORTNAME`). The public-zone schema parser at `app_utils/zone_catalog.py` was the only one wired in even though the upload page and `tools/download_nws_gis_data.py --marine` both advertised marine support. `iter_zone_records` now 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`, …) in `nws_zones.state_code` so the new marine state-tree builder can find them with a simple `IN (...)` filter. Verified against the official `mz16ap26.dbf` from `weather.gov/gis/MarineZones` (569 zones across 15 marine prefixes).
  • **Admin FIPS picker surfaces marine areas** loaded via `mz_*.dbf` and `oz_*.dbf`. `app_utils/fips_codes.py` gained `MARINE_PREFIX_TO_SAME_STATE` (UGC prefix → SAME `SS` digits, 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()` (queries `nws_zones` and emits state-shaped entries with 6-digit `PSSCCC` `code` values per marine area), and `get_extended_state_county_tree()` which composes the static US tree with the runtime marine tree. `webapp/admin/dashboard.py` now 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 carrying `077650/077633/077632/077631` whose CCC values match GMZ650/GMZ633/GMZ632/GMZ631 byte-for-byte in the NWS marine zone DBF. All 699 zones across `mz16ap26.dbf` + `oz16ap26.dbf` are now pickable. The base `get_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 every `nws_zones` row whose `zone_number` matches 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.
Fixed (1)
  • **`/admin/zones/upload` now logs the actual exception** instead of just `str(e)`. The handler now uses `logger.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 against `current_app.root_path` instead of the process CWD (the systemd unit's `WorkingDirectory` is `/opt/eas-station`, but CWD can shift in other launch modes), and filenames that `secure_filename()` reduces to an empty string are rejected up front instead of letting `file.save()` write to a directory path.
Changed (1)
  • **`sync_zone_catalog` gained `delete_scope`** (`None | False | "public" | "marine"`). The previous full-replace behaviour deleted orphans unconditionally, which meant uploading any of `z_*.dbf` / `mz_*.dbf` / `oz_*.dbf` over each other wiped every previously-loaded zone from the other catalogs. Admin uploads now pass `delete_scope=False` so 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.
v2.76.0
2026-05-22
Fixed (7)
  • **Automatic RWT broadcasts had never successfully fired** — every minute inside the configured window every gunicorn worker logged `Triggering automatic RWT broadcast`, then `Failed to trigger RWT broadcast: (psycopg2.errors.NotNullViolation) null value in column "storage_path" of relation "manual_eas_activations" violates not-null constraint`. The `manual_eas_activations.storage_path` column is `NOT NULL` at the schema level — it carries the on-disk directory for operator-triggered broadcasts created via `webapp/eas/workflow.py` — but the automated scheduler in `app_core/rwt_scheduler.py:211-233` never 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 the `last_run_at` update all rolled back together. `app_core/rwt_scheduler.py` now passes `storage_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-804` starts the scheduler at module-import time, which happens in each worker process — the production journal showed `[501597]` and `[501598]` (two workers, default config) firing identifier `RWT-AUTO-<same timestamp>` in the same second every minute. Without a deduplicator each worker would have recorded a separate `ManualEASActivation` row once the `storage_path` fix above unblocked the insert. `_check_and_send_rwt()` now acquires a Redis `SET NX EX` lock at `rwt:fired:<schedule_id>:<YYYY-MM-DD>` (25 h TTL — slightly longer than a calendar day so a window straddling midnight is still covered) before calling `trigger_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/NAMESPACE` on fresh installs.** `eas-station-zigbee.service` lists `/var/lib/eas-station` in `ReadWritePaths=` (zigpy persists its NCP/network state to `/var/lib/eas-station/zigbee.db` per `services/zigbee/coordinator.py:144-147`), but neither `install.sh` nor `update.sh` ever created that directory and no other subsystem unit referenced it. With `ProtectSystem=strict`, systemd couldn't set up the mount namespace, exited with `226/NAMESPACE` before Python started, and the unit looped forever — the web UI consequently rendered "Serial Port: Not Accessible" because port 5102 was unreachable. Added `StateDirectory=eas-station` / `StateDirectoryMode=0750` to the unit so systemd auto-creates `/var/lib/eas-station` owned by `eas-station:eas-station` before `ExecStart`, and added explicit `mkdir`/`chown` for the same directory to both `install.sh` (with the existing log-dir block) and `update.sh` (after the log-dir block, so existing installs that already hit the crash loop recover on the next `sudo 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.html` now 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 in `webapp/admin/zigbee.py` was 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 exact `systemctl status` command 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()` in `app_core/rwt_scheduler.py` returned `max(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-scheduled `window_start` and 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. Rewrote `templates/version.html` to 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-content` typography 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/radio` spectrum 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.
Changed (10)
  • **`hardware_service.py` (single process, port 5001) split into five per-subsystem subprocesses bundled under `eas-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`)
  • **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 of `hardware_service.py` now live in `services/common/bootstrap.py` and `services/common/metrics.py`. Each subsystem's `__main__.py` is 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 with `NETWORK_SERVICE_URL` (5101), `ZIGBEE_SERVICE_URL` (5102), `GPS_SERVICE_URL` (5103), `DISPLAYS_SERVICE_URL` (5104), and `GPIO_SERVICE_URL` (5105) in `app_core/config/services.py`. Every web-app call site in `webapp/admin/{zigbee,gps,network,displays,gpio}.py` and `webapp/admin/hardware.py` was updated to dispatch to the correct subsystem service.
  • **`install.sh` / `update.sh` / `uninstall.sh` updated for the split.** Both installers `enable --now` the five new per-subsystem units and the bundling `eas-station-hardware.target`. `update.sh` additionally retires the legacy monolithic `eas-station-hardware.service` (disable + remove from `/etc/systemd/system/`) so existing installs don't keep trying to revive it. `uninstall.sh` was taught to stop / disable / remove the five new units in addition to the legacy one. `eas-station.target` continues 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 exports `HARDWARE_SERVICE_URL`, (b) no live code imports or attribute-accesses it, and (c) no Python source references port `5001` for hardware service calls. Adding a backwards-compat shim is therefore CI-blocking — the split is durable.
v2.75.0
2026-05-15
Added (2)
  • **Tamper-evident audit log.** Extended the existing `audit_logs` table (added by `20251105_add_rbac_and_mfa`) with three new nullable columns — `prev_hash`, `entry_hash`, `signature` — and wired chain construction and Ed25519 signing into `AuditLogger.log()`. Every newly-recorded audit row now carries the SHA-256 of its predecessor's `entry_hash`, an SHA-256 over its own canonical-JSON content (including the `prev_hash` linkage), and an Ed25519 signature over that hash. Verification is exposed via `AuditLogger.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 from `AUDIT_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 SQLAlchemy `after_insert` listeners on `CAPAlert`, `EASMessage`, and `ManualEASActivation` (registered from the app factory via `app_core.auth.audit_listeners.register_audit_listeners`) automatically capture every alert-lifecycle insert without touching the 5+ existing creation sites. **Deliberately reused the existing `audit_logs` table and `/admin/audit-logs` viewer rather than creating a parallel "audit ledger" table or admin page**, so the menu picks up tamper-evidence with zero new navigation entries. Migration `20260515_add_chain_columns_to_audit_logs.py` is fully additive (nullable columns, idempotent up/down, no data backfill). Tests in `tests/test_audit_chain.py` cover happy-path chain construction, verifier acceptance, tamper detection in `details`, tamper at `prev_hash`, signature forgery, mid-chain row deletion, and the ephemeral-key bootstrap fallback. Pinned `cryptography>=46.0.5` in `requirements.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 `cryptography` dependency.** Added a footer badge in `templates/partials/tech_stack_badges.html`, a top-level shield in `README.md`, a "Security / auth / notifications" attributions table row in the README's `## 📚 Attributions & Open-Source Credits` section, an Authentication &amp; Notifications stack card on `templates/about.html`, and a System and Utilities bullet in `docs/reference/ABOUT.md` — each explaining that the library underpins the Ed25519 signing and SHA-256 hashing for the tamper-evident `audit_logs` chain. The drift-guard `tests/test_tech_stack_badges.py` continues to pass.
v2.74.0
2026-05-14
Added (6)
  • **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.html` while a *second*, larger copy lived in the orphan `templates/partials/footer.html` (not included by any template — silently drifting). The badge list is now consolidated into a new partial **`templates/partials/tech_stack_badges.html`** which `base.html` `{% include %}`s; the orphan `partials/footer.html` was 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 a `title="..."` 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 Credits`** section near the bottom of `README.md` lists every Python dependency from `requirements.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.html` actually `{% include %}`s the badge partial, (b) the deleted `partials/footer.html` is 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 in `requirements.txt` appears 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 in `requirements.txt` without bumping the matching shield now fails CI. `docs/process/CONTRIBUTING.md` was 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/diagnostics` triggers a one-second capture; the browser then streams the resulting `.npy` file 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 `.npy` file (load in Python, feed to `rbds_diagnose.py`, open in inspectrum / GNU Radio). Two new HTTP endpoints back the button: `POST /api/radio/diagnostics/capture/<receiver_id>` (returns `capture_id`, filename, size, and a `download_url`) and `GET /api/radio/diagnostics/capture/<capture_id>/download` (streams as attachment, then cleans up). A new `capture_iq` action in `sdr_hardware_service.py` does the actual `numpy.save()` to `RADIO_CAPTURE_DIR` (default `/var/log/eas-station/captures`, override via env), capped at `RADIO_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's `RADIO_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 in `tests/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 &amp; Time Dashboard** at `/admin/gps-dashboard` — a dense, single-page status page modelled visually on [W0CHP's chrogps-dash](https://w0chp.radio/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 of `chronyc -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 parsing `chronyc -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 by `system.configure`) and `gps_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-collected `chronyc -c tracking` and `chronyc -c sources` output. 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 &amp; Time Dashboard" tile in `templates/admin.html` Hardware tab links to the page. Regression coverage in `tests/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 &amp; 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 (2)
  • **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.html` `constellationInfo()` and `templates/system_health.html` `gpsConstInfo()`) only reads `sat.constellation`, so in gpsd mode every dot, badge, and legend chip silently fell back to neutral grey. Renamed the gpsd-side key to `"constellation"` in `app_core/gps/gps_manager.py::_handle_gpsd_sky` so 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.
  • **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 in `docs/reference/ABOUT.md`. No code changes; existing GPS HAT setup guide at `docs/hardware/GPS_HAT_SETUP.md` remains the authoritative procedural reference.
Changed (1)
  • Backfill completed for recent release metadata; new changes should be documented here going forward.
v2.73.5
2026-05-09
Added (5)
  • **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 substitutes `ptt_id_post` so 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 or `0x..` hex notation including the `A`–`F` digits, matching Motorola CPS conventions. New encoder lives at `app_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](../reference/protocols/MDC1200.md). Configured under **Admin → EAS Broadcast Settings → Pre/Post-Alert Signaling**, persisted in `eas_settings` (Alembic migration `20260505_add_mdc1200_to_eas_settings`). Verify generated packets with a dedicated MDC1200 decoder such as [`mdc-decoder`](https://github.com/pabutusa/mdc-decoder) (mainline multimon-ng has no MDC demodulator).
  • **Protocol technical reference docs** — new `docs/reference/protocols/` directory with engineering-level specifications for both [SAME](../reference/protocols/SAME.md) (FCC §11.31 / NRSC-4-B §4 — modulation, burst structure, header field grammar, attention tone, EOM, composite audio assembly, encode/decode pipelines) and [MDC1200](../reference/protocols/MDC1200.md). The index at [docs/reference/protocols/README.md](../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), and `dtmf` (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 in `eas_settings` (Alembic migration `20260501_add_alert_chime_to_eas_settings`). See [docs/guides/ALERT_SIGNALS.md](../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.
  • **Added (frontend, vendored under `static/vendor/jspdf/`):** `jspdf` 4.2.1 and `html2canvas` 1.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.
Fixed (6)
  • **Phantom whitespace below the page footer on every page (PR #2040 follow-up)**:
  • **`update.sh` no longer silently corrupts the database when Alembic fails.** When `alembic upgrade head` exited non-zero (or when `alembic` was not found), `update.sh` would silently fall back to `db.create_all()`. For migrations that *move* data — most recently `20260506_split_location_settings` — this fallback was actively destructive: it created the new `alert_filter_settings` table empty, added an empty `hardware_settings.led_default_lines` column, left the old `location_settings.fips_codes` / `zone_codes` / `storage_zone_codes` / `area_terms` / `led_default_lines` columns in place, and never advanced `alembic_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.sh` now: (1) prints both `alembic current` and `alembic heads` *before and after* the upgrade attempt so the operator can see exactly which revision is pending and whether it advanced, (2) refuses to run `db.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.py` finishes a half-applied `20260506_split_location_settings`. It detects the half-migrated state (new tables exist but old columns still present on `location_settings` and/or `alembic_version` not advanced), copies any non-empty `fips_codes` / `zone_codes` / `storage_zone_codes` / `area_terms` from `location_settings` into `alert_filter_settings` (only when the destination row is empty/default — never overwrites populated user data), copies `led_default_lines` into `hardware_settings`, drops the orphaned columns, and stamps `alembic_version` to `20260506_split_location_settings`. The script is idempotent and a no-op on a healthy database; it runs automatically at the end of `update.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]`.
  • **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 via `getComputedStyle`, 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')` in `templates/gpio_control.html` and `templates/gpio_pin_map.html` raised a `BuildError` at render time because the endpoint lives in the `dashboard` Blueprint and must be referenced as `dashboard.gpio_statistics_page`. Both templates now use the correct qualified name.
  • **RBDS detail rendering regression** in `static/js/rbds_visualization.js`. PR #1975
Changed (12)
  • **EAS Station™ fingerprint trill changed to `0xA9`**: The post-burst
  • **`.env` deprecation cleanup in user-facing docs**: Several docs still instructed users to edit `.env` for 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 the `migrated_vars` list in `webapp/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 `.env` for `POLL_INTERVAL_SEC` / `IPAWS_CAP_FEED_URLS` / `NOAA_USER_AGENT` — points at Settings → Poller and the `poller_settings` table); `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 reference `POLL_INTERVAL_SEC` as 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 claims `EAS_ORIGINATOR` env 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_id` instead of an `EAS_STATION_ID` env var). No code changes.
  • **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; the `easSettingsForm` and its `/admin/eas_settings` endpoint are unchanged. Documentation updated: `docs/guides/ALERT_SIGNALS.md` now points to the Broadcast tab.
  • **Database Schema Reorganization**: Split `location_settings` table 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 includes `led_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_filtering` endpoint; existing `/admin/location_settings` endpoint remains backwards compatible
  • The `get_location_settings()` function maintains backwards compatibility by returning a merged dictionary with the same shape as before
  • ...2 more
v2.73.4
2026-05-09
Added (1)
  • **Release metadata CI workflow** — Added `.github/workflows/release-metadata.yml` to run `tests/test_release_metadata.py` on pull requests and pushes to `main`/`develop`. This enforces the existing contributor requirement to keep `VERSION` and `docs/reference/CHANGELOG.md` aligned for behavioral changes.
Fixed (1)
  • **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.md` were advisory only. CI now blocks regressions when release metadata is not updated.
v2.71.74
2026-04-29
Added (2)
  • **Per-source EAS decoder-tap streaming** — new endpoint
  • **WebSocket `alerts_update` event** — `app_core/websocket_push.py` now
Fixed (2)
  • **RBDS Radio Text reassembly** in `app_core/radio/demodulation.py` (PR #1953).
  • **FM stereo (L–R) decoding** now produces real channel separation (PR #1953).
v2.71.73
2026-04-28
Changed (1)
  • **`templates/about.html`** — Replaced the generic ham-radio icon in the
v2.71.72
2026-04-28
Changed (1)
  • **`README.md`** — Complete rewrite from ~800 lines of technical
v2.71.71
2026-04-28
Changed (1)
  • **`scripts/rbds_diagnose.py`** — Updated to introspect the live
v2.71.70
2026-04-28
Fixed (1)
  • **`app_core/radio/demodulation.py`** — `RBDSWorker` previously built its
v2.71.69
2026-04-28
Added (1)
  • **`scripts/rbds_diagnose.py`** *(new)* — standalone offline diagnostic
Fixed (6)
  • **`app_core/radio/demodulation.py`** — Three compounding RBDS bugs fixed
  • **Costas/M&M processing order** — M&M timing recovery was running before
  • **Costas loop bandwidth** — previous `alpha=0.026 / beta=0.00035` gave
  • **Bandpass filter gain** — `_design_fir_bandpass` normalised by
  • **Stale docstrings** — `_process_rbds` and `_costas_pysdr` docstrings
  • **Temporary debug capture removed** — the `# TEMPORARY CAPTURE — remove
v2.71.68
2026-04-28
Fixed (1)
  • **`app_core/radio/demodulation.py`** — `RBDSWorker` presync state machine
v2.71.67
2026-04-28
Fixed (1)
  • **`app_core/radio/demodulation.py`** — Fragile synced-mode handling caused
v2.71.66
2026-04-28
Fixed (1)
  • **`app_core/radio/demodulation.py`** — Presync spacing tolerance widened
v2.71.65
2026-04-28
Fixed (1)
  • **`app_core/radio/demodulation.py`** — `_mm_timing_pysdr` was silently
v2.71.64
2026-04-27
Fixed (1)
  • **`app_core/radio/demodulation.py`** — Presync spacing check relaxed from
v2.71.63
2026-04-27
Fixed (1)
  • **`app_core/radio/demodulation.py`** — Per-chunk `x[::decim]` decimation
v2.71.62
2026-04-22
Changed (2)
  • **`templates/about.html`** — Complete visual redesign (PR #1912). Added a
  • ~400 lines of scoped CSS (inside `{% block extra_css %}`) using CSS custom
v2.71.61
2026-04-22
Added (3)
  • **`templates/audio_monitoring.html`** — Real-time RF signal-strength
  • **`app_core/radio/demodulation.py`** — `DemodulatorStatus` gains a
  • **`app_core/audio/sources.py`** — Extracts `signal_strength` from the
Changed (2)
  • RBDS decode now uses an adaptive sliding window: a 3-second window during
  • UI converts the linear magnitude to dBFS via `20 * log10(value)` and maps
v2.71.60
2026-04-22
Fixed (3)
  • **`app_core/websocket_push.py`** — `_emit_analytics_update()` now uses
  • **`scripts/screen_manager.py`** — `_has_active_alerts()` filters active
  • **`app_core/auth/audit.py`** — `cleanup_old_logs()` switched to the
Changed (2)
  • `app_core/gps/gps_manager.py` and `tools/download_nws_gis_data.py` move
  • All datetime comparisons in the touched modules are now timezone-aware,
v2.71.59
2026-04-14
Added (6)
  • **`app_utils/image_export.py`** — Fills the right-hand info panel of the
  • Enables the previously-defined-but-unused `_draw_vtac()` VTEC block.
  • **DESCRIPTION** — word-wrapped alert description text.
  • **INSTRUCTIONS** — yellow accent bar highlighting safety / action
  • **ISSUING OFFICE** — sender name, response type, and category.
  • All new sections respect the vertical panel boundary and stop rendering
v2.71.58
2026-04-14
Added (1)
  • **`app_utils/image_export.py`** — The OpenStreetMap background in the
v2.71.57
2026-04-14
Changed (1)
  • **`app_utils/image_export.py`** — Re-adds the compass-rose decoration to
v2.71.56
2026-04-14
Added (1)
  • **`app_utils/image_export.py`** — Expands the exported 1200×630 social
v2.71.55
2026-04-14
Added (8)
  • **`app_utils/image_export.py`** *(new)* — Image composition engine built
  • OpenStreetMap tile background with the alert polygon overlaid
  • Storm-threat card: tornado detection, wind gust, hail size /
  • County-coverage percentage with a progress bar and service-boundary
  • VTAC decoded labels and raw strings; storm-motion direction / speed.
  • Affected-area description wrapped across rows; severity-coloured
  • **`webapp/admin/api.py`** — New `/alerts/<id>/export-image.png` route
  • **`templates/alert_detail.html`** — "Export Social Image" button added
Changed (1)
  • Map tiles are fetched live from OSM; a plain dark fallback is rendered
v2.71.54
2026-04-13
Changed (1)
  • **`app_utils/eas_fsk.py`, `app_utils/eas_demod.py`, `app_utils/eas.py`**
v2.71.53
2026-04-13
Added (1)
  • **`docs/policies/TRADEMARK_POLICY.md`** *(new)* — Separates trademark
Changed (5)
  • **`LICENSE-COMMERCIAL`** — Replaced with a full Commercial Software
  • **`NOTICE`** — Simplified and clarified to explain dual licensing,
  • **`README.md`** — Clarifies AGPL availability, links to
  • **`docs/policies/TERMS_OF_USE.md`** and **`templates/terms.html`** —
  • Documentation site builds cleanly with `mkdocs build`; repository
v2.71.52
2026-04-13
Fixed (1)
  • **`webapp/__init__.py` (before-request hook)** — When the 2.71.51
v2.71.51
2026-04-13
Added (5)
  • **`app_utils/eas_fsk.py`** — New `encode_terminator_bits()` helper,
  • **`app_utils/eas_demod.py`** — New `ENDEC_MODE_EAS_STATION` constant;
  • **`app_utils/eas.py`** — `_generate_station_terminator_samples()`
  • **EAS settings** — New "Station Fingerprint" toggle in the broadcast
  • `test_eas_decode.py` — Unit test plus DLL integration test for the
v2.71.50
2026-04-13
Added (1)
  • **`webapp/received.py` + `templates/audio_received_detail.html`** — The
Changed (1)
  • Badge layout updated to stack the numeric code and county name
v2.71.49
2026-04-11
Added (1)
  • **`webapp/__init__.py`** — Registered Python's built-in `min` and `max`
v2.71.48
2026-04-10
Fixed (2)
  • **`eas_monitor_v3.py`** — The streaming decoder fires the ZCZC callback
  • Fix subtracts 1.5 s from the ring position when burst 1 fires, so the
v2.71.47
2026-04-10
Fixed (3)
  • **`app_utils/eas_demod.py`** — After a burst completed, `synced` stayed
  • **`eas_monitor_v3.py`** — The pending alert was unconditionally
  • **`app_core/audio/ingest.py` / `eas_monitor_v3.py`** — Headers injected
v2.71.46
2026-04-10
Fixed (4)
  • **`hardware_service.py`** — `_update_alert_indicators()` was a 2-state
  • **`app_utils/eas.py` (TowerLightController)** —
  • **`app_utils/eas.py`** — `start_incoming_alert()` was never called from
  • Added `test_tower_light_start_incoming_alert_disabled_sends_nothing`
v2.71.45
2026-04-09
Added (2)
  • **EAS settings** — Configurable relay tone duration and relay tone
  • Enhanced EOM (`NNNN`) message detection and recognition logic in the
Fixed (2)
  • Low-confidence alerts are no longer silently discarded — they are
  • Improved audio tone end-detection so narration timing lines up with
v2.71.44
2026-04-09
Fixed (2)
  • **`eas_monitor_v3.py` — `_store_received_alert()`** — When the
  • Warning / info log messages now describe the degraded state explicitly
v2.71.43
2026-04-09
Fixed (4)
  • Alert-storage serialisation failures surfaced during test-signal
  • Handling of repeated emergency alerts with different event codes — the
  • Audio-timing synchronisation so the captured narration starts at the
  • Adjusted test-signal injection behaviour so the injected chunks no
v2.71.42
2026-04-08
Added (8)
  • **`eas_monitor_v3.py` / `eas_monitoring_service.py`** — Separated raw
  • `_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
  • `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 (or `None`).
Changed (2)
  • `_on_eom_received()` increments `_total_alerts_dispatched` and updates
  • Service stop log line now clearly distinguishes "alerts dispatched"
v2.71.41
2026-04-08
Fixed (5)
  • **`app_core/audio/ingest.py` — `_capture_loop`** — An injected SAME
  • Fix gates the live-audio publish to `_eas_broadcast` when
  • `test_audio_pipeline_integration.py::TestStreamInjectEASGating`:
  • `test_interleaved_live_and_inject_fails_detection` — reproduces the
  • `test_gated_inject_detects_eas_signal` — verifies the gated path
v2.71.40
2026-04-03
Fixed (2)
  • USB tower light (`TowerLightController`) and NeoPixel controller were
  • On-air broadcast overlay (global countdown timer popup) could disappear
v2.71.39
2026-04-01
Fixed (5)
  • `ssl_utils.get_ssl_certificate_info()` incorrectly reported a Let's Encrypt
  • `update.sh` nginx config refresh silently reverted a Let's Encrypt certificate back
  • `update.sh` showed the "Do you want to continue with the update?" welcome dialog a
  • `update.sh` backup whiptail dialog did not call `redraw_screen` on the "No" path,
  • `update.sh` migration-error prompt used a plain `read` command whose text was buried
v2.71.38
2026-04-01
Added (1)
  • Alert History table now has server-side sortable columns: clicking any column header
Fixed (4)
  • `GET /eas_messages/<id>/summary` returned HTTP 500 because `EASMessage` has no
  • Light theme: table column headers were nearly invisible because the `table-light`
  • Removed two orphaned `</div>` closing tags at the end of `alerts.html` that
  • Added `flex-shrink: 0` to the footer so it is never compressed by the flex layout,
v2.71.37
2026-04-01
Added (5)
  • **`app_utils/alert_sources.py`** — Two new canonical source-identifier constants:
  • **`app_core/models.py`** + migration — `received_eas_alerts` table gains an
  • **`eas_monitor.py`** — Resolves the canonical source when an alert is decoded:
  • **`templates/audio_received.html`** + detail page — Ingest Path **badge** (RF /
  • **`webapp/received.py`** — Wires up the `alert_source` query filter to support the
v2.71.36
2026-04-01
Fixed (1)
  • **`webapp/admin/coverage.py`** — SAME look-ups store county names as
v2.71.35
2026-04-01
Fixed (1)
  • **`eas_monitoring_service.py`** — The variable rename from `configured_fips` to
v2.71.34
2026-04-01
Added (3)
  • **Relay audio** — OTA-received alerts that are forwarded now attach the original
  • **Live location config reload** — The EAS monitor service re-reads
  • **Alert metadata enrichment** — Forwarded alert objects now carry `event_type` and
Fixed (3)
  • SAME header forwarding now preserves statewide wildcard codes (e.g., `039000`)
  • FIPS code lists are validated at intake to reject malformed or out-of-range values
  • New unit-test coverage for location-code filtering, wildcard preservation, and
v2.71.33
2026-03-31
Fixed (1)
  • **`eas_monitoring_service.py`** — A confidence threshold of **0.25** is now applied
Changed (2)
  • **`eas_monitoring_service.py`** — Audio resampling for hardware-controlled sources
  • Waveform and spectrogram visualisations in the diagnostics panel are disabled;
v2.71.32
2026-03-31
Fixed (4)
  • **`eas_monitoring_service.py`** — `UnifiedEASMonitorService` previously shared a
  • Ring buffer is updated **before** `process_samples()` so audio is captured in the
  • `get_status()` now aggregates `decoder_synced`, `in_message`, and `bytes_decoded`
  • The `_current_source_context` mutable field is removed; source identity is carried
v2.71.31
2026-03-31
Fixed (2)
  • **`app_utils/eas.py`** — `_extract_text_from_payload()`: removed `"headline"` from
  • **`app_utils/eas.py`** — Improved punctuation, whitespace, and special-character
v2.71.30
2026-03-31
Fixed (2)
  • **`app_core/eas_storage.py`** — Added `ensure_eas_settings_columns()` following the
  • **`app.py`** — Imports and calls `ensure_eas_settings_columns(logger)` as step 5b in
v2.71.29
2026-03-31
Added (2)
  • **`app_utils/eas_encoding.py`** — When building the SAME header for a forwarded
  • **Admin dashboard** — New **Auto-Forward Event Filter** section with grouped
v2.71.28
2026-03-31
Added (2)
  • **`docs/guides/TTS_NORMALIZATION.md`** — New reference guide documenting
  • **`tests/test_tts_text_normalization.py`** — 26 tests covering all
Fixed (9)
  • **`app_utils/eas.py`** — `_normalize_text_for_tts()`: added Layer 2 NWS-specific
  • Alternate-timezone slash notation (`/5 PM CDT/`) is stripped to plain
  • `ST.` abbreviation is expanded to "Saint" (e.g. "ST. JOSEPH" →
  • Indiana county-name disambiguation: `IN` is replaced with "Indiana"
  • **`app_utils/eas.py`** — Extended `_ACRONYM_MAP` (Layer 3) with:
  • `MI` → "Michigan" — NWS county-disambiguation state code; TTS
  • `OH` → "Ohio" — NWS county-disambiguation state code; TTS reads bare
  • `AFD` → "Air Force Depot" — facility abbreviation used in SAME area
  • **`app_utils/eas.py`** — Aligned inline Layer comment numbering (0–3 →
Changed (3)
  • **`templates/admin/tts_pronunciation.html`** — Info banner now explains
  • **`templates/admin/tts.html`** — Pronunciation Preview panel now shows a
  • **`templates/help.html`** — New "Text-to-Speech Normalization &
v2.71.27
2026-03-30
Changed (2)
  • **`templates/terms.html`** — Replaced the "Jenga tower" fragility callout with three-paragraph
  • **`docs/policies/TERMS_OF_USE.md`** — Markdown source updated to match.
v2.71.26
2026-03-30
Added (2)
  • **`templates/terms.html`** — New `alert-danger` callout in Section 4b explaining that EAS was
  • **`docs/policies/TERMS_OF_USE.md`** — Mirrored callout added to markdown source.
v2.71.25
2026-03-30
Changed (2)
  • **`templates/terms.html`** — Removed **ORC § 2921.13** (Falsification) from the Ohio-specific
  • **`docs/policies/TERMS_OF_USE.md`** — Updated markdown source to match.
v2.71.24
2026-03-30
Changed (2)
  • **`templates/terms.html`** — Added three additional Ohio-specific statutes to the Section 4a
  • **`docs/policies/TERMS_OF_USE.md`** — Updated markdown source to match.
v2.71.23
2026-03-30
Changed (2)
  • **`templates/terms.html`** — Added **ORC § 2909.04** (Disrupting Public Services,
  • **`docs/policies/TERMS_OF_USE.md`** — Updated markdown source to match.
v2.71.22
2026-03-30
Changed (2)
  • **`templates/terms.html`** — Expanded Section 4a "State and local laws" bullet to add an
  • **`docs/policies/TERMS_OF_USE.md`** — Updated markdown source to match the above changes.
v2.71.21
2026-03-27
Added (4)
  • **`app_core/models.py`** — `ManualEASActivation` gains two new nullable columns:
  • **`webapp/eas/workflow.py` `manual_eas_generate()`** — Captures the client IP
  • **`webapp/eas/workflow.py` `manual_eas_send()`** — Same IP capture at broadcast
  • **`app_core/migrations/versions/20260327_add_ip_to_manual_eas_activations.py`** —
v2.71.20
2026-03-27
Changed (3)
  • **`templates/terms.html`** — Strengthened Section 3 (Disclaimer of Liability & Indemnification)
  • **`templates/terms.html`** — Added new Section 4a (Criminal Liability & Federal Law Violations)
  • **`docs/policies/TERMS_OF_USE.md`** — Updated markdown source to match all changes above.
v2.71.19
2026-03-27
Fixed (3)
  • **`webapp/admin/coverage.py`** — Census TIGER fallback for county coverage now
  • **`webapp/admin/coverage.py`** — Step 3 Boundary-table fallback (`Boundary.query
  • **`webapp/admin/api.py` `_detect_county_wide()`** — `short_with_list` heuristic
v2.71.18
2026-03-27
Fixed (2)
  • **`webapp/routes_debug.py`** — `.cast("geography")` called directly on a SQLAlchemy
  • **`templates/alert_detail.html`** — The debug panel rendered the full errors array with
v2.71.17
2026-03-27
Fixed (3)
  • **`webapp/routes_debug.py`** — Both `/debug/alert/<id>` and `/debug/boundaries/<id>`
  • **`templates/alert_detail.html`** — Debug panel "Boundary Intersection Results" table
  • **`webapp/admin/intersections.py`** — `fix_county_intersections` was computing
v2.71.16
2026-03-27
Fixed (2)
  • **`app_core/alerts.py`** — `_fetch_bulk_intersections` filtered boundaries with
  • **`webapp/admin/intersections.py`** — `fix_county_intersections` (the backend for
v2.71.15
2026-03-27
Fixed (1)
  • **`webapp/admin/intersections.py`** — Wrong import path `from app_core.coverage import
v2.71.14
2026-03-27
Fixed (2)
  • **`templates/alert_detail.html`** — The `debugBoundaries()` JS function existed but had
  • **`templates/components/navbar.html`** — The `/debug/ipaws` IPAWS Poller Debug page
v2.71.13
2026-03-27
Fixed (7)
  • **`webapp/admin/coverage.py`** — `calculate_coverage_percentages`: Three separate bugs
  • **`webapp/admin/api.py`** — `alert_detail`: `is_actually_county_wide` now requires
  • **`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`,
  • Square miles are now displayed next to the coverage percentage in both the
  • Coverage badge in the Alert Information header no longer shows the county-wide
v2.71.12
2026-03-27
Fixed (1)
  • **`webapp/admin/coverage.py`** — `calculate_coverage_percentages`: Added fallback
Changed (1)
  • **`webapp/admin/api.py`** — `get_boundaries`: When `/api/boundaries?type=county`
v2.71.11
2026-03-27
v2.71.10
2026-03-27
Fixed (2)
  • **`poller/cap_poller.py`** — `_update_existing_alert`: No longer clears
  • **`webapp/admin/coverage.py`** — `try_build_geometry_from_same_codes`: Added
v2.71.9
2026-03-27
Fixed (4)
  • **`templates/alert_detail.html`** — Replaced misleading "COVERAGE CALCULATING" /
  • **`templates/alert_detail.html`** — `triggerIntersectionFix()`: Added immediate
  • **`webapp/admin/intersections.py`** — `calculate_single_alert`: Always calls
  • **`webapp/admin/coverage.py`** — `calculate_coverage_percentages`: County coverage
v2.71.8
2026-03-26
v2.71.7
2026-03-26
Fixed (2)
  • **`templates/base.html`** — Python badge updated from `3.11` to `3.13` to
  • **`templates/partials/footer.html`** — Python badge updated from `3.11.14` to
v2.71.6
2026-03-26
Changed (14)
  • **`templates/base.html`** — Updated copyright year 2025 → 2026. Wrapped
  • **`templates/partials/footer.html`** — Updated both copyright year references
  • **`static/css/styles.css`** — Multiple visual improvements:
  • Added the previously missing `page-header-gradient` CSS class (referenced in
  • Added animated rainbow bottom accent line (`::after`) to `.navbar`.
  • Added `page-header::after` subtle bottom highlight line.
  • Enlarged `.footer-logo-mark` icon box (60 → 64 px) with a blue glow shadow.
  • Made `.footer-divider` an animated rainbow gradient stripe instead of a plain
  • Updated `.footer-column-title::after` underline to teal-to-blue gradient.
  • Added `.tech-stack-card` glass-morphism container for the badge row.
  • ...4 more
v2.71.5
2026-03-26
v2.71.4
2026-03-26
Fixed (1)
  • **`templates/admin/tts_pronunciation.html`** — The JavaScript block was declared as
v2.71.3
2026-03-26
Fixed (2)
  • **`app_utils/eas_decode.py`** (`_try_multiple_sample_rates`) — Audio file was read and
  • **`app_utils/eas_decode.py`** (`_decode_from_samples`) — Extracted the decode body
v2.71.2
2026-03-26
Fixed (2)
  • **`app_utils/ipaws_enrichment.py`** (`_canonicalize_signed_info`) — `with_comments=False`
  • **`poller/cap_poller.py`** (`_convert_cap_alert`) — Alert XML is now serialized using
v2.71.1
2026-03-26
Fixed (2)
  • **`templates/alert_detail.html`** (`loadCountiesFromSameCodes`) — SAME codes ending in
  • **`app_utils/eas.py`** (`_convert_audio_to_samples`) — Added direct `ffmpeg` subprocess
v2.71.0
2026-03-26
Added (6)
  • **`app_core/models.py`** — New `TTSPronunciationRule` model and `TTS_BUILTIN_PRONUNCIATIONS`
  • **`app_utils/eas.py`** — `_normalize_text_for_tts()` function: two-layer substitution
  • **`app_utils/eas.py`** — `_load_pronunciation_rules()` helper loads enabled rules ordered
  • **`webapp/admin/tts_pronunciation.py`** — Full CRUD admin routes under `/admin/tts/pronunciation`
  • **`app_core/migrations/versions/20260326_add_tts_pronunciation_rules.py`** — Alembic migration
  • **`docs/development/AGENTS.md`** — New "Alembic Migration Rules" section under Database
Fixed (1)
  • **`app_core/migrations/versions/20260326_add_tts_pronunciation_rules.py`** — `down_revision`
v2.70.3
2026-03-25
Fixed (6)
  • **`app_utils/eas.py`** (`EASBroadcaster.handle_alert`) — `inject_eas_audio()` was called
  • **`app_core/audio/eas_stream_injector.py`** (`inject_eas_audio`) — Before publishing EAS
  • **`app_core/audio/ingest.py`** (`AudioSourceAdapter`) — Added `_eas_inject_seq` integer
  • **`app_core/audio/icecast_output.py`** (`IcecastStreamer._feed_loop`) — Each streamer now
  • **`app_core/audio/eas_monitor.py`** (`_store_received_alert`) — If `db.session.commit()`
  • **`eas_monitoring_service.py`** (`_ensure_raw_audio_column`) — At startup the service now
v2.70.2
2026-03-25
Fixed (1)
  • **`app_core/audio/ingest.py`** (`AudioIngestController.inject_eas_test_signal`) — Test
v2.70.1
2026-03-25
Fixed (3)
  • **`app_core/audio/ingest.py`** (`AudioSourceAdapter`) — Added `_eas_injection_active`
  • **`app_core/audio/eas_stream_injector.py`** (`inject_eas_audio`) — Sets
  • **`app_core/audio/eas_monitor.py`** (`_store_received_alert`) — `full_alert_data=alert`
v2.70.0
2026-03-25
Added (6)
  • **`app_core/audio/ingest.py`** (`AudioSourceAdapter.schedule_inject`) — New public method
  • **`app_core/models.py`** (`ReceivedEASAlert.raw_audio_data`) — New `LargeBinary` column that
  • **`app_core/audio/eas_monitor_v3.py`** (`UnifiedEASMonitorService`) — Per-source audio ring
  • **`webapp/admin/audio/received.py`** — New `/audio/received/<id>/audio` route that streams
  • **`templates/audio_received_detail.html`** — Audio player card showing the raw received OTA
  • **`app_core/migrations/versions/20260325_add_raw_audio_to_received_alerts.py`** — Migration
Fixed (3)
  • **`eas_monitoring_service.py`** — `eas_stream_injector.set_controller()` was never called
  • **`app_core/audio/ingest.py`** (`inject_eas_test_signal`) — The test signal was injected
  • **`webapp/documentation.py`** — `/docs/DIAGRAMS` (and `/docs/CHANGELOG`, `/docs/ABOUT`)
v2.69.6
2026-03-24
Fixed (3)
  • **`app_core/audio/redis_commands.py`** (`_execute_command` / `source_start`) — The return
  • **`app_core/audio/sources.py`** (`StreamSourceAdapter._restart_ffmpeg_process`) — When
  • **`eas_service.py`** (`publish_eas_metrics_to_redis`) — When `eas_monitoring_service.py`
v2.69.5
2026-03-24
Fixed (5)
  • **`webapp/admin/audio_ingest.py`** (`api_delete_audio_source`) — Replace the
  • **`webapp/admin/audio_ingest.py`** (`api_delete_audio_source`) — Deleting a
  • **`app_core/audio/redis_commands.py`** (`delete_source`) — Added
  • **`eas_monitoring_service.py`** (`initialize_audio_controller`) — Wrapped
  • **`eas_monitoring_service.py`** (`main`) — Wrapped the
v2.69.4
2026-03-24
Fixed (4)
  • **`webapp/admin/audio_ingest.py`** (`api_delete_audio_source`) — Delete no
  • **`webapp/admin/audio_ingest.py`** (`api_get_audio_sources`) — Sources that
  • **`update.sh`** — Added `systemctl reset-failed` for all EAS Station™ service
  • **`systemd/eas-station-audio.service`** — Added `StartLimitBurst=0` to
v2.69.3
2026-03-24
Fixed (7)
  • **`eas_monitoring_service.py`** (`publish_metrics_to_redis`) — Replaced the
  • **`eas_monitoring_service.py`** (main loop) — Reduced metrics publish interval
  • **`eas_monitoring_service.py`** (source watchdog) — Watchdog now also
  • **`app_core/audio/worker_coordinator_redis.py`** (`read_shared_metrics`) —
  • **`app_core/audio/auto_streaming.py`** (`_get_eas_monitor_settings`) —
  • **`app_core/audio/auto_streaming.py`** (health-check step) — Dead streamers
  • **`app_core/websocket_push.py`** — Reduced the WebSocket push loop from
v2.69.2
2026-03-24
Fixed (4)
  • **`app_core/audio/auto_streaming.py`** — `_get_eas_monitor_settings()` now
  • **`app_core/audio/redis_commands.py`** — `inject_test_signal` handler now
  • **`eas_service.py`** — `initialize_eas_monitor()` now wraps the FIPS
  • **`eas_monitoring_service.py`** — Added `_redis_publisher_monitor_loop()`
v2.69.1
2026-03-24
Fixed (3)
  • **`eas_monitor_v3.py`** — `HealthTracker.update_no_audio()` no longer resets
  • **`redis_commands.py`** — Added `inject_test_signal` command to
  • **`eas_decoder_monitor.py`** — The `/api/admin/eas_decoder_monitor/test_signal`
v2.69.0
2026-03-23
Added (8)
  • `EASMonitor._streaming_decoder` alias, `_restart_count` tracker, `_restart_monitor_thread()`, and `_resample_if_needed()` to support watchdog restarts and stereo audio handling.
  • `EASMonitor.get_status()` now includes `restart_count` and 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()` in `location_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 into `assets/`.
  • NWS partial-county shapefile `assets/cs16ap26.dbf` (April 2026 vintage) bundled; `_load_county_subdivision_index` now auto-detects the newest `cs*.dbf` in `assets/` and logs a download hint when absent.
  • `install.sh` now runs `tools/download_nws_gis_data.py` after database setup to fetch the latest GIS data.
Fixed (4)
  • Removed redundant `import os` inside `_collect_smart_health` that caused `UnboundLocalError` in production.
  • `_restart_ffmpeg` in `icecast_output.py` now sleeps for `ICECAST_RESTART_DELAY` seconds before relaunching FFmpeg to prevent rapid restart loops.
  • `build_database_url()` now falls back to `POSTGRES_*` environment variables when `DATABASE_URL` is not set.
  • SOAPY_SDR error code −7 description now includes "not locked" so the PLL lock hint is surfaced correctly.
v2.68.0
2026-03-23
Changed (4)
  • **`broadcast_adapter.py`** — Replaced bare `except:` clause with `except queue.Empty:` so
  • **`radio/discovery.py`** — Silent `except Exception: pass` blocks in SoapySDR capability
  • **`routes_settings_radio.py`** — Replaced three generic `raise Exception(error)` calls with
  • **Migration scripts** — Replaced `print()` calls in five Alembic migration files with
v2.67.0
2026-03-23
Added (5)
  • **Per-source EAS ingest Icecast streams** — The auto-streaming service now creates a
  • **EAS decoder monitor respects database settings** — `AutoStreamingService` now reads
  • **Test signal injection** — New `POST /api/admin/eas_decoder_monitor/test_signal`
  • **Navbar link** — *EAS Decoder Monitor* is now listed under Monitor → Radio Monitoring
  • **Updated nginx proxy rule** — The single `/eas-ingest.mp3` location block is replaced
Changed (2)
  • `AutoStreamingService.__init__` accepts an optional `flask_app` parameter so the
  • `AudioIngestController` gains `inject_eas_test_signal(source_name)` method.
v2.66.2
2026-03-23
Fixed (1)
  • **TTS "No TTS provider configured" for every IPAWS/CAP alert** — `load_eas_config()` was
v2.66.1
2026-03-23
Fixed (1)
  • **Navbar Tools menu overflow** — The standalone "Tools" dropdown was too long to fit on
v2.66.0
2026-03-23
Added (3)
  • **EAS ingest Icecast stream** (`/eas-ingest.mp3`) — a 3rd Icecast mountpoint that
  • **Three working audio pipeline test files** — `tests/test_audio_playout_queue.py` (24
  • **Robust test-runner logging** — `routes_audio_tests.py` now scans output from the
Fixed (2)
  • **Listen button** — root cause was `audio.play()` being called inside an async
  • **Error messages now actionable** — the error alert distinguishes between "no audio
v2.65.9
2026-03-23
Added (5)
  • **Operator audit trail for manual EAS alerts** — `manual_eas_activations` now stores
  • **Application log entries** — `workflow_logger.info` now emits a line such as
  • **`generated_by` in SystemLog** — the `admin` code path also records the operator in the
  • **Alert self-test log** — `route_logger.info` for `run_alert_self_test` now includes the
  • **Database migration** `20260323_add_created_triggered_by_to_activations` adds the two
v2.65.8
2026-03-21
Fixed (4)
  • **OLED screen previews no longer blank** — the Custom Display Screens management page now
  • **Bar graphs visible in previews** — `bar` elements are drawn as filled progress bars on
  • **VFD element previews improved** — VFD screens that use the `elements` format now render
  • **Legacy `lines`-format OLED screens unaffected** — the previous text-based renderer is
v2.65.7
2026-03-21
Added (2)
  • **ENDEC hardware shown in Alert Verification** — the detected ENDEC type (`endec_mode`)
  • **`endec_mode` persisted in stored decode records** — `record_audio_decode_result()` now
Fixed (1)
  • `_deserialize_decode_result` in the alert-verification route now correctly restores
v2.65.6
2026-03-21
Added (10)
  • **ENDEC hardware detection via null/FF terminator bytes** — `detect_endec_mode()` now
  • 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_3644` vote
  • DEFAULT / DASDEC / TRILITHIC: identified by inter-burst gap timing (existing logic retained)
  • **Post-message terminator capture in `SAMEDemodulatorCore`** — after a SAME message is
  • **Leading null byte detection** — a 0x00 byte decoded just before a burst's preamble
v2.65.5
2026-03-21
Fixed (3)
  • **32-bit PCM WAV files fail to decode** — `_read_audio_samples` only handled 16-bit
  • **Goertzel decoder overrides correct DLL result with garbled partial header** — for
  • **SAME headers generated with trailing spaces** — `build_same_header` padded the
v2.65.4
2026-03-20
Fixed (3)
  • **`/admin` returning fallback HTML** — `get_same_lookup()` returns a `MappingProxyType`
  • **`/admin/notifications` and `/admin/application` returning fallback HTML** — both pages
  • **Setup-mode first-run access** — `before_request` endpoint allowlist for setup mode only
v2.65.3
2026-03-20
Fixed (3)
  • **`/api/system_status` 500 error** — `_CPU_SAMPLE_INTERVAL_SECONDS` constant was
  • **`/logs` page (system_logs.html)** — template used `{% block head %}` which is not
  • **Test correctness** — updated `test_admin_dashboard_fixes.py` to reflect the active
v2.65.2
2026-03-20
Fixed (4)
  • **`admin/notifications/` 500 error** — error-handler in `notifications.py` referenced
  • **`admin/poller/` 500 error** — same `admin_page` typo in `poller.py`; corrected.
  • **`admin/application-settings/` 500 error** — same `admin_page` typo in
  • **`admin/hardware/`, `admin/icecast/`, `admin/tts/`, `admin/certbot/`,
v2.65.1
2026-03-20
Added (3)
  • **Application Settings**, **Alert Poller**, **Text-to-Speech**, **SSL Certificates**, and **Backups**
  • New **System** category in the Settings Hub for Backups.
  • Certbot (SSL) card added to the **Network** category.
Fixed (1)
  • Notifications card description in the Settings Hub now correctly reads
v2.65.0
2026-03-20
Added (5)
  • **SNMP v2c trap notifications** — EAS Station™ can now send SNMP traps to NMS targets
  • **`pysnmp` added to `requirements.txt`** — previously the SNMP library was an undocumented
  • **`test-snmp` endpoint** — `/admin/notifications/test-snmp` (POST) sends a test SNMP trap
  • **SNMP fields in `NotificationSettings` model** — `snmp_enabled`, `snmp_targets` (JSONB),
  • **Database migration `20260320_add_snmp_to_notifications`** — upgrades existing installs
Fixed (2)
  • **Compliance email alerts now use database SMTP settings** — `system_health.py` was still
  • **SNMP health monitor uses database targets** — `system_health.py` now reads SNMP targets
v2.64.0
2026-03-20
Added (8)
  • **Raw SAME Header Parser** on `/admin/alert-verification` — paste any `ZCZC-…` string and
  • **Skip baud-rate offset variants when DLL confidence ≥ 0.85** — the Goertzel bit-scan now
  • **Early-exit in multi-rate sample-rate selection** — `_try_multiple_sample_rates` stops
  • **Vectorized Goertzel filter for tone detection** — `_goertzel_power` in
  • **Eliminated double audio load in `detect_eas_from_file`** — tone and narration detection
  • **Polyphase audio resampler** — `_resample_with_scipy` now uses `scipy.signal.resample_poly`
  • **FIPS lookup singleton** — `get_same_lookup()` returns the module-level `US_FIPS_LOOKUP`
  • **DB indexes on alert analytics columns** — added `idx_cap_alerts_sent`,
v2.63.3
2026-03-20
Changed (8)
  • **`docs/hardware/ALPHA_*.md` renamed** — removed "Phase X" development numbering from
  • **`docs/troubleshooting/AUDIO_STREAMING_SETUP.md`** — rewrote from scratch. Previous
  • **`docs/guides/HELP.md`** — fixed Reference Commands table (all entries were Docker
  • **`docs/troubleshooting/TTS_TROUBLESHOOTING.md`** — replaced two references to the
  • **`docs/guides/MANUAL_EAS_EVENTS.md`** — replaced reference to `debug_tts.py` with
  • **`mkdocs.yml`** — removed all nav entries pointing to previously deleted files; updated
  • **`docs/INDEX.md`** — added Alpha LED Sign documentation to the Hardware section.
  • **`scripts/README.md`** — rewrote to reflect current bare-metal scripts inventory.
v2.63.2
2026-03-20
Fixed (1)
  • **Missing image beside maintainer bio on About page** – `ham-radio-icon.svg` was a PNG
v2.63.1
2026-03-20
Fixed (2)
  • **EAS monitor showing false "Disconnected/Unavailable" status** – The
  • **Audio System Logs tab always empty** – The `AudioAlert` database model existed and
v2.63.0
2026-03-19
Fixed (4)
  • **Coverage percentage calculation** – The denominator in `calculate_coverage_percentages`
  • **County-wide fallback producing wrong 100 % coverage** – The alert detail view had a
  • **"Calculate Coverage Percentage" button failing with missing geometry** – The
  • **XML digital signature verification** – Added `_canonicalize_signed_info()` helper in
v2.62.2
2026-03-19
Fixed (5)
  • **Unauthenticated access to VFD control** – All VFD routes (`/vfd_control`, `/vfd`, and all
  • **Unauthenticated access to Displays dashboard** – `/displays` now requires
  • **Unauthenticated access to Screen management** – All screen and rotation routes (`/screens`,
  • **Unauthenticated access to Alert Verification** – All alert verification routes
  • **Unauthenticated access to EAS Compliance dashboard** – All compliance routes
v2.62.1
2026-03-19
Fixed (4)
  • **Unauthenticated access to LED control** – All LED routes (`/led_control`, `/led`, and all
  • **Message history stuck on "Loading message history..."** – `loadMessageHistory()` now updates
  • **Live sign preview (canvas simulator) not working** – Fixed a JavaScript bug where a duplicate
  • **Search/filter history did nothing** – Implemented the previously empty `displayFilteredHistory()`
v2.62.0
2026-03-19
Added (12)
  • **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** on `Alpha9120CController` – encodes a 2-D pixel grid as an M-Protocol Type I (Picture File) frame
  • **`LEDRSSFeed` and `LEDRSSItem` database 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
  • ...2 more
v2.61.2
2026-03-18
Added (3)
  • **EAS decoding architecture diagram** in `docs/architecture/EAS_DECODING_SUMMARY.md` —
  • **Notification delivery flow diagram** in `docs/guides/notifications.md` — Sequence
  • **Updated `docs/reference/DIAGRAMS.md`** — Added index entries for 5 previously
Fixed (1)
  • **7 broken Mermaid diagrams** — Fixed parse and lexical errors in
v2.61.1
2026-03-18
Fixed (5)
  • **Dark theme: invisible text on cards and Bootstrap components** — Bootstrap 5.3
  • **`.card` missing explicit text color** — Added `color: var(--text-color)` directly
  • **`bg-*-subtle` / `text-*-emphasis` Bootstrap utilities** — Overrode
  • **`alert-light` / `alert-secondary` in dark themes** — These alerts previously
  • **Severity badge text contrast (`index.html`)** — `.severity-severe` used
v2.61.0
2026-03-18
Fixed (7)
  • **OTA broadcast silently skipped** — The EAS monitor daemon thread had no
  • **`handle_alert()` false-positive success on DB failure** — `same_triggered`
  • **EASSettings not loaded from database in CAP poller** — `load_eas_config()`
  • **Deprecated `datetime.utcnow()` in `alert_forwarding.py`** — Redis payload
  • **OTA auto-forward attempted broadcast for UNKNOWN event codes** — When the
  • **`build_files()` exceptions propagated uncaught from `handle_alert()`** —
  • **`test_eom_segment_duration_is_reasonable` used wrong lower bound** — The
v2.60.4
2026-03-18
Fixed (9)
  • **IPAWS alerts with embedded audio fall back to TTS instead of using the pre-recorded
  • **`save_ipaws_audio()` skips `derefUri` resources with missing `mimeType`** — The
  • **MPEG audio format detection too narrow** — `_convert_audio_to_samples()` checked
  • **EAS audio sources stuck in ERROR state after network disruption** — The
  • **No automatic recovery of failed audio sources** — Added a source error-recovery
  • **"Listen to EAS audio feed" button always fails when EAS monitor has no active
  • **Misleading "audio-service may be starting up" error message** — The EAS monitor
  • **EAS monitor badge showed no guidance when sources are stopped** — Added a
  • **Listen button error showed no actionable guidance** — When the decoder stream
v2.60.2
2026-03-17
Fixed (5)
  • **Edit Alert modal and Confirmation modal unclickable** — Both Bootstrap modals were
  • **"Delete Expired Alerts" button always failed** — The JavaScript `clearExpiredAlerts()`
  • **"View Alert" button on Audio Archive** — The button was incorrectly linking to the
  • **"Edit Alert" modal not opening on Admin Panel** — The Bootstrap Modal instance for
  • **Confirmation modal not opening on Admin Panel** — `window.confirmationModal` was
v2.59.0
2026-03-17
Added (2)
  • **NOAA vs IPAWS polling differentiation** — The CAP poller now writes a separate
  • **Per-source error attribution** — Fetch errors (SSL, timeout, request failures) are now
Fixed (3)
  • **`AudioAlert.cleared` AttributeError** — The `audio` log-viewer category referenced a
  • **`PollHistory.poll_time` AttributeError** — `websocket_push.py` referenced
  • **IPAWS-STAGING endpoints now grouped with IPAWS** — The FEMA TDL staging domain
v2.58.0
2026-03-14
Changed (12)
  • **Documentation cleanup** — Removed one-off development artifacts from the docs directory:
  • **CSS Variables Migration doc relocated** — Moved `CSS_VARIABLES_MIGRATION.md` from the
  • **mkdocs.yml copyright corrected** — Changed "MIT License" to the accurate dual-license
  • **mkdocs.yml navigation rebuilt** — Removed 29 navigation entries pointing to files that do
  • **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
  • ...2 more
v2.57.3
Fixed (2)
  • **RBDS unreliable for stations broadcasting Group 2B (C' blocks)** — When the presync state
  • **RBDS polarity not updated at sync achievement** — After presync achieved sync, `_rbds_inverted_polarity`
v2.57.2
Fixed (7)
  • **Audio monitor shows "No metrics available from audio-service"** — `_sanitize_value()` in
  • **`broadcast_queue` stats never populated** — `collect_metrics()` stored broadcast queue data
  • **EAS monitor status stored as string `"None"` in Redis** — when `_eas_monitor.get_status()`
  • **`routes_eas_monitor_status.py` "invalid type" error** — the non-dict check now returns
  • **Audio monitor VU meter warning hides when sources are running but silent** — the warning
  • **EAS Continuous Monitor badge stays "Loading…" on error** — the status badge is now updated
  • **Source cards show "STOPPED" for unknown status** — when the audio-service is not running,
v2.57.1
Fixed (2)
  • **RBDS crystal-locked carrier phase drift** — `RBDSWorker._pilot_sample_counter` only
  • **Stale RBDS unit tests** — Updated `tests/test_rbds_demodulation.py` to test the current
v2.57.0
Added (5)
  • **Received EAS Alerts log tab** — New "Received EAS" tab on the Logs page shows EAS alerts
  • **EAS activity stat cards** — The Statistics dashboard now shows four new metric cards:
  • **Urgency and Certainty distribution charts** — New "By Urgency" and "By Certainty" bar/doughnut
  • **Received EAS stats in backend** — Stats route now queries `ReceivedEASAlert` and
  • **Received EAS category in All Logs** — The "All Logs" view now includes a "Received EAS"
Fixed (1)
  • **Duration chart `avg_hours` field mismatch** — `createDurationChart()` was reading `i.avg_hours`
v2.56.2
Fixed (3)
  • **504 Gateway Timeout / Gunicorn worker hung in I2C on Raspberry Pi** — Three
  • **Session key inconsistency across Gunicorn workers** (`app.py`). Without
  • **Gunicorn workers crashing on startup when PostgreSQL is not yet ready**
v2.56.1
Fixed (2)
  • **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:
v2.56.0
Changed (7)
  • **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-color` and `--secondary-color` variables, 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-mix` at low opacity against `--surface-color`), giving every section card a subtle accent without obscuring form content.
  • **Admin header banner** — Replaced hardcoded `#667eea / #764ba2` hex values with `var(--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 uses `color-mix` on 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 with `color-mix(in srgb, var(--primary-color) 20%, transparent)` so the focus state reflects the active theme color.
v2.55.0
Added (1)
  • **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 (1)
  • **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 `/settings` hub page, reducing navbar visual complexity.
v2.54.1
Changed (3)
  • **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 `/stats` link in Tools > Analytics & Reporting has been removed since Statistics is already accessible from the Monitor dropdown.
v2.54.0
Added (10)
  • **`POST /api/led/set_time_format` endpoint** (v2.54.0)
  • Accepts `time_format` ("TIME_12H" or "TIME_24H"), `color`, and `font` parameters.
  • 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_messages` database table.
  • Files: `webapp/routes_led.py`
  • **`POST /api/led/set_date_format` endpoint** (v2.54.0)
  • Accepts `date_format` (one of MMDDYY, DDMMYY, MMDDYYYY, DDMMYYYY, YYMMDD, YYYYMMDD), `color`, and `font` parameters.
  • 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_messages` database table.
  • Files: `webapp/routes_led.py`
Changed (4)
  • **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()` and `sendDateDisplay()` in `templates/led_control.html`.
  • `sendDateDisplay()` corrected to call `/api/led/set_date_format` with the `date_format` key instead of the old copy-paste bug that called `/api/led/set_time_format` with `time_format`.
  • Files: `templates/led_control.html`
v2.53.2
Added (21)
  • **CTIA-required opt-out footer in all outgoing EAS alert SMS messages** (v2.53.2)
  • `app_core/notifications/sms.py` now appends `Reply STOP to stop msgs` to 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 help` so test submissions to Twilio reviewers demonstrate compliance.
  • Files: `app_core/notifications/sms.py`
  • **Expanded `/sms-compliance` opt-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`
  • ...11 more
v2.53.1
Added (83)
  • **AMPR 44.0.0.0/8 Non-Commercial Network Disclaimer** (v2.53.1)
  • Added a prominent non-commercial network notice to `templates/about.html` and `templates/terms.html` for 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`
  • **Consistent visual theming across all pages** (v2.52.0)
  • Added the standard `admin-page-header` gradient 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.html` from the non-admin `.page-header` to `.admin-page-header` for consistent admin section styling.
  • Fixed `index.html` (dashboard): removed the large inline `<style>` block that overrode the global `.page-header` CSS 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-purple` in `static/css/admin.css` with theme-aware CSS variables (`var(--vibrant-indigo)`, `var(--secondary-color)`) so the purple header variant respects the active theme.
  • ...73 more
Fixed (405)
  • **Created missing `docs/javascripts/mermaid-init.js`** (v2.53.1)
  • `mkdocs.yml` referenced `javascripts/mermaid-init.js` as 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.js` with proper Mermaid initialization configuration (startOnLoad, theme variables, flowchart and ER diagram options).
  • Files: `docs/javascripts/mermaid-init.js`
  • **Fixed `.bg-light` text readability in dark and coffee themes** (v2.53.1)
  • The `.bg-light` CSS rule hard-coded `color: #212121` (near-black text), which became illegible when the `--light-color` variable resolves to a dark background colour (`#455169` in the dark theme, `#5b4333` in the coffee theme). Added theme-scoped overrides to use `var(--text-color)` and `var(--text-secondary)` for those two dark themes.
  • Files: `static/css/styles.css`
  • **Updated SMS Messaging Policy date** (v2.53.1)
  • Updated the "Last updated" field in `docs/policies/SMS_MESSAGING.md` from a placeholder to the current revision date.
  • Files: `docs/policies/SMS_MESSAGING.md`
  • ...395 more
Changed (44)
  • **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)
  • ...34 more
Security (16)
  • **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
  • **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_BYTES` env 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
v2.43.4
2024-12-21
Fixed (38)
  • **CRITICAL: RBDS Buffer Management Fixed** - Changed from buffer-draining to index-based bit processing
  • Root cause: `_decode_rbds_groups()` was using `pop(0)` in a `while` loop, 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=0` in 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_index` to 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)
  • ...28 more
v2.43.0
2024-12-20
Added (63)
  • **Icecast Source Limit Configuration** - Made maximum concurrent sources configurable
  • Added `max_sources` field to `IcecastSettings` database model
  • Web UI field at `/admin/icecast` to 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`
  • **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)
  • ...53 more
Fixed (237)
  • **CRITICAL: SDR Audio Source Startup Failure** - Fixed `ModuleNotFoundError: No module named 'app_core.radio.rbds'`
  • Root cause: `FMDemodulator._init_rbds_state()` was trying to import `RBDSDecoder` from non-existent `.rbds` module
  • `RBDSDecoder` class is defined in the same file (`app_core/radio/demodulation.py` line 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`
  • **CRITICAL: Hardware Module Import Errors Fixed** - Fixed `ImportError` crashes in VFD and LED modules
  • **VFD**: Removed `VFD_PORT` and `VFD_BAUDRATE` from `app_core/vfd.py` `__all__` exports (not defined as module-level constants)
  • **VFD Routes**: Updated `webapp/routes_vfd.py` to use `get_vfd_settings()` from `app_core.hardware_settings` instead of importing constants
  • ...227 more
Changed (73)
  • **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
  • ...63 more
v2.39.0
Added (77)
  • **Poller Settings Admin Page** - New database-based poller configuration interface
  • Created `/admin/poller` page for managing alert poller settings
  • Added `enabled` and `poll_interval_sec` fields to `PollerSettings` model
  • 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=100` for viewing polling logs
  • Added navigation link in Settings dropdown menu
  • Database migration: `20251218_add_poller_settings.py`
  • Replaces `POLL_INTERVAL_SEC` environment variable with database setting
  • ...67 more
Fixed (258)
  • **Update Script Password Prompts** - Fixed update.sh asking for eas-station user password
  • Added `root ALL=(eas-station) NOPASSWD: ALL` to 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"
  • **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
  • ...248 more
Changed (26)
  • **Admin Page Refactoring Phase 2 Complete** - Completed modularization of admin.html JavaScript
  • Moved final inline function `sanitizeBoundaryTypeInput` to 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
  • **Admin Page Refactoring - Phase 2 (Major Progress)** - Modular JavaScript extraction
  • ✅ Moved 449 lines of inline CSS to `/static/css/admin.css`
  • ...16 more
v2.36.0
Added (20)
  • **LED Sign IP Address Configuration** - Added IP address and port fields to admin Hardware tab
  • Added `led_ip_address` and `led_port` input fields in admin.html Hardware Integrations tab
  • Updated `/api/led/serial_config` endpoint 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
  • **Admin Role Assignment Fix Script** - Added utility script to fix users without roles
  • Created `scripts/fix_admin_roles.py` to 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`
  • ...10 more
Fixed (14)
  • **Hardware Settings Permission Issue** - Fixed "permission denied" error accessing advanced hardware settings
  • Changed `/admin/hardware` permission from `'admin'` (superuser only) to `'system.configure'` (regular admins)
  • Updated navbar to show Hardware Settings link only to users with `system.configure` permission
  • Separated hardware navigation: GPIO/Zigbee for `gpio.view`, Hardware Settings for `system.configure`
  • Eliminated confusion caused by two hardware configuration locations
  • **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
  • **Admin Users Created Without Roles** - Fixed critical issue where admin users show "No Role"
  • ...4 more
v2.34.2
Fixed (4)
  • **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
v2.34.1
Fixed (7)
  • **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
v2.34.0
Added (29)
  • **Full Web UI for Certbot Operations** - Complete SSL certificate management through web interface
  • Added `/api/certbot/obtain-certificate-execute` endpoint to directly obtain SSL certificates
  • Added `/api/certbot/renew-certificate-execute` endpoint to directly renew certificates
  • Added `/api/certbot/enable-auto-renewal` endpoint 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
  • ...19 more
Fixed (7)
  • **Removed Duplicate Icecast Settings** - Consolidated all Icecast configuration to single location
  • Removed entire Icecast settings section from `/settings/audio` page (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
v2.33.1
Added (107)
  • **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-passwords` for 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
  • ...97 more
Fixed (350)
  • **Certbot/SSL Certificate Management Security Fix** - Removed sudo calls from web interface
  • Removed all `sudo certbot` subprocess 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
  • ...340 more
Changed (66)
  • **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)
  • **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)
  • ...56 more
v2.21.0
2025-12-12
Fixed (8)
  • 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-csv` to `fa-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.env` and `noaa.env` files (no longer used in 2.20+)
  • Added troubleshooting section for "IPAWS.env not found" error
Changed (8)
  • **Environment variable consolidation** - Reduced from 93 to 73 variables by consolidating related settings
  • `MAIL_URL` replaces 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_URL` and `ICECAST_PUBLIC_URL` replace 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
  • VERSION bumped to 2.20.2
v2.20.1
2025-12-11
Changed (22)
  • **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)
  • ...12 more
v2.20.0
2025-12-11
Added (7)
  • **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
Fixed (3)
  • 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
Changed (5)
  • **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
v2.19.12
2025-12-11
Added (7)
  • 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
Fixed (4)
  • 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)
Changed (6)
  • 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
v2.19.11
2025-12-10
Added (9)
  • **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
Fixed (3)
  • 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
Changed (6)
  • 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)
v2.19.10
2025-12-10
Added (4)
  • 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
Fixed (3)
  • 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
Changed (4)
  • 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
v2.19.9
2025-12-10
Fixed (10)
  • 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 (5)
  • 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™")
v2.19.8
2025-12-10
Changed (14)
  • 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
  • ...4 more
Security (5)
  • 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
v2.19.7
2025-12-10
Fixed (7)
  • 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
v2.19.6
2025-12-10
Changed (8)
  • 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
v2.19.5
2025-12-10
Fixed (4)
  • Fixed PostgreSQL authentication configuration in `install.sh` to allow password-based connections
  • Added `pg_hba.conf` configuration to enable `scram-sha-256` authentication for `eas_station` user
  • Updated `scripts/database/fix_database_permissions.sh` to also configure PostgreSQL authentication
  • Resolves "password authentication failed for user eas_station" errors during installation
v2.19.4
2025-12-10
Changed (7)
  • 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.md` from container to service
  • Updated `HARDWARE_ISOLATION.md` with systemd service terminology and journalctl commands
  • Updated `DATA_FLOW_SEQUENCES.md` to reflect systemd service architecture
  • Aligned all documentation with ISO_BUILD_READY.md bare-metal migration status
v2.19.3
2025-12-10
Added (3)
  • Added `samples/README.md` documenting EAS test audio files and their purpose
  • Added comprehensive `legacy/README.md` explaining Docker-era scripts and their bare-metal replacements
  • Added `tests/bug_reproductions/README.md` explaining one-off test files
Changed (3)
  • Updated `install.sh` to exclude development directories: `bugs/`, `legacy/`, `bare-metal/`, `tests/bug_reproductions/`
  • Updated `.gitignore` to exclude `bugs/` and `tests/bug_reproductions/` from version control
  • Cleaned samples directory to ~6.2MB (only EAS audio test files remain)
v2.19.2
2025-12-10
Added (2)
  • Added Redis server health check to `/health/dependencies` endpoint
  • Created new bare-metal version of `scripts/collect_sdr_diagnostics.sh` using systemd and native tools
Changed (3)
  • Updated `webapp/routes_monitoring.py` to check Redis instead of Docker daemon
  • Updated comment in `webapp/routes_settings_radio.py` to remove Docker architecture reference
  • SDR diagnostics now use systemd service status and journalctl for logs instead of Docker commands
v2.19.1
2025-12-10
Added (3)
  • 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
Changed (5)
  • Updated `docs/installation/INSTALLATION_DETAILS.md` to remove Docker references
  • Updated `docs/troubleshooting/AUDIO_SQUEAL_FIX.md` to note it's for legacy Docker deployments
  • Updated `scripts/README.md` to remove references to deleted SQL files
  • Updated `webapp/routes_ipaws.py` to use systemd commands for service restarts instead of Docker
  • Updated `webapp/routes_monitoring.py` to remove docker-compose.yml from configuration checks
v2.19.0
2025-12-10
Changed (3)
  • 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
v2.18.0
2025-12-10
Fixed (3)
  • Maintenance API uses standard filesystem paths instead of container paths
  • Complete installation guide available in `bare-metal/README.md`
  • Quick start guide available in `bare-metal/QUICKSTART.md`
Changed (3)
  • Updated README.md to focus on bare metal deployment via systemd services
  • Configuration now uses `/opt/eas-station/.env` as standard location
  • Services managed via systemd: `sudo systemctl [start|stop|restart] eas-station.target`
v2.17.2
2025-12-09
Fixed (10)
  • **EAS Monitor Display Issues**: Fixed decoding rates showing >100% and display bouncing between states
  • Root cause 1: Rate calculation `samples_per_second` was 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
Changed (6)
  • **Code Quality**: Extracted magic numbers to named class constants for easier configuration
  • `WARMUP_DURATION_SECONDS = 2` - Duration of warmup period
  • `WARMUP_MAX_HEALTH_PERCENTAGE = 0.95` - Maximum health shown during warmup
  • `RATE_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
v2.17.1
2025-12-09
Fixed (14)
  • **CRITICAL: WebSocket Support Broken**: Fixed Flask-SocketIO async_mode mismatch that prevented WebSockets from working
  • Root cause: `app.py` used `async_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'` to `async_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
  • **UI White Space**: Fixed excessive white space at top of pages caused by `flex: 1` on `.page-shell`
  • Root cause: Flexbox layout with `flex: 1` caused content to expand and fill all vertical space
  • Fix: Removed `flex: 1` from `.page-shell` - footer's `margin-top: auto` handles sticky footer
  • Result: Pages now start content immediately after navbar without huge gaps
  • ...4 more
Changed (8)
  • **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
  • 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: 1` is 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
v2.16.5
2025-12-09
Fixed (4)
  • **Application Startup Failure**: Fixed unterminated triple-quoted string literal in `webapp/admin/audio_ingest.py` at 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
v2.16.3
2025-12-09
Fixed (11)
  • **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_seconds` from the audio-service metrics
  • Fix: Added `wall_clock_runtime_seconds` to the API response in `routes_eas_monitor_status.py`
  • **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 the `api` blueprint
  • Fix: Changed to `url_for('api.alert_detail', ...)` in `audio_detail.html`
  • **Layout Spacing**: Reduced global `--layout-padding-top` from `1.5rem` to `0.5rem` to 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 `!important` flags to override global pseudo-elements that added shimmer/glow effects
  • ...1 more
v2.16.2
2025-12-09
Fixed (9)
  • **Code Quality**: Fixed bare `except:` clauses in multiple files for PEP 8 compliance:
  • `scripts/run_radio_manager.py`: Added proper exception logging during cleanup
  • `debug_airspy.py`: Changed bare `except:` to `except 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 result
  • `app_core/migrations/versions/20251105_add_rbac_and_mfa.py`: Safe handling when INSERT RETURNING fails
  • `app_core/migrations/versions/20251116_populate_oled_example_screens.py`: Safe handling for screen insert
  • **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
v2.16.1
2025-12-09
Fixed (4)
  • **Dashboard Layout**: Removed duplicate `page-shell` class from dashboard container that caused large gap at top of page
  • Root cause: `page-shell` was 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-shell` class from inner `<div class="container-fluid">` in index.html
v2.16.0
2025-12-08
Changed (9)
  • **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
  • `eas_monitoring_service.py`: New name for EAS monitoring + audio processing service
  • `sdr_hardware_service.py`: New name for SDR hardware access service
  • `RENAME_SERVICES.md`: Updated to reflect completed rename
  • Old files (`audio_service.py`, `sdr_service.py`) removed completely
v2.15.5
2025-12-08
Fixed (12)
  • **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_manager` references
  • 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
  • ...2 more
v2.15.4
2025-12-08
Fixed (8)
  • **Code Quality: Removed Bare Except Statements**: Fixed 4 bare `except:` statements that could mask errors
  • `app_core/audio/eas_monitor.py`: Database rollback and SAME header parsing now log errors
  • `app_core/audio/streaming_same_decoder.py`: Message validation errors now logged at debug level
  • `app_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
v2.15.3
2025-12-08
Fixed (15)
  • **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
  • ...5 more
v2.15.2
2025-12-08
Added (4)
  • **Diagnostic Tools**: Created comprehensive audio chain diagnostic utilities
  • `diagnose_audio_chain.py` - Full audio chain health check from SDR to EAS monitor
  • `fix_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
Fixed (10)
  • **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
v2.15.1
2025-12-08
Fixed (4)
  • **Template Consistency**: Fixed deprecated block usage in zigbee.html template, resolving CI failures
  • Changed `templates/settings/zigbee.html` from deprecated `{% block extra_js %}` to standard `{% block scripts %}`
  • Ensures all templates consistently use the `scripts` block for page-specific JavaScript
  • Fixes template consistency check CI workflow that was failing
v2.15.0
2025-12-08
Changed (5)
  • 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
v2.14.0
2025-12-08
v2.13.5
2025-12-08
Fixed (16)
  • **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)
  • ...6 more
v2.13.4
2025-12-07
Fixed (7)
  • **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
  • ✅ Audio sources persist across audio-service restarts
  • ✅ No more "SDR source not available - radio manager missing" errors
  • ✅ Separated architecture fully functional at startup
v2.13.3
2025-12-07
Fixed (7)
  • **CRITICAL AUDIO BUG**: Fixed source_type mismatch preventing audio from playing and Icecast mounts from appearing
  • `ensure_sdr_audio_monitor_source` was sending `source_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
  • ✅ Audio now plays from SDR receivers
  • ✅ Icecast mounts now appear (e.g., /receiver.mp3)
  • ✅ Complete end-to-end audio pipeline working
v2.13.2
2025-12-07
Fixed (9)
  • **CRITICAL END-TO-END**: Complete signal chain from detection to audio now works
  • **Device Discovery**: Added `discover_devices` command handler in sdr-service
  • **Receiver Creation**: Added `reload_receivers` command 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
  • _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
v2.13.1
2025-12-07
Fixed (9)
  • **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
  • Comprehensive Airspy R2 configuration logging
  • Sample rate validation with clear error messages
  • Better exception handling for Airspy-specific settings
v2.13.0
2025-12-07
Added (4)
  • **MAJOR FEATURE**: PPM (Parts Per Million) frequency correction support for compensating crystal oscillator drift in SDRs
  • Added `frequency_correction_ppm` field to RadioReceiver model and database schema
  • Hardware frequency readback verification with mismatch warnings
  • Comprehensive frequency tuning diagnostics and logging
Fixed (6)
  • **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
  • 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 `ReceiverConfig` dataclass, not just database
v2.12.27
2025-12-07
Fixed (2)
  • **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 only `demodulate()` existed, preventing any audio from being generated from IQ samples
v2.12.26
2025-12-07
Fixed (9)
  • **SDR Core**: Implemented missing `get_ring_buffer_stats()` method in `_SoapySDRReceiver` that 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
  • 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
v2.12.25
2025-12-05
Fixed (3)
  • **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
v2.12.24
2025-12-05
Fixed (1)
  • 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
v2.12.23
2025-12-05
v2.12.22
2025-12-05
Fixed (2)
  • 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
v2.12.21
2025-11-27
Added (5)
  • 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 (3)
  • 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
v2.12.21
2025-11-27
Fixed (1)
  • 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.
v2.12.20
2025-11-27
Fixed (1)
  • 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.
v2.12.19
2025-11-26
Fixed (1)
  • 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.
v2.12.18
2025-11-26
Fixed (1)
  • Redirected the policy docs URLs to the canonical `/terms` and `/privacy` routes and updated the documentation index to point to those pages so users no longer see divergent copies of the legal notices.
v2.12.17
2025-11-25
Fixed (1)
  • 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.
v2.12.15
2025-11-22
Changed (2)
  • 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.
v2.12.14
2025-11-22
Fixed (2)
  • 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.
v2.12.13
2025-12-05
Fixed (3)
  • 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/metrics` so VU meters can distinguish "no signal" from transport failures and display accurate runtime state.
v2.12.12
2025-12-05
Fixed (2)
  • 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.
v2.12.10
2025-12-04
Changed (1)
  • 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.
v2.12.9
2025-12-04
Fixed (1)
  • Filter placeholder artwork metadata values (e.g., `null`, `undefined`, root-only paths) in the audio monitor so browsers stop
v2.12.8
2025-12-03
Fixed (1)
  • Corrected the default Icecast external port variable so Icecast URLs use the configured `ICECAST_EXTERNAL_PORT` rather than
v2.12.7
2025-12-02
Fixed (1)
  • Hardened the SDR audio monitoring stack by adding an auto-healing ingest controller that restarts stalled/error sources,
v2.12.6
2025-12-01
Fixed (2)
  • Added a differential RBDS symbol slicer so FM demodulation correctly reconstructs PI/PS/RadioText metadata and keeps the latest
  • Hardened the SoapySDR receiver implementation by mapping stream error codes (including SOAPY_SDR_NOT_LOCKED) to descriptive
v2.12.5
2025-11-30
Changed (1)
  • Disabled the CAP poller's optional SDR capture orchestration by default so its RadioManager hooks stay idle unless the poller
v2.12.4
2025-11-29
Fixed (1)
  • Forced OLED templates with manually positioned lines to default to no-wrapping in the renderer so preview cards and physical
v2.12.3
2025-11-29
Fixed (1)
  • 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 reserved` failure during upgrades.
v2.12.2
2025-11-29
Fixed (2)
  • 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_data` before persisting it to PostgreSQL so upgrades no longer crash with `can't adapt type 'dict'` errors.
v2.12.1
2025-11-27
Changed (2)
  • 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.
v2.12.0
2025-11-27
Added (2)
  • 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 (2)
  • 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.
v2.11.7
2025-11-18
Changed (3)
  • Added a refresh-status meta block on the dashboard map card that now shows the last update time, refresh source, and a live
  • Replaced the fixed interval timer with a scheduler that pauses during manual refreshes, resumes after success or failure, and
  • Updated the dashboard refresh action so manual, automatic, keyboard, and debug triggers all share the same code path,
v2.11.6
2025-11-23
Changed (1)
  • Default location snapshots now seed `area_terms` with an empty list rather than mirroring the removed environment variable,
v2.11.5
2025-11-23
Fixed (1)
  • Removed the CAP poller's area-term fallback so alerts only appear on `/alerts` when their SAME or UGC codes match the
v2.11.4
2025-11-22
Fixed (1)
  • Fixed duplicate DOM element declarations on the Weekly Test Automation page that threw JavaScript errors and prevented saved
v2.11.3
2025-11-21
Fixed (1)
  • Ensured the RWT scheduler always opens a Flask application context before touching the
v2.11.2
2025-11-20
Added (2)
  • Added an offline alert self-test harness plus `scripts/run_alert_self_test.py` so operators can replay bundled RWT captures,
  • Folded the alert self-test harness into the **Tools → Alert Verification** dashboard so operators can replay bundled or custom
Changed (1)
  • Consolidated the alert self-test workflow into the Alert Verification dashboard so operators validate decoding, analytics,
v2.10.0
2025-11-18
Added (8)
  • Added comprehensive `utilities.css` with 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.py` utility 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
Fixed (3)
  • 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
Changed (5)
  • Updated `base.html` template 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.py` to 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
v3.20.2 Current
Added (42)
  • Clarified the commercial license offer notes pricing covers software only and excludes any hardware costs.
  • Extended `/api/system_status` and `/api/system_health` with hostname, primary IPv4, uptime, and primary-interface metadata
  • 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
  • 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.py` with luma.oled-based controller, new `OLED_*` environment variables, and runtime initialization hooks
  • Extended screen renderer, manager, and `/api/screens` endpoints with an `oled` display type alongside LED and VFD rotations
  • Updated admin Environment editor, setup wizard, and hardware reference docs for OLED installation and configuration guidance
  • ...32 more
Fixed (96)
  • Removed caching from `/api/audio/metrics` and set explicit no-store headers so VU meters and live audio telemetry refresh in
  • Hardened backup API endpoints by validating backup names to block path traversal before
  • Removed the CAP poller's area-term fallback so `/alerts` only surfaces entries that explicitly name the configured SAME or
  • Ensured the continuous EAS monitor auto-initializes on demand so the audio monitoring page no longer stalls when the monitor
  • Added comprehensive audio ingest pipeline for unified capture from SDR, ALSA, and file sources
  • Implemented `app_core/audio/ingest.py` with pluggable source adapters and PCM normalization
  • Added peak/RMS metering and silence detection with PostgreSQL storage
  • Built web UI at `/settings/audio-sources` for source management with real-time metering
  • Exposed configuration for capture priority and failover in environment variables
  • Documented the Weekly Test Automation county list regression addressed in 2.11.4 so QA can trace the scheduler fix through the
  • ...86 more
Changed (13)
  • 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_type` and `stream_url` fields 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/audio` instead of deprecated `/audio/sources` route
  • Clear separation of concerns: Radio = RF hardware, Audio = all audio ingestion sources
  • Enhanced AGENTS.md with bug screenshot workflow, documentation update requirements, and semantic versioning conventions
  • ...3 more
v2.9.0
2025-11-15
Added (2)
  • OLED alert rotations now preempt normal playlists when `skip_on_alert` is enabled, prioritizing the most severe alert and
  • `/api/alerts` now returns each alert's source and (when available) the cached EAS narration text, allowing custom OLED/LED
v2.8.0
2025-02-15
Fixed (42)
  • Prevented the `20251113_add_serial_mode_to_led_sign_status` Alembic migration from
  • Added an offline pyttsx3 text-to-speech provider so narration can be generated without
  • Authored dedicated `docs/reference/ABOUT.md` and `docs/guides/HELP.md` documentation 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.
  • Documented open-source dependency attributions in the docs and surfaced
  • Inserted the mandatory display-position byte in LED sign mode fields so M-Protocol
  • Surface offline pyttsx3 narration failures in the Manual Broadcast Builder with
  • Detect missing libespeak dependencies when pyttsx3 fails and surface
  • Detect missing ffmpeg dependencies and empty audio output from pyttsx3 so the
  • Surface actionable pyttsx3 dependency hints when audio decoding fails so
  • ...32 more
Changed (11)
  • Documented why the platform remains on Python 3.12 instead of the new Python 3.13 release across the README and About surfaces,
  • 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-bookworm` base.
  • 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.
  • ...1 more
v2.7.5
2025-11-15
Fixed (1)
  • Allow first-time deployments to create the initial administrator from a dedicated
v2.7.2
2025-11-15
Fixed (1)
  • Restore SDR audio monitor adapters on-demand for all audio ingest APIs, eliminating the recurring 503 responses and broken
v2.7.1
2025-11-15
Fixed (1)
  • Backfill SDR squelch columns automatically when legacy deployments haven't run the
v2.7.0
2025-11-14
Added (1)
  • 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 (1)
  • 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.
v2.4.16
2025-11-10
Fixed (1)
  • Removed the `APP_BUILD_VERSION` environment override so persistent `.env` files can no longer pin stale release numbers; the UI now always reflects the repository `VERSION` manifest.
v2.4.15
2025-11-10
Fixed (2)
  • Ensured the version resolver invalidates its cache when `APP_BUILD_VERSION` or the `VERSION` file changes so dashboards display
  • Disabled caching on the built-in documentation viewer routes to prevent browsers and reverse proxies from serving outdated
v2.4.14
2025-11-10
Fixed (1)
  • 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).
v2.4.11
2025-11-09
Fixed (2)
  • 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.
v2.4.1
2025-11-09
Fixed (3)
  • **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
v2.3.12
2025-11-15
Fixed (1)
  • Hardened admin location validation so statewide SAME/FIPS codes are always accepted and labelled consistently when saving.
v2.3.11
2025-11-14
Fixed (1)
  • Fixed admin location settings so statewide SAME/FIPS codes remain saved when operators select entire states.
v2.3.10
2025-11-03
Changed (1)
  • Reformatted SAME plain-language summaries to omit appended FIPS and state code
v2.3.9
2025-11-03
Changed (1)
  • Display the per-location FIPS identifiers and state codes on the Audio Archive
v2.3.8
2025-11-02
Fixed (1)
  • Backfilled missing plain-language SAME header summaries when loading existing
v2.3.7
2025-11-02
Changed (1)
  • Linked the admin location reference summary and API responses to the bundled
v2.3.6
2025-11-02
Added (1)
  • Added an admin location reference API and dashboard card that surfaces the saved
v2.3.5
2025-11-01
Fixed (1)
  • Prevented the public forecast zone catalog synchronizer from inserting duplicate
v2.3.3
2025-11-13
Changed (1)
  • 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.
v2.3.2
2025-11-02
Changed (1)
  • The web server now falls back to a guarded setup mode when critical
v2.3.1
2025-11-01
Added (1)
  • Added one-click backup and upgrade controls to the Admin System Operations panel, wrapping the existing CLI helpers in background tasks with status reporting.
v2.1.9
2025-10-31
Added (1)
  • Delivered a WYSIWYG LED message designer with content-editable line cards, live colour/effect previews,
Changed (2)
  • Refactored the LED controller to accept structured line payloads, allowing nested colours, display modes,
  • Enhanced the LED send API to normalise structured payloads, summarise mixed-format messages for history
v2.1.8
2025-10-30
Fixed (1)
  • Inserted the mandatory display-position byte in LED sign mode fields so M-Protocol
v2.1.7
2025-10-29
Changed (1)
  • Updated ignore rules and documentation so generated EAS artifacts and runtime logs remain outside
v2.1.6
2025-10-28
Changed (2)
  • Aligned build metadata across environment defaults, the diagnostics endpoints, and the
  • Refreshed the README to highlight core features, deployment steps, and configuration
v2.1.5
2025-10-27
Added (5)
  • Added database-backed administrator authentication with PBKDF2 hashed passwords,
  • Expanded the admin console with a user management tab, dedicated login page, and APIs
  • Introduced `.env.example` alongside README instructions covering environment setup and
  • Implemented the EAS broadcaster pipeline that generates SAME headers, synthesizes WAV
  • Published `/admin/eas_messages` for browsing generated transmissions and downloading
Changed (2)
  • Switched administrator password handling to Werkzeug's PBKDF2 helpers while migrating
  • Extended the database seed script to provision `admin_users`, `eas_messages`, and
v2.1.4
2025-10-26
Added (4)
  • Persisted configurable location settings with admin APIs and UI controls for managing
  • Delivered a manual NOAA alert import workflow with backend validation, a reusable CLI
  • Enabled editing and deletion of stored alerts from the admin console, including audit
  • Broadened boundary metadata with new hydrography groupings and preset labels for water
Changed (1)
  • Hardened manual import queries to enforce supported NOAA parameters and improved error
v2.1.0
2025-10-25
Added (3)
  • Established the NOAA CAP alert monitoring stack with Flask, PostGIS persistence,
  • Delivered the interactive Bootstrap-powered dashboard with alert history, statistics,
  • Integrated optional LED sign controls with configurable presets, message scheduling,
v2.2.0
2025-10-29
Added (2)
  • Recorded the originating feed for each CAP alert and poll cycle, exposing the source in the
  • Normalised IPAWS XML payloads with explicit source tagging and circle-to-polygon conversion
Changed (2)
  • Automatically migrate existing databases to include `cap_alerts.source` and
  • Surfaced poll provenance in the statistics dashboard, including the observed feed sources
v2.3.4
Added (1)
  • Documented the public forecast zone catalog synchronisation workflow and
v2.3.0
2025-10-30
Changed (3)
  • Normalized every database URL builder to require `POSTGRES_PASSWORD`, apply safe
  • Trimmed duplicate database connection variables from the default `.env` file and
  • Bumped the default `APP_BUILD_VERSION` to 2.3.0 across the application and sample
v2.4.9
2025-11-09
Fixed (2)
  • Switch certbot issuance to standalone HTTP-01 mode so the container itself binds to port 80 during startup,
  • Log the standalone challenge server activation so operators can confirm ACME connectivity when debugging
v2.4.8
2025-11-09
Fixed (2)
  • 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.
v2.4.7
2025-11-09
Fixed (2)
  • Detect existing certificates issued by anything other than Let's Encrypt (including legacy self-signed chains)
  • Extend the certificate cleanup routine to treat unknown issuers as invalid, guaranteeing that deployments replace
v2.4.6
2025-11-09
Fixed (2)
  • Remove any lingering self-signed certificate directories (including suffixed variants) on
  • Extend the certificate purge routine to clean historical self-signed material before certbot
v2.4.5
2025-11-09
Fixed (2)
  • Purge the domain's existing `/etc/letsencrypt` material whenever a self-signed
  • Force certbot to request a fresh certificate for self-signed domains by
v2.4.4
2025-11-09
Fixed (2)
  • Detect legacy self-signed fallback certificates by inspecting the existing fullchain.pem and
  • Remove invalid certificate files prior to issuing new ones so nginx never launches with the
v2.4.3
2025-11-09
Fixed (2)
  • Detect previously generated self-signed certificates and automatically retry Let's Encrypt
  • Tag self-signed fallbacks with a marker file and clear it after successful issuance to avoid
v2.4.2
2025-11-09
Fixed (2)
  • Provision certbot in the nginx container via Python's package manager so Let's Encrypt
  • Replaced bash-specific `[[ ... ]]` usage in the nginx initialization script with

Installed Features & Software Stack

Live availability for hardware-dependent features comes from the runtime probe at startup. Core software features are always present in this build. The footer of every page shows the full version-pinned dependency stack.

Alerting Core

CAP Alert Monitoring
NOAA / NWS / IPAWS feeds
EAS SAME Encoder / Decoder
Header, attention tone, EOM
PostGIS Spatial Filtering
County / zone polygon match
CAP Forwarding & Webhooks
Outbound integrations

Hardware Integrations

LED Signs (Alpha 9120C)
Not configured
VFD Display
Not configured
OLED Display (SSD1306)
Not configured
SDR Radio Receiver
Not detected
GPIO Control
Raspberry Pi rail
GPS / Stratum-1 NTP
gpsd + chrony PPS refclock

Audio & DSP

Icecast Streaming
FM / AM mounts over HTTP
Text-to-Speech
eSpeak NG
Audio Decode
FFmpeg + pydub (MP3/AAC/OGG)
ALSA Output
Available
PulseAudio Output
Available
SciPy DSP Filters
FIR / IIR / notch
Numba JIT Demod
SAME / RBDS JIT enabled

Security & Auth

RBAC
Role-based access control
MFA (TOTP)
PyOTP + QR enrolment
Tamper-Evident Audit Log
Ed25519 + SHA-256 hash chain
TLS / HTTPS
Nginx + Let's Encrypt

Notifications & Dashboards

Twilio SMS
Alert + health notifications
Live WebSocket Updates
Flask-SocketIO + gevent
Analytics Dashboard
Chart.js time-series
Leaflet Maps
Alert polygons & coverage

Deployment

systemd Services
eas-station, sdr_hardware_service, hardware_service, gps_manager
Raspberry Pi Compatible
Reference deployment target
PostgreSQL + PostGIS
Primary store, Alembic migrations
Redis
Pub/sub, cache, rate limit

Software Stack (pinned versions)

Canonical dependency list, kept in sync with requirements.txt by tests/test_tech_stack_badges.py. Hover any badge for a one-line description of how that library is used inside EAS Station.

Feature Status

Hardware-dependent rows reflect the live startup probe — see Admin → Hardware Settings to enable or reconfigure devices. Software stack versions above are the build-time pins; the running Python interpreter, host platform, and hostname are reported on the System Info tab.

System Information

Version Details

System Name
EAS Station
Version
3.20.2
Author
EAS Station, LLC (KR8MER) / KR8MER Amateur Radio Emergency Communications
Description
Emergency alert system for Putnam County, OH

Git Information

Commit Hash
781ae60a
Branch
main
Commit Date
2026-09-19T04:16:04Z
Message
Add HSTS preload flag + Certbot nginx re-sync action (#2664)

Time Information

Timezone
America/New_York
Local Time
2026-09-19T02:14:03.610920-04:00
UTC Time
2026-09-19T06:14:03.610900+00:00

Runtime Environment

Python
3.13.5
Platform
Linux-6.12.100+deb13-amd64-x86_64-with-glibc2.41
Hostname
ohc137

Runtime Feature Probe

LED Signs
Not Available
VFD Display
Not Available
OLED Display
Not Available
SDR Radio
Not Available
ALSA Audio
Available
PulseAudio
Available
MFA (TOTP)
Available
SciPy DSP
Available
Numba JIT
Available

JSON API Response

This data is also available in JSON format at /version for programmatic access.

JSON Output
{
  "version": "3.20.2",
  "name": "EAS Station",
  "author": "EAS Station, LLC (KR8MER) / KR8MER Amateur Radio Emergency Communications",
  "description": "Emergency alert system for Putnam County, OH",
  "timezone": "America/New_York",
  "led_available": false,
  "vfd_available": false,
  "oled_available": false,
  "radio_available": false,
  "alsa_available": true,
  "pulse_available": true,
  "mfa_available": true,
  "scipy_available": true,
  "numba_available": true,
  "python_version": "3.13.5",
  "platform": "Linux-6.12.100+deb13-amd64-x86_64-with-glibc2.41",
  "hostname": "ohc137",
  "timestamp": "2026-09-19T06:14:03.610900+00:00",
  "local_timestamp": "2026-09-19T02:14:03.610920-04:00"
}