Commit Graph
1083 Commits
Author SHA1 Message Date
agessaman fcb10eaa7e Merge remote-tracking branch 'origin/fix/weather-multiday-days' into feat/wx-gwx-convergence
# Conflicts:
#	CHANGELOG.md
2026-10-01 22:26:08 -07:00
agessaman 988c0caeb3 Merge remote-tracking branch 'origin/feat/gwx-keep-coordinates' into feat/wx-gwx-convergence
# Conflicts:
#	CHANGELOG.md
2026-10-01 22:22:34 -07:00
agessaman bdd9ea92cb Merge remote-tracking branch 'origin/fix/nws-alerts-per-location' into feat/wx-gwx-convergence 2026-10-01 22:22:27 -07:00
agessaman cf91210394 fix(geocoding): keep a zero coordinate on the state-qualified city lookups
A review found two more paths in geocode_city_sync still testing coordinates for truthiness: the lookup with a state parsed from the query and the [Weather] default_state fallback. A place on the equator or prime meridian found there (or cached) was thrown away, so the reply fell through to later lookups or failed.
2026-10-01 22:20:44 -07:00
agessaman 151cf0b78a fix(wx): take today from the location's clock, and date undated periods from their neighbors
A codex review found two holes in using the first NOAA period's date as today: at 1 AM the first period can still be the night that began the evening before, so tomorrow and multi-day started a day early; and one period without startTime switched the forecast to date grouping and then dropped that period (multi-day showed Friday night's low as Friday). Today is now the current instant in the UTC offset NOAA writes on its periods; an undated period takes its neighbor's date (same date for a night after a day, else the next). With the characterization clock at Thursday afternoon the drop-period variants start on Friday again, as before this PR; against the base branch only Miami's seven-day forecast (a seventh date) and its already-started hourly entry differ for NOAA.
2026-10-01 21:59:50 -07:00
agessaman 65ca8ff164 fix(geocoding): accept a zero coordinate throughout city geocoding
A codex review of this PR found that geocode_city_sync (and its async and step-generator forms) still tested coordinates for truthiness in ten places, so a country-qualified lookup that found a place on the equator or prime meridian was discarded and the fallback queries tried instead; wx's own None check never saw it. They now test for None. The new test drives geocode_city_sync itself with only the first lookup succeeding, which fails before this change. The CHANGELOG entry's lookup count is corrected.
2026-10-01 21:55:26 -07:00
agessaman 987df9b124 fix(weather): scope NWS 'no coverage' to the point that got it, not the whole bot
A no-coverage reply (400/404) from the NWS alerts API set a single flag on wx and on the weather service that skipped every later alert request for the rest of the run, so one wx request for a city abroad silently removed alerts from every US reply after it. NWSNoCoverageCache remembers no-coverage per point rounded to two decimals, for 24 hours, at most 256 points (least recently used dropped first); a successful response clears its point, and the warning is logged once per point. The weather service polls one position, so for it the change means a retry after 24 hours instead of never.
2026-10-01 21:31:38 -07:00
agessaman 70972d1d4d refactor(weather): keep solarforecast's enabled check and the names local plugins may use
A codex review of this port found solarforecast relying on the enabled_attr hook, which dev's BaseCommand does not have, so [Solarforecast_Command] enabled = false stopped working; its can_execute override is back as on dev. It also found names an earlier refactor commit had dropped as unused, which local plugins could still read: WxCommand.forecast_duration, num_wx_alerts, use_metric, zulu_time and geolocator, geolocator on gwx and solarforecast, the WXSIM_PARSER_AVAILABLE and WX_INTERNATIONAL_AVAILABLE flags, and the _mqtt_weather_error_key hook (now a WeatherCommandMixin method that _mqtt_weather_line calls through self, so an override takes effect again). tests/unit/test_weather_compat_surface.py pins all of them.
2026-10-01 21:27:56 -07:00
agessaman 8874c54a67 fix(weather): date forecasts by the location's own times, not the bot's clock
wx took "today" from the bot's clock: on a server running UTC, or answering for a city in another zone, tomorrow and multi-day started a day late, and hourly compared NOAA's local wall-clock times with the bot's after dropping their offsets, keeping hours that had passed. wx now takes today from the forecast's first period date and compares hourly times as absolute times; multi-day groups NOAA periods by their start date, which also keeps holiday-named periods ("Columbus Day") the weekday-name grouping skipped. Name-based grouping remains the fallback when periods have no startTime. gwx chose Today/Tonight and the multi-day weekday labels from the bot's clock; it now uses Open-Meteo's location-local current time and daily dates. gwx also asked for N days counting today and then skipped today, so 7d showed six days; it now asks for N+1 (capped at Open-Meteo's 16). The NOAA characterization clock gains its time zone, so aware comparisons do not depend on the machine running the tests; the regenerated goldens move only where these fixes apply (drop-period variants whose first period is a later day, Miami's already-started 6 PM hour, gwx Today/Tonight and weekday labels, and gwx's forecast_days request parameter).
2026-10-01 21:24:03 -07:00
agessaman 94e6387eba feat(weather): forecast the sender's own point in gwx, and accept zero coordinates
gwx with no location reverse-geocoded the companion's (or bot's) coordinates to a town name and then forecast that name: three Nominatim lookups, and a forecast for wherever the geocoder put the town rather than for the sender. It now passes the coordinates through, as wx does; the reply's label still comes from the one reverse lookup in geocode_location. Both commands write those coordinates in fixed decimals (`_coordinates_query`), since str() of a small float gives "1e-05", which the coordinate pattern does not match. The companion and repeater position queries in modules.location skipped any zero latitude or longitude; only 0,0 is the no-position sentinel, so they now exclude just that. wx's city lookup used truthiness and rejected a geocoded zero coordinate.
2026-10-01 21:17:16 -07:00
agessaman 5601a126dd refactor(weather): port the behavior-neutral weather refactor from refactor/maintainability
This brings the weather slices of my long-running refactor branch to dev ahead of converging wx and gwx, so the convergence PRs build on one copy of the code instead of rewriting it twice.

