422 Commits
Author SHA1 Message Date
agessaman f5f3b6e52a fix(web_viewer): review fixes for plugin settings coverage and template previews
Fixes from a Codex review of the previous commit, plus the shared settings the rebase onto dev showed were still missing.

Settings that misstated or changed what runs:
- sports never read [Sports_Command] api_timeout (documented in the example config); the new field did nothing. It now sets the ESPN timeout.
- The path-byte requirement help on test, multitest and path said "bytes per hop"; the gate checks the path's total size (at least 2, or exactly 3).
- feed's allow_private_urls showed false while it inherited true from [Feed_Manager]. Fields can now name an inherit_section whose value they show when unset.
- notify_on_prefix_bytes was an int field for a value the service reads as a list (2,3), so the form refused a valid setting.
- The greeter's form defaults differed from the fallbacks it uses when the keys are missing. Both now come from shared constants.
- The MQTT weather limits accepted values the service clamps (payload 256 to 1 MiB, staleness 5 s to 7 days, length 64 to 4000).

Shared settings: the wx card now carries the [Weather] temperature, wind and precipitation units and the three temperature_*_format templates (with previews through format_temperature_high_low), next to wx's own [Wx_Command] unit overrides, which default to "Same as [Weather]". Two fields with one key in different sections are told apart as key@Section in the save payload; a bare key still resolves when only one field has it. An enum's "" option, like a blank number, removes the key. Packet capture brokers gain username, password, topic prefix, JWT timings and include_decoded; blank inheriting values are left out of block saves, since the service reads them with getint/getboolean when present.

Previews:
- They use the bot's saved config: a blank test format falls back to [Keywords] test, multitest lays out paths per condense_paths, and MQTT applies passthrough_max_length.
- A quoted empty template ("") counts as blank, as the commands strip the quotes first.
- A str.format width or precision over 500 isn't rendered, so `{sender:100000000}` can't make the endpoint build a huge string.
- The page invalidates an in-flight preview as soon as the field changes, and shows the server's note about fallbacks instead of guessing it.

The endpoint inventory golden gains the preview route (401 for anonymous requests when a password is set).
2026-10-04 15:55:17 -07:00
agessaman 7758403148 feat(web_viewer): cover every plugin setting, with a template reference and preview
The Plugins page now has a field for every setting the plugins read. I compared each plugin's settings_schema against config.ini.example and the keys the code reads; fourteen plugins had gaps (greeter, trace, stats, path, wx/gwx, rain, test, multitest, sports, worldcup, cmd, feed, the webhook, weather, packet capture, earthquake and prefix-collision services) and the World Cup service had no form at all. Webhook URLs, bot tokens and channel keys use the password field. A guard test needed four [Rain_Command] keys documented in config.ini.example.

Template fields get a { } button that opens a reference of the placeholders they take, with click-to-insert. Fields rendered by the piped engine (test's response_format, path's reply_prefix) also list the filters and conditionals. Every template field previews as you type: piped fields against a 2-byte, a 1-byte and a direct sample message, so a conditional's effect shows before saving, and the str.format fields (greeter, mesh info, multitest, MQTT weather) as their plugin renders them, including what it sends when the template has a mistake. The specs and renderers live in modules/template_reference.py; the preview endpoint only renders and never saves. The greeter's channel_greetings parser and multi-part split moved into module functions so the command and the preview share them.