- `modules/weather_common.py` (WeatherCommandMixin): code wx and gwx shared by copy (MQTT and WXSIM source lookup, multi-day sending, high/low formatting, the forecast-option parser, the Open-Meteo model loader).
- `modules/nws_alerts.py`: one NWS alert entry parser for wx and the weather service, keeping each caller's own Special Weather Statement rules.
- `modules/http_retry.py`: one retrying session builder for the weather HTTP clients.
- wx: get_noaa_weather, the hourly, tomorrow and multi-day formatters, _add_period_details and the extract_* readers broken up or made table-driven; NOAA fetches go through `_noaa_fetch`.
- gwx: one fetch path; `_open_meteo_current` and `_open_meteo_daily_tail` split out of get_open_meteo_weather.
- solarforecast: `_format_forecast` built from one timestamp parser and one day builder (418 → 155 lines).
- aurora, rain, wx, gwx: companion and bot location lookups through `modules.location` (which gains optional logging arguments).

The changes outside these files that the refactor branch made (the base command's enabled_attr hook, the service base's run_periodic and _cancel_tasks) stay out: aurora and rain keep their can_execute overrides and the weather service its own loops, exactly as on dev.

Tests: characterization goldens over real captured NOAA, NWS, Open-Meteo and forecast.solar responses (tests/characterization, fixtures in tests/fixtures), 50 tests and several hundred cases, all of which also pass against dev's unmodified weather code. Each refactor step was additionally checked on the branch with an old-vs-new differential run (tens of thousands of generated inputs, including log calls and exceptions) and reviewed by codex.
2026-10-01 21:11:23 -07:00
Adam Gessaman 8d28ac637e Merge pull request #321 from agessaman/fix/gwx-null-forecast-days
fix(gwx): end multi-day forecasts at Open-Meteo's null days instead of failing
2026-10-01 20:12:47 -07:00
agessaman 9b04791dff fix(gwx): end multi-day forecasts at Open-Meteo's null days instead of failing
Open-Meteo always returns the requested number of days but fills the ones past the model's horizon with nulls: the last one or two with the default model, nine of sixteen with icon_seamless. format_multiday_forecast called int() on them, so the whole reply became the multi-day error (gwx 15d for London and Reykjavik on 2026-10-01; gwx 7d under icon_seamless). It now stops at the first day without a high and low. The tomorrow forecast and the default reply compared a null precipitation probability with 30, which raised too; a null probability now just leaves the precipitation out, and a tomorrow with null temperatures reports that it is not available.
2026-10-01 19:27:49 -07:00
Adam Gessaman 6dfd02711a Merge pull request #320 from agessaman/fix/wx-wind-direction
fix(wx): show NOAA's 16-point wind directions instead of truncating them
2026-10-01 18:40:58 -07:00
agessaman 09cf1f3e64 Merge remote-tracking branch 'origin/dev' into fix/wx-wind-direction 2026-10-01 18:40:49 -07:00
Adam Gessaman dc11733aa0 Merge pull request #319 from agessaman/fix/bot-tx-spacing
fix(send): stop dropping follow-up reply parts, and space concurrent transmissions
2026-10-01 18:40:48 -07:00
agessaman b7783ba443 Merge remote-tracking branch 'origin/dev' into fix/bot-tx-spacing
# Conflicts:
#	CHANGELOG.md
2026-10-01 18:40:39 -07:00
Adam Gessaman c430c06dca Merge pull request #317 from agessaman/fix/dm-ack-timeout-nan
fix(dm): treat a NaN dm_min_ack_timeout as unset
2026-10-01 18:40:38 -07:00
agessaman 9ffdd49b7f Merge remote-tracking branch 'origin/dev' into fix/dm-ack-timeout-nan 2026-10-01 18:40:29 -07:00
Adam Gessaman c42e515d0c Merge pull request #314 from agessaman/fix/atomic-migrations
fix(db): apply migrations atomically
2026-10-01 18:40:27 -07:00
agessaman 6971a9faf8 Merge remote-tracking branch 'origin/dev' into fix/atomic-migrations
# Conflicts:
#	CHANGELOG.md
2026-10-01 18:40:14 -07:00
Adam Gessaman 1c5e82ab79 Merge pull request #311 from agessaman/docs/region-scopes
docs: add a region scopes page
2026-10-01 18:40:05 -07:00
Adam Gessaman e8f3cbe401 Merge pull request #309 from agessaman/fix/region-warning-channel-scope
fix(region-warnings): send channel warnings at the bot's configured scope
2026-10-01 18:40:01 -07:00
agessaman 55e5f0eda3 fix(wx): show NOAA's 16-point wind directions instead of truncating them
abbreviate_wind_direction matched full words (NORTHWEST, NORTH, ...), but NOAA's forecast windDirection is always an abbreviation like WNW, SSE or N. Every value fell through to the fallback, which kept the first two characters behind a generic wind emoji, so replies read '💨WN2' or '💨SS1' and even plain N and NE lost their arrow. The 16 compass abbreviations now return the nearest 8-point arrow plus the abbreviation; full words and other values behave as before. Found while pinning wx's formatting on real NOAA responses for the maintainability refactor.
2026-10-01 15:53:04 -07:00
agessaman 4d16764f1e Merge remote-tracking branch 'origin/dev' into fix/dm-ack-timeout-nan
# Conflicts:
#	CHANGELOG.md
2026-10-01 15:46:26 -07:00
agessaman c00e9de3e3 Merge remote-tracking branch 'origin/dev' into fix/atomic-migrations
# Conflicts:
#	CHANGELOG.md
2026-10-01 15:46:25 -07:00
agessaman 90ef72355a Merge remote-tracking branch 'origin/dev' into fix/region-warning-channel-scope
# Conflicts:
#	CHANGELOG.md
2026-10-01 15:46:24 -07:00
agessaman 4e26175054 Merge remote-tracking branch 'origin/dev' into fix/bot-tx-spacing
# Conflicts:
#	CHANGELOG.md
2026-10-01 15:37:44 -07:00
agessaman b49181342d fix(send): two more dropped follow-ups, and drop a pre-wait that now double-waits
Review found the same reply-limiter drop in two more places: the earthquake service's link post right after its alert, and the announcements command's 'sent' confirmation right after the announcement. Both now skip the reply limiter. multitest called wait_for_tx itself just before send_response, which waits again; with wait_for_tx claiming the slot that pre-wait made the reply wait a full extra interval, so it goes. A test that only documented RateLimiter's behavior, and guarded nothing, is replaced by an earthquake test that drives _check_earthquakes.
2026-10-01 15:26:21 -07:00
Adam Gessaman 9027c2aef5 Merge pull request #318 from agessaman/fix/service-restart-loop
fix(services): back off services that don't start, and unsubscribe on stop
2026-10-01 15:11:44 -07:00
agessaman 1d749d3847 fix(send): stop dropping follow-up reply parts, and space concurrent transmissions
A user who raised bot_tx_rate_limit_seconds to 5 noticed wx's weather-then-alerts reply did not seem to honor it. The gap between those two messages was in fact longer than the limit (every send already waits on the bot TX limiter in _check_rate_limits, and wx sleeps limit + 1 s on top), but looking at it turned up two real bugs.

The second part of wx's and gwx's two-part reply, and every part after the first of 'alert all', went through the reply rate limiter (rate_limit_seconds, 10 s by default) a few seconds after the first part had used it, so it was refused: with default settings the alert message was never sent. These now pass skip_user_rate_limit=True, as the other multi-part senders already did. The repeater command's two-part branch gets the same change for consistency.

BotTxRateLimiter.wait_for_tx only checked the time since the last recorded transmission, and record_tx runs after the radio confirms, so concurrent senders (two replies, a reply and a scheduled message) all passed the wait before any of them recorded and transmitted back to back. wait_for_tx now claims the slot under the lock when it returns. A coverage test that monkeypatched can_tx to drive the old loop is rewritten against the new one.
2026-10-01 15:01:29 -07:00
agessaman 4bdb1ef186 fix(services): keep restarted services logging, and stop a taken port from killing the bot
A second review of the restart fix found more of the same class. The map uploader's stop() removes its file log handler and start() never added it back, so after one restart it logged nothing to file; start() now restores it. DARC MoWaS set _running before binding its port, so a port in use left it dead but healthy, and werkzeug's make_server calls sys.exit when the port is taken, which got past every except Exception and stopped the bot; start() now binds first and turns that exit into an OSError.

Boot-time starts go through _start_service_at_boot, which records a restart failure when start() raises, so that service also waits out the backoff, and no longer reports a disabled service as a configuration problem. The health-loop step is _restart_unhealthy_services. New tests cover both, the bridges' session cleanup and the map uploader restart; all 13 lifecycle tests fail on dev.
2026-10-01 14:33:56 -07:00
Adam Gessaman b72e848827 Merge pull request #313 from agessaman/fix/radio-offline-latch
fix(core): let radio-offline state clear while sends are suppressed
2026-10-01 11:49:16 -07:00
agessaman 3ff24a93f6 fix(services): back off a service that does not start instead of retiring it
Review found that marking a not-running service inactive for good also retired services that only returned early for a transient reason: packet capture and the map uploader give up after waiting 30 s for the radio, and the bridges return when meshcore is momentarily unset, which is exactly when the health loop restarts them. Such a service now records a restart failure, so the next attempt waits out service_restart_backoff_seconds; a transient failure recovers, a missing configuration costs one attempt per backoff instead of one every 5 s. The restart decision is _service_restart_due, which the tests now exercise directly.