Fixes the new fields would otherwise have exposed:
- Clearing a number field saved `key =`, which makes getint/getfloat raise and the plugin fail to load. A cleared number now removes the key.
- The weather service raised in __init__ when only some blitz_area_* corners were set. It now needs all four and logs a warning otherwise.
- A blank MQTT weather json_template saved an empty template, so every reply said no message had arrived. Blank now saves the default.
2026-10-04 15:41:45 -07:00
agessaman 6468ae03d4 Merge dev (#344) into feat/wx-openmeteo-fallback
Conflict in wx_command.py: the refactor split _get_weather_for_location_sync into _locate_*, _noaa_forecast_body and _with_noaa_alerts. The fallback now checks coverage after _locate_* and when _noaa_forecast_body returns its fetch error. _openmeteo_weather takes no address_info: a city's label comes from city_to_lat_lon again, answered from the geocoding cache the first lookup filled.
2026-10-04 11:20:00 -07:00
agessaman 0e7b9fef0f feat(wx): optional Open-Meteo fallback for places outside NWS coverage
[Weather] openmeteo_fallback (default false, only with weather_provider = noaa) answers wx from Open-Meteo when NWS reports a point as outside its coverage (HTTP 400/404 from /points), in gwx's format and labels and wx's units. Timeouts and server errors keep today's error. The point goes into the existing NWS no-coverage cache, so later requests skip NWS for 24 hours. wx <place> alerts there replies that alerts are not available.

gwx's per-point body moves into _weather_for_point so wx can reuse it without geocoding twice; gwx's output is unchanged.
2026-10-04 08:28:02 -07:00
agessaman fd1e8d1926 refactor(commands): read the help method's own signature, not the one functools.wraps copies
Codex on f0649558: inspect.signature follows __wrapped__, so an operator-installed command under modules/commands that wraps PingCommand.get_help_text with a get_help_text(self) override looked like it accepted the message, was called with it, and raised where dev retried and returned its help. _accepts_message now uses follow_wrapped=False. New test covers that override. No bundled get_help_text is decorated.
2026-10-03 23:51:32 -07:00
agessaman f0649558e8 refactor(commands): direct help call only when the method also accepts the message
Space bunny on d60e7cf1: operators may install their own command files inside modules/commands or modules/commands/alternatives (documented, and preserved across upgrades), so a class under modules. is not necessarily bundled. Such a command written as get_help_text(self) was treated as bundled, called with the message and raised, so help returned nothing where dev returned its text. help_text_for now calls once with the message only when the method comes from a class under modules. and its signature binds the message; otherwise, including when the signature can't be read, it uses dev's call-then-retry. New test: an operator command in the modules.commands namespace with get_help_text(self) gets its help.
2026-10-03 23:47:56 -07:00
agessaman dabb59b823 refactor(commands): help retry re-reads get_help_text; a reused bundled function stays bundled
Codex on d60e7cf1 found two more edges where dispatch differed from dev: the retry called the captured method instead of reading command.get_help_text again, so a local plugin that replaced its help callable before raising got the original back; and a local class that reuses a bundled function (get_help_text = PingCommand.get_help_text) was classified as local, so a TypeError inside the bundled body ran it twice. The retry reads the attribute again, and a function counts as bundled when any bundled class in the MRO holds it in its __dict__. Both reproductions now match dev.

Accepted: a local override that forwards *args to a bundled get_help_text now passes the message through (the bundled method accepts it), and adapters that pass a message to a formerly no-message bundled method now succeed instead of raising.
2026-10-03 23:26:00 -07:00
agessaman d60e7cf135 refactor(commands): tell bundled help methods apart by their defining class, not __module__
Codex on ba293bb2: functools.wraps copies __module__, so a local subclass that wraps PingCommand.get_help_text with a no-message override looked bundled and lost the retry it needs (it raised instead of returning its help), and callables with odd __module__ values could fail or be misclassified. help_text_for now treats a method as bundled only when it is a plain bound method of this command whose function is defined, per the command class's MRO, by a class whose own __module__ is under modules. Everything else (local classes and overrides, partials, Mocks, other callables) gets dev's call-then-retry. New test: a wrapped local override of PingCommand still gets its help.

Accepted residual, besides joke and rain: an instance-level wrapper (functools.partial, Mock(wraps=...)) around a bundled get_help_text whose body raises TypeError now runs the body twice, since the bundled method accepts the message.
2026-10-03 23:19:00 -07:00
agessaman ba293bb262 refactor(commands): keep the help TypeError retry for local plugins; fix two doc claims
Codex on 495df80e: choosing the help call by signature also dropped the TypeError retry for local plugins written as get_help_text(self, message=None), which used to fall back to generic help when the message call raised, and inspect.signature itself can raise TypeError on odd callables and then take the wrong branch. help_text_for now decides by where get_help_text is defined: bundled commands (defined under modules.) are called once with the message, so an error from their body propagates as it did before 7aba432b; anything else gets exactly the old call-then-retry. Tests pin both: a bundled body's TypeError runs once, a local plugin's is retried without the message. joke and rain stay the accepted residual (no retry for a TypeError inside their help bodies).

Codex also found two inaccuracies in b569ed5d's docs: ChannelBridgeBase subclasses must also supply _open_http_session and _init_queues, and an empty [Keywords] format means no reply.
2026-10-03 22:57:26 -07:00
agessaman 2a506bc327 refactor(weather): one reader for the Open-Meteo unit settings
S7's units item. GlobalWxCommand and WeatherService each read [Weather] temperature_unit, wind_speed_unit and precipitation_unit, lowercased them and fell back to fahrenheit/mph/inch with the same three warnings; weather_common.load_open_meteo_units does that once, next to load_open_meteo_model. Both still accept kn. wx keeps its own NOAA unit reading (_unit_setting), which has different fallbacks and sources. Old-vs-new over 5,000 random configs matched on values and warnings.
2026-10-03 22:50:52 -07:00
agessaman 495df80e2f refactor(commands): choose the help call by signature instead of retrying on TypeError
Codex found that after 7aba432b a TypeError raised inside one of the 40 newly widened help bodies reached the retry in help and command_manager, so the body ran a second time (and could then succeed); before, the no-message call ran it once and the error propagated. Both call sites now go through base_command.help_text_for(command, message), which binds the message against get_help_text's signature first: commands that accept it get get_help_text(message), local plugins written as get_help_text(self) get get_help_text(), and an error from the body propagates after one call. A callable with no introspectable signature keeps the old try-and-retry.

Accepted residual difference: joke and rain already took the message, and a TypeError raised inside their help bodies used to be retried without it; it now propagates. help's no-message test now uses a real get_help_text(self) instead of a Mock that raised TypeError, and a new test pins the single call.
2026-10-03 22:43:24 -07:00
agessaman 647202bc5b refactor: get_running_loop() where get_event_loop() was called inside a coroutine
Phase 8's mechanical part. The 19 asyncio.get_event_loop() calls that run directly in an async def (found by walking each file's AST; calls inside nested sync functions or lambdas don't count) become asyncio.get_running_loop(). Inside a running coroutine both return the same loop; get_running_loop says so and never creates one. The five calls in sync functions (maintenance.run_data_retention, radio_link._handle_serial_probe_error, scheduler._run_in_current_loop and run_scheduler, weather_service._run_daily_forecast) can run on threads without a loop, where the two differ, so they stay. No test patches get_event_loop.
2026-10-03 22:37:37 -07:00
agessaman 2c971875c1 refactor(commands): share the pubkey ACL check and the [Keywords] response format
S4. _check_admin_access and announcements' _check_announcements_access ran the same pubkey validation and logging, differing only in wording; both now call BaseCommand._pubkey_in_acl(message, acl, kind, denied), which builds each log line from those two words, so every line keeps its exact text and level. The admin check still runs it inside its try, announcements outside, as before.

ping and magic8 had identical get_response_format overrides reading [Keywords] <name>; a keywords_format_key class attribute on BaseCommand now does that, and commands without one still get None. typing.Optional stays importable from both modules.

Old-vs-new over 20,000 randomized configs and senders (missing, malformed, mixed-case and listed keys; missing sections; quoted and empty formats) matched on results and log calls; three mutations were each caught.
2026-10-03 22:34:11 -07:00
agessaman 7aba432b54 refactor(commands): every bundled get_help_text takes message=None
S4. BaseCommand and the 40 bundled overrides that took no argument now take `message: MeshMessage | None = None`, matching joke and rain, which already use the message. None of the 40 bodies read it, so their help text is unchanged. help and command_manager keep their TypeError retry, since local plugins may still implement get_help_text(self). For bundled commands the retry no longer runs: `help <command>` calls get_help_text(message) once instead of failing and calling it again. A new test pins the signature for every bundled command class. The offline smoke run against the real config produces the same help text for all 46 commands.
2026-10-03 22:28:45 -07:00
agessaman 596a369204 refactor(location): one sender-position and one repeater-name query for every command
S6. The sender's latest position (airplanes, path, test) and the repeater-or-room-server lookup by name (prefix, solarforecast) were each pasted into the commands, alongside location.py's own copies for get_companion_lat_lon and lookup_repeater_lat_lon. They now run through location.latest_contact_position_rows and location.repeater_by_name_rows. The copies differed only in how they drop a zero coordinate, so that is a zero_rule parameter: "both" (location.py's helpers: drop only 0,0) or "either" (the commands: drop a fix with either coordinate zero), and each caller passes the rule it had. Each command keeps its private wrapper, its pubkey access, try/except, log text and value conversion; airplanes' _get_bot_location now calls get_bot_lat_lon, which is the same code.

I did not move the commands onto resolve_location: its labels and geocoding rules differ from what each command does today. Old-vs-new over 20,000 randomized cases against a real SQLite table (zero coordinates, ties, near-duplicate names, missing pubkeys, database errors, no db_manager) matched on results and log calls for all eight functions; four mutations were each caught.
2026-10-03 22:24:48 -07:00
agessaman ba8e3f0229 fix(refactor): restore 24 module-level names the moves stopped binding, and guard all of them
Codex's final review found that moving code out of modules dropped imports that local plugins could import from them, for example `from modules.service_plugins.discord_bridge_service import EventType`, which would make such a plugin fail to load. Comparing every top-level name bound on dev against the branch (both trees imported, so names under TYPE_CHECKING are excluded) found 24 in 11 modules: EventType, copy and contextlib in both bridges; sha256, Iterable and hmac_mod in message_handler; RotatingFileHandler, colorlog, contextlib, contextvars and functools in core; hashlib and re in utils; contextlib in feed_manager, earthquake_service and weather_service; Any in cmd and help; time in path. Each is re-imported exactly as dev imported it.

The public-import guard only covered `from modules... import` statements, so it missed these. It now records every unconditional top-level import and assignment, regenerated from dev's tree (461 → 1,597 names), and fails if the discord bridge stops exposing EventType.
2026-10-03 22:13:19 -07:00
agessaman 793767581d refactor(gwx): move _open_meteo_current's visibility conversion and extra conditions into helpers
_visibility_miles converts Open-Meteo's visibility from feet or meters; _open_meteo_extra_conditions builds the dew point, visibility and pressure strings. Both bodies and the rest of _open_meteo_current are AST-identical to the original statements, called at the same points, so translate calls and log lines keep their order. The Open-Meteo characterization passes unchanged.
2026-10-03 20:50:19 -07:00
agessaman 4bcc152bca refactor(wx): move execute's alert-only geocoding into _send_alert_list_for
The "wx <place> alerts" branch (geocode coordinates, zipcode or city, reply with the error or the full alert list) moves verbatim into one async helper; execute calls it inside the same try. The helper body and the rest of execute are AST-identical to the original, and the execute dispatch characterization (which covers the alert inputs for each location type) passes unchanged.
2026-10-03 20:49:11 -07:00
agessaman d7c4704bf6 refactor(wx): skip the city prefix for an unrecognized location type instead of discarding it
Codex found that 3c5f31ec built the city prefix for an unrecognized location_type and then threw it away, so a geocoder returning a string subclass whose formatting raises turned a forecast into an error reply. _locate_city now takes labeled and skips the prefix entirely, as the original did. The old-vs-new fuzz now includes that case: 30,000 cases, 0 differences.
2026-10-03 20:48:06 -07:00
agessaman 97b6d20e89 refactor(wx): split get_noaa_weather's current-period line and Tomorrow append into helpers
_noaa_current_summary builds the current period's line with its details, UV and precipitation; _append_noaa_tomorrow appends the Tomorrow period. Both bodies are statement-for-statement the original code (checked by AST comparison), and an old-vs-new run over 30,000 perturbed real NOAA forecasts (fixtures in tests/fixtures/noaa, varied units, budgets, names, temperatures, wind, fetch failures and JSON errors) matched on replies, fetch calls and log calls.
2026-10-03 20:42:00 -07:00
agessaman 3c5f31ecbb refactor(wx): split _get_weather_for_location_sync into location, forecast and alert steps
_locate_coordinates, _locate_zipcode and _locate_city each return the error reply (or None), the coordinates and the location prefix; _noaa_forecast_body fetches and formats one forecast type; _with_noaa_alerts builds the default reply and its two-message alert tuple. An unrecognized location_type still geocodes as a city without the city prefix, as before. Old-vs-new over 30,000 randomized cases (fake geocoders, NOAA fetches and alert results, including exceptions and error sentinels) matched on replies, call traces and log calls; four mutations were each caught.
2026-10-03 20:27:18 -07:00
agessaman 13b1ff5092 refactor(gwx): split geocode_location and _format_location_display into named steps
geocode_location now dispatches to _geocode_coordinates, _geocode_zipcode and _geocode_city inside its original try, so a failure in any of them still logs and returns four Nones. _format_location_display takes its city from _display_city and its last-resort label from _fallback_location_label. Bodies moved verbatim; an old-vs-new run over 30,000 label cases and 20,000 geocode cases (faked lookups that succeed, miss or raise) found no difference in results or log lines.
2026-10-03 17:47:48 -07:00
agessaman e76383f257 refactor(weather): wx and gwx execute share their dispatch steps
The custom MQTT and WXSIM replies, the no-location fallback chain (sender's position, default_city, bot's position) and the reply tail (two-part alerts, multiday split, single message) move into WeatherCommandMixin. Each command keeps its own WXSIM fetch (_fetch_wxsim) and wx keeps naming fallback positions in its log lines (_fallback_place_name). The no-location MQTT reply still never splits a multiday answer. The execute dispatch goldens are unchanged.
2026-10-03 17:24:44 -07:00
agessaman e359d8d01f Merge origin/dev (#335, #336, #338, #341) into refactor/maintainability
command_manager: _record_transmission passes extra keyword arguments through, so #336's recipient_key (DMs) and channel_idx (channels) reach record_transmission as on dev.

graph_trace_helper: dev's rewrite from #336, with its two repeater-key lookups (immediate neighbor and _unique_repeater_key) going through contacts_repo.unique_recent_repeater_key, whose queries are identical.
2026-10-03 09:41:56 -07:00
agessaman 4c1f55236e Merge remote-tracking branch 'origin/dev' into fix/transmission-tracker
# Conflicts:
#	CHANGELOG.md
2026-10-03 09:13:11 -07:00
agessaman 0ad9f4e69a fix(transmission_tracker): read the bot's prefix from the radio's reported key
bot_prefix and graph_trace_helper's bot key read meshcore.device, which meshcore_py doesn't have, so both were always None and update_mesh_graph_from_trace_data skipped every trace. Both now come from meshcore.self_info's public_key (device stays a fallback), and bot_prefix is read lazily because the tracker is built before the radio connects. The helper names the bot at the trace's hash width, so the last hop's edge matches the hops' own width, and marks 2-byte edges with prefix_bytes=2.

trace_command read update_graph_two_byte but never used it; 2-byte traces now follow it instead of update_graph_one_byte.
2026-10-02 18:32:54 -07:00
agessaman b569eb8dd7 fix(wx): keep a 0° Tomorrow period in the default NOAA reply
get_noaa_weather's Tomorrow part tested period.get('temperature', '') for truthiness, so a forecast of exactly 0 was treated as missing and the part was left out. It now uses _has_temp, as _noaa_period_str already does for Today and Tonight.
2026-10-02 18:02:34 -07:00
agessaman 0c30cdfcb3 refactor(wx): append NOAA's Today and Tonight periods through _append_noaa_period
After #334 the two blocks were identical, so get_noaa_weather calls one helper for both. Tomorrow keeps its own thresholds inline. An old-vs-new run over 30,000 generated NOAA forecasts (log calls included) shows no difference; a threshold mutation in the helper shows 1,021.
2026-10-02 17:59:10 -07:00
agessaman 4dce714e0d Merge remote-tracking branch 'origin/dev' into refactor/maintainability 2026-10-02 17:33:57 -07:00
agessaman 308e95f159 refactor: restore AqiCommand.geolocator and MeshCoreBot.last_clock_sync_time
The S1 dead-code slice dropped both because nothing in the tree reads them, but local plugins may (codex review of the dev merge). set_radio_clock records the sync time again after a successful update. Two DEBUG diagnostics S1 removed with dead code stay removed: parse_advert's flags line behind handler.debug and the overwritten contact-path lookup lines.
2026-10-02 16:44:22 -07:00
agessaman f3dc49a677 refactor(wx): find the NOAA follow-up periods in _noaa_followup_periods
get_noaa_weather's Today/Tonight/Tomorrow lookup moves verbatim into its own method, which returns the three periods and the two night flags the rest of the method reads. The moved statements and the remaining body are AST-identical to the original.
2026-10-02 16:33:00 -07:00
agessaman e05df87a59 fix(wx): leave out a NOAA Tonight period without a temperature instead of failing the reply
get_noaa_weather built the Tonight part only when _noaa_period_str returned one, but the detail and append steps after it ran either way. With no temperature or short forecast, period_str was unbound and the whole reply became "Error fetching weather data"; after a night-time Today part it still held that part, which was appended a second time. The steps now sit under the same check, as in the Today and Tomorrow blocks.
2026-10-02 16:31:31 -07:00
agessaman a10baf6de2 Merge origin/dev (#322, #323, #333) into refactor/maintainability
#323 ported this branch's weather slices to dev and #333 built the wx/gwx convergence on them, so dev's wx, gwx, weather_common and location are a superset of the branch's: I took dev's side there, with the characterization goldens #333 regenerated. The rest:

- core.py: kept the template file and carried dev's knots option into modules/templates/default_config.ini.
- utils.py: kept the shared _geocode_city_steps and applied dev's zero-coordinate fix (is not None instead of truthiness) to its seven checks.
- weather_service.py: kept run_periodic/_cancel_tasks and took dev's per-point NWS coverage cache.
- solarforecast: restored self.geolocator, which #323 kept for local plugins and the merge would have dropped.
2026-10-02 16:26:53 -07:00
agessaman 7159169eb3 Merge remote-tracking branch 'origin/dev' into feat/wx-gwx-convergence
# Conflicts:
#	CHANGELOG.md
2026-10-02 16:03:06 -07:00
agessaman f994171818 feat(weather): [Weather] always_show_location names the place in every wx/gwx reply
wx and gwx name the place only when it adds information: a city outside default_state or default_country, or a position. always_show_location (default false) names every place a lookup finds instead, including cities in the bot's own state and ZIP codes.

For a ZIP code, gwx now does a reverse lookup when the option is on (it skipped that since the name was never shown), and wx reuses the reverse lookup it already did for a companion position. A failed lookup leaves the reply unnamed rather than repeating the ZIP code. The prefix comes out of the message budget as before. The option is in the wx settings schema for the web viewer, the example config, the command reference and the changelog.
2026-10-02 07:55:18 -07:00
agessaman 66ce785e6c Merge remote-tracking branch 'origin/dev' into fix/unicode-digit-crash
# Conflicts:
#	modules/commands/alternatives/wx_international.py
#	modules/commands/wx_command.py
2026-10-02 07:19:44 -07:00
agessaman 9959cc33fc fix(gwx): moon for clear night hours in the hourly forecast
The hourly forecast still showed ☀️ for clear hours after dark. I now request Open-Meteo's hourly is_day with the hourly forecast and pick each hour's emoji from it, falling back to 06:00–18:00 local when it's absent, as the current conditions do.
2026-10-02 07:12:38 -07:00
agessaman 17a93104cc fix(gwx): label current conditions by is_day, moon at night, name tomorrow's weather
At 5:58 am wx 98258 (Open-Meteo provider) replied "Tonight: ☀️Clear … | H:68°F L:50°F | Tomorrow: 🌫️ H:67°F L:51°F". Any hour before 06:00 was labeled "Tonight", which next to today's high/low and a "Tomorrow" for the following day read like last night's forecast, and code 0 always showed a sun.

I now request Open-Meteo's is_day and use it for both the label and the emoji, falling back to 06:00–18:00 local when it's absent. Night before noon is "Overnight" (the NWS name for that period, new key in every catalog), night after noon stays "Tonight". Clear and mostly clear at night show 🌙. Tomorrow's segment adds its description when it fits and keeps the emoji-only form otherwise.

Goldens change for the new request field, Overnight on the fixtures captured after midnight, 🌙 on clear night fixtures, and tomorrow's description.
2026-10-02 07:04:52 -07:00
agessaman 9c055030c7 fix(weather): alerts as an option alone, option-named sources, alert list send status
A codex re-review of the last two commits confirmed their fixes and found three gaps. wx alerts (and gwx alerts) with no location looked up a place called "alerts"; the option now applies to the fallback location like the others. A custom source named like an option (custom.wxsim.hourly or custom.mqtt_weather.hourly) was treated as the option and answered from the default source; a configured name now wins. The full alert list (wx <place> alerts) kept sending after a refused first message and reported success; it now stops and reports the refusal.
2026-10-02 01:31:37 -07:00
agessaman 1536f2a634 fix(weather): codex review of the whole branch
- A scheduled {cmd:gwx ...} or {cmd:wx ...} with a forecast and an alert slept out the TX pacing between them during capture; with bot_tx_rate_limit_seconds = 30 that passed the 30 s render timeout and the whole reply was lost. Pacing is skipped when the message has a capture_sink.
- gwx tomorrow ignored its byte budget (a Russian Reykjavik forecast was 99 bytes against 89). It now drops the precipitation amount, gusts, wind, the precipitation chance and the description, in that order. The Open-Meteo goldens give tomorrow runs a roomy budget, since their key-name translator makes the text much longer than real; trimming is tested with real catalogs in test_gwx_tomorrow_budget.
- Custom default sources (bare wx/gwx) still returned True after a refused send.
- An option without a location (wx hourly, gwx tomorrow, wx 5d) showed usage, since the fallback location was resolved before the option was parsed; the option now applies to the fallback, custom default sources included. The docs say how far each provider's forecast reaches.
- wx read its unit settings through get_config_value, whose [Weather] fallback logged a notice to move them into [Wx_Command] on every lookup (14 per reply); _unit_setting reads [Wx_Command], then [Weather], without it.
- NOAA forecasts that mention thunderstorms after sun ("Sunny then Slight Chance Showers And Thunderstorms") still got ☀️; thunderstorms now outrank sun.
- The catalog tests passed with keys removed, since a missing key differs from English; they now require each key and every wind point, with en-GB and Polish allowed to use the English letters.
2026-10-02 01:10:39 -07:00
agessaman 0853739f30 fix(weather): review follow-ups: warning units, wx localization, help text
A space bunny review of this branch's commits found:
- gwx's high-wind warning converted the speed to mph for every catalog, so on a km/h bot it read 49 next to a reply saying 80, and Polish (whose template said km/h) regressed in the merge. The threshold stays 30 mph; the warning shows the speed in the configured unit with a {unit} placeholder in every catalog.
- tomorrow_not_available, tomorrow_error, multiday_not_available and multiday_error were still English in seven catalogs; they are translated, and the catalog test now fails on any weather reply left in English.
- wx kept NOAA's English wind letters and English weekday letters while gwx's are translated, so one German bot showed NE and NO for the same wind. wx now translates both through the catalog (commands.wx.day_abbrev added); English output is byte-for-byte unchanged, which the goldens show (their stub translator gives English for these short labels, via tests.helpers.key_name_translate).
- Polish feels_like was ungrammatical (odczuwalna -> odczuwalnie), Russian's used a nonstandard abbreviation, and Russian gwx's MQTT subscriber error lacked wx's hint.
- The help text (commands.wx.description/usage, commands.gwx.usage) described wx as ZIP-only; it now lists cities, coordinates and the options, under 130 bytes in every language.
- The generated default config, the docs' default_state wording, _fit_tomorrow_parts' docstring and the CHANGELOG's stats claim were inaccurate.
2026-10-02 00:48:31 -07:00
agessaman 5b50229223 feat(weather): accept knots as the wind unit in wx, gwx and WXSIM
The weather service took [Weather] wind_speed_unit = kn, while wx and gwx rejected it with a warning and used mph, so the same setting gave knots in one and mph in the other. Knots now work in all of them: Open-Meteo is asked for kn, NOAA's mph and km/h values and station gusts are converted, gwx's high-wind warning converts back to mph for its threshold, and the WXSIM parser converts its km/h. The web config's wind unit choices gain m/s and knots.
2026-10-01 23:35:48 -07:00
agessaman dbe4ac7028 feat(gwx): name the place only when it adds information, as wx does
gwx prefixed every reply with the resolved place. It now follows wx's policy: a city is named when it resolved to another country than [Weather] default_country or, in the US, to another state than default_state (or no default_state is set); coordinates are named when the reverse lookup found a place; a ZIP code is not named. A failed reverse lookup for coordinates used to turn the input into a label like "47.60620, -122.33210" (the longitude standing in for a country); such replies now have no label. The ZIP path drops its reverse-geocoding request, whose only use was the label.
2026-10-01 23:23:14 -07:00
agessaman 3d86b70899 fix(wx): fit NOAA's tomorrow forecast into its byte budget
format_tomorrow_forecast took max_length and never used it, so two long periods (Miami's "Chance Showers And Thunderstorms" day and night) came to 132 bytes against a 100-118 byte budget and the send path split the reply in two. It now tries the full reply, then without the night period's wind, without any wind, the first period alone, and that period without wind. Measured over the captured NOAA forecasts with the real English catalog at budgets 100-235, nothing overruns now (15 of 384 did); gwx and wx's default reply already fit. The regenerated goldens differ only in tomorrow keys, whose key-name translations are longer than real text.
2026-10-01 23:17:47 -07:00
agessaman a2f0d4c22c fix(weather): a refused first message ends the reply
Follow-up parts of a wx or gwx reply skip the per-user rate limit, since the limiter already let the first part through. When it had not (rate-limited, or the send failed), the alert after the forecast and the later parts of a multi-day forecast still went out on their own. Now a refused first part ends the reply. The multi-day sender also sent a forecast that fits one message only after dropping blank lines as a continuation, skipping the limit; the first message now always goes through it. execute returns whether the reply was sent, which the stats and web viewer record.
2026-10-01 23:12:47 -07:00
agessaman da6e699008 fix(wx): read NOAA's precipitation, gust and below-zero wording
NOAA writes "Chance of precipitation is 60%." and "with gusts as high as 25 mph", and none of the precipitation or gust patterns allowed either, so the 🌦️ and 💨 details never appeared for real forecasts (the captured Miami, Anchorage and Denver forecasts all carry them). High/low extraction took only unsigned numbers and rejected Fahrenheit values under 20, so "Low around -5." or a high of 14 lost the high/low. Both now cover NOAA's range, -65 to 130 °F or -55 to 55 °C. The regenerated goldens differ only by the newly read details, which at tight budgets now take the place of pressure.
2026-10-01 23:08:02 -07:00
agessaman 43c3006072 Merge remote-tracking branch 'origin/refactor/weather-port' into feat/wx-gwx-convergence 2026-10-01 23:00:39 -07:00
agessaman 66176a173b refactor(weather): keep wx and gwx's companion-location DEBUG lines
The codex review of this port also found that the shared companion lookup dropped the three DEBUG lines wx and gwx logged on dev (no sender key, no row for the key, the position found). get_companion_lat_lon takes a trace flag that wx and gwx pass; rain and aurora, which never logged these, stay quiet. With trace, a bot without a db_manager logs the lookup error at the caller's level again instead of returning silently.
2026-10-01 23:00:14 -07:00
agessaman 45f2512acb Merge remote-tracking branch 'origin/feat/gwx-render-safe' into feat/wx-gwx-convergence
# Conflicts:
#	CHANGELOG.md
2026-10-01 22:53:29 -07:00
agessaman 4895da3f77 Merge remote-tracking branch 'origin/fix/weather-wind-emoji' into feat/wx-gwx-convergence
Goldens conflicted; I regenerated them, and against this branch they differ only in NOAA emojis, gwx wind labels and the Phoenix width budgets the merged branch corrected. gwx's new hourly test expected the old truncating conversion (202.5° as SW); with nearest-point directions it is SSW.
2026-10-01 22:50:34 -07:00