Also from the review: packet capture and the map uploader never cleared the should_exit flag stop() sets, so after a restart their background loops exited at once while the service reported healthy; start() now clears it. The Discord and Telegram bridges close the HTTP session they open when they return early because meshcore is not available.
2026-10-01 11:47:47 -07:00
agessaman c4f7c8c603 docs(scheduler): say how a False result settles a trial 2026-10-01 11:40:39 -07:00
agessaman 8631d2f8bb fix(dm): treat a NaN dm_min_ack_timeout as unset
configparser's getfloat accepts 'nan', and NaN fails every comparison, so it passed both the negative-value check and the min() cap and reached send_msg_with_retry as the ACK wait. It now falls back to the default like a negative value. 'inf' was already capped at 60 s; both cases are in the parametrized test, and the nan case fails on dev.
2026-10-01 11:39:32 -07:00
agessaman 3e31612dd8 test(core): sort the imports in the cancellation test 2026-10-01 11:30:11 -07:00
agessaman 4c4f65d73f fix(core): a trial that does not succeed waits for the next probe, and its settlement survives cancellation
A fifth review found two gaps. A scheduled trial that returned False was re-armed at once, but False also follows exhausted no_event_received retries, so the radio could be tried again and again without another answered probe. And a reply's success settlement ran in a worker thread that a cancelled caller could cancel while it was still queued, stranding the trial in flight.

_end_offline_trial ends a trial that went out without succeeding (False or an exception, from a reply or a scheduled send) under the state lock alone; only paths where nothing reached the send still re-arm. The success settlement is shielded, so it completes even if the caller is cancelled. The cancellation test queues the settlement behind a busy one-worker executor and fails without the shield.
2026-10-01 11:29:44 -07:00
agessaman a3b0f56639 fix(services): stop restarting unconfigured services, and unsubscribe on stop
The health loop restarted every enabled service whose is_healthy() was False. A service that returns from start() without running because it has nothing to do (Discord or Telegram with no channels, the map uploader with no key) is never healthy, and its restart raised nothing, so it counted as a success and cleared the backoff: the service was restarted every 5 seconds, logging a warning each time.

Services whose start() returns without running are now recorded as inactive, logged once, and skipped by the health loop. A restart that leaves a service running but unhealthy records a failure, so the next attempt waits out service_restart_backoff_seconds.

Restarts also double-subscribed: services subscribed to meshcore events in start() and never unsubscribed (the comments said meshcore cleans up; it does not). BaseServicePlugin gains _subscribe and _unsubscribe_all; the Discord, Telegram, map uploader, packet capture and prefix-collision services subscribe through it, unsubscribe in stop(), and drop the old instance's subscription before re-subscribing after a transport reconnect. Tests use meshcore's real EventDispatcher and fail on dev.
2026-10-01 11:25:18 -07:00
agessaman 49960e50bf fix(core): let a reply take the offline trial, so bots without schedules recover too
Only scheduled messages and interval adverts could claim the trial armed by an answered health probe, so a bot with neither stayed offline however healthy the radio became. send_dm and send_channel_message now claim an armed trial when offline and run the send through _send_as_offline_trial, under the trial's identity; its result settles the trial (True clears the outage, False or an exception fails it until the next answered probe), so a dead radio still gets at most one attempt per probe. Settlement runs in a worker thread because it can write bot_metadata. Tests drive the real CommandManager guard, and the alert-thread counting in the recovery tests no longer stops asyncio.to_thread's workers.
2026-10-01 11:18:21 -07:00
agessaman 43ca23b30f fix(core): only the current trial can clear an outage, and an expired trial is cancelled
A third review found four more gaps. A trial send that hit the 60 s wait kept running and could transmit later, during a newer trial; _record_send_success cleared any outage without checking whose success it was, so an old send finishing late could clear a newer one; the wrappers claimed the trial before logging and time lookup, outside the code that settles it; and the startup advert's timeout path recorded its failure on the event loop, where tripping the outage writes bot_metadata.

_record_send_success now takes the trial id and clears only when that trial is still in flight, deciding and resetting under the state lock (_reset_offline_state_locked); success of an ordinary send while offline leaves the outage alone. A trial whose wait fails is cancelled. Each wrapper settles the trial if anything after admission raises. The startup advert records its failure through asyncio.to_thread.
2026-10-01 10:57:46 -07:00
agessaman cf2c2f47ee Merge remote-tracking branch 'origin/dev' into fix/radio-offline-latch
# Conflicts:
#	CHANGELOG.md
2026-10-01 09:36:23 -07:00
agessaman 5e35098a36 fix(core): give each offline trial an identity and settle it on every exit
A second review found five gaps. A loop that closed between is_running() and submission stranded the trial in flight; fallback sends (no running main loop) settled every trial as inconclusive; a timed-out send that kept running passed the guard during a later trial, because the context flag was a bare True; the viewer clear checked the outage generation, released the lock, then cleared, so it could clear a newer outage; and the health loop ran the metadata sync, with its database retries, on the event loop.

Trials now carry an id: _admit_measured_send returns it, _as_offline_trial sets it in the context, and is_radio_offline and the settle methods only honor the trial currently in flight. The scheduler's two wrappers share _run_measured_send, which submits inside its error handling (closing the coroutine if submission fails), settles a fallback trial by its real outcome, and settles any trial left open in a finally. _clear_radio_offline_state takes an expected generation and checks it under the state lock. The health loop checks the 30 s throttle on the loop and runs the sync itself in a worker thread. The delayed-write test now waits for the newer outage to trip before releasing the stalled writer, so it fails without publish serialization.
2026-10-01 09:33:22 -07:00
Adam Gessaman 401125277a Merge pull request #315 from agessaman/fix/repeater-geocode-off-loop
fix(repeater_manager): reverse-geocode in a worker thread, not on the event loop
2026-10-01 09:22:00 -07:00
agessaman e79e7ada77 Merge remote-tracking branch 'origin/dev' into fix/repeater-geocode-off-loop 2026-10-01 09:15:24 -07:00
Adam Gessaman 28827d8624 Merge pull request #316 from agessaman/test/stats-perf-timing
test(dashboard): judge /api/stats speed by the fastest of five requests
2026-10-01 09:15:21 -07:00
agessaman 473d0d6a22 fix(core): admit exactly one trial send while radio-offline and settle it by its real outcome
Review of the circuit breaker found four problems. The trial was a shared flag that let every sender through, interactive sends never settled it, and it could stay open forever. The scheduler threw away send_channel_message's result, so a send the radio rejected counted as a success and cleared the outage. Metadata writes happened outside the state lock, so a stalled clear could overwrite a newer outage's 'true' and the viewer check would then cancel it. And one failed read-back of the persisted 'true' disabled the viewer clear for the rest of the outage.

Now a probe arms one trial, which the scheduler's next measured send (scheduled message or interval advert) claims atomically through _admit_measured_send; that send runs under a context flag so only it passes the send-path offline guards. Its real outcome settles it: success clears the outage, a timeout ends the trial until the next answered probe, and a False result or nothing sent re-arms it. _send_scheduled_message_async now returns whether the message went out. Metadata is published under its own lock from the current state, confirmed per outage generation, and retried from the health loop until confirmed. The in-process viewer clear calls the full clear. Tests go through the real scheduler on a live loop, race admission across threads, stall a clear write while a new outage trips, and count alerts from the first trip.
2026-10-01 09:15:00 -07:00
agessaman e02b198eb1 test(dashboard): judge /api/stats speed by the fastest of five requests
The threshold test timed one request, so a single GC pause or a busy CI runner could fail it. On the Python 3.10 runner it failed four times in a row at 0.22 to 0.26 s on a branch that does not touch the stats path, while the same request takes about 22 ms locally on both that branch and dev, with or without coverage. It now times five requests and asserts on the fastest, which an algorithmic regression still slows, and the failure message lists every timing.
2026-10-01 09:03:17 -07:00
agessaman b6538de760 Merge remote-tracking branch 'origin/dev' into fix/repeater-geocode-off-loop
# Conflicts:
#	CHANGELOG.md
2026-10-01 08:31:15 -07:00
Adam Gessaman 0173b4a52b Merge pull request #310 from agessaman/fix/dm-ack-timeout-floor
fix(dm): wait at least dm_min_ack_timeout for each DM attempt's ACK
2026-10-01 08:27:26 -07:00