diff --git a/CHANGELOG.md b/CHANGELOG.md index b386c3a..b38659e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,8 @@ semantic versioning. but this skips the attempt entirely. `poll_weather_alerts_interval` is ignored when it is off, and the toggle is exposed in the web viewer's Plugins page. -- The web viewer's Plugins page now covers every setting the plugins read, not just the older ones. New fields: `greeter`'s dead-air delay, defer-to-human and name fuzziness; `trace`'s base timeout, attempts and retry delay; `stats`' `collect_stats`; `path`'s `geographic_scoring_enabled`; `wx`/`gwx`'s `use_bot_location_when_no_location` (in `[Weather]`); `rain`'s window, threshold, amount, probability, temperature, cache and ZIP lookup options; the path-byte requirement for `test` and `multitest`; API timeouts for `sports` and `worldcup` plus `worldcup`'s cache time; `cmd_reference_url`; `feed`'s `allow_private_urls`; the webhook's `rate_limit_per_minute`; the weather service's rain-nowcast tuning and lightning watch box; packet capture's decoding, observer name and log rotation; `flood_scope`, external-only and Discord/Telegram targets for the earthquake, prefix-collision and World Cup services; and every World Cup service setting, which had no form at all. Webhook URLs, bot tokens and channel keys are masked like other secrets. -- Template fields on the Plugins page have a `{ }` button that opens a reference of the placeholders the field takes; clicking one inserts it at the cursor. It covers `test`'s `response_format`, `path`'s `reply_prefix`, the greeter's greeting, per-channel greetings and mesh-info format, `multitest`'s `response_format` and the MQTT weather `json_template`, and says which fields take placeholders only. For `test` and `path`, which take pipe filters, it also lists the filters (`hops_min`, `pathbytes_min`, `prefix_if_nonempty`, `if_nonempty`, `shorten`) with an example each. Every template field previews as you type, with each message's size in bytes. `test` and `path` render against three sample messages (a 3-hop 2-byte path, a 3-hop 1-byte path and a direct message), which makes a conditional's effect visible before saving, such as `{path_distance|prefix_if_nonempty: | Dist: }` printing `Dist: N/A` on a direct message. The other fields show what their plugin would do with a mistake: a misspelled placeholder in a greeting means no greeting at all, in the mesh-info format the greeting goes out without mesh info, in `multitest` the default format goes out instead, and in the MQTT weather template a format spec such as `{humidity:.0f}` fails the whole reply when the station leaves that reading out. Each greeting part and each channel's greeting is shown as its own message, and a `channel_greetings` fragment the greeter would skip is marked. A blank field previews what the plugin falls back to. Plugin authors can mark their own template fields with `template_spec` (see `docs/develop-command-scripts.md`). +- The web viewer's Plugins page now covers every setting the plugins read, not just the older ones. New fields: `greeter`'s dead-air delay, defer-to-human and name fuzziness; `trace`'s base timeout, attempts and retry delay; `stats`' `collect_stats`; `path`'s `geographic_scoring_enabled`; `wx`/`gwx`'s `use_bot_location_when_no_location` (in `[Weather]`); `rain`'s window, threshold, amount, probability, temperature, cache and ZIP lookup options; the path-byte requirement for `test` and `multitest`; API timeouts for `sports` and `worldcup` plus `worldcup`'s cache time; `cmd_reference_url`; `feed`'s `allow_private_urls`; the webhook's `rate_limit_per_minute`; the weather service's rain-nowcast tuning and lightning watch box; packet capture's decoding, observer name and log rotation; `flood_scope`, external-only and Discord/Telegram targets for the earthquake, prefix-collision and World Cup services; and every World Cup service setting, which had no form at all. The `wx` card now has the shared `[Weather]` temperature, wind and precipitation units (which also drive `gwx`, `rain` and the weather service) next to its own `[Wx_Command]` overrides, which default to "Same as [Weather]" and can be set back to it; the overrides used to show `fahrenheit`/`mph` even when unset. Packet capture brokers gain username, password, topic prefix, per-broker JWT timings and a per-broker "Publish decoded" that defaults to following the service-wide setting. The prefix-collision service's `notify_on_prefix_bytes` is a list, as the service reads it (`2,3`), instead of a single number that refused a list. `feed`'s `allow_private_urls` shows the `[Feed_Manager]` value it inherits when it has none of its own. The greeter's form shows the greeting and mesh-info format it actually uses when they are unset. Webhook URLs, bot tokens and channel keys are masked like other secrets. +- Template fields on the Plugins page have a `{ }` button that opens a reference of the placeholders the field takes; clicking one inserts it at the cursor. It covers `test`'s `response_format`, `path`'s `reply_prefix`, the greeter's greeting, per-channel greetings and mesh-info format, `multitest`'s `response_format`, the MQTT weather `json_template` and the `[Weather]` high/low temperature formats, and says which fields take placeholders only. For `test` and `path`, which take pipe filters, it also lists the filters (`hops_min`, `pathbytes_min`, `prefix_if_nonempty`, `if_nonempty`, `shorten`) with an example each. Every template field previews as you type, with each message's size in bytes. `test` and `path` render against three sample messages (a 3-hop 2-byte path, a 3-hop 1-byte path and a direct message), which makes a conditional's effect visible before saving, such as `{path_distance|prefix_if_nonempty: | Dist: }` printing `Dist: N/A` on a direct message. The other fields show what their plugin would do with a mistake: a misspelled placeholder in a greeting means no greeting at all, in the mesh-info format the greeting goes out without mesh info, in `multitest` the default format goes out instead, and in the MQTT weather template a format spec such as `{humidity:.0f}` fails the whole reply when the station leaves that reading out. Each greeting part and each channel's greeting is shown as its own message, and a `channel_greetings` fragment the greeter would skip is marked. A blank field previews what the plugin falls back to, including `[Keywords] test` for a blank `test` format, and previews use the saved settings that shape the output (multitest's path layout, the MQTT length limit). Plugin authors can mark their own template fields with `template_spec` (see `docs/develop-command-scripts.md`). ### Changed @@ -45,6 +45,7 @@ semantic versioning. - Place names in `wx`, `gwx` and other location replies are in the bot's `[Localization] language` (falling back to English) instead of each place's local language: `gwx Tokyo` named the place `千代田区, JP` rather than `Chiyoda, JP`, which also took more of the message. Nominatim was never told a language. `[Localization] place_name_language` changes it: `local` keeps each place's own name as before, and a language code picks another language. Reverse lookups are now cached per language, so addresses cached before in local languages are looked up again (unless `local` is set). - Reloading the config (from the web viewer, the `reload` command or the admin API) no longer fails when any value contains a bare `%`, such as `% RH` in an `[MqttWeather] json_template`, which `config.ini.example` documents as fine. The reload check expanded `%` interpolation on every value, although those values are read raw and startup accepts the same file, so every reload was rejected and changes saved from the web viewer only took effect after a restart. Such values are now named in a warning and the reload goes ahead. +- `sports` reads `[Sports_Command] api_timeout`. The example config documented it, but the ESPN timeout was fixed at 10 seconds. The Plugins page's MQTT weather payload, staleness and length limits now match the ranges the service clamps them to, so the form no longer accepts a value the service quietly replaces. - Saving a blank MQTT weather `json_template` on the Plugins page now saves the default template. It saved an empty one, so with `output_mode = json_template` every MQTT weather reply said no message had been received yet. - Clearing a number on the Plugins page now removes the setting instead of saving `key =`, which made the plugin fail to load (a blank float or int raises in configparser rather than falling back to the default). The weather service's lightning watch box also needs all four `blitz_area_*` corners now: with only some set it failed to start, and now it logs a warning and leaves lightning detection off. - The Telegram bridge no longer logs "Failed to stop service" at shutdown when it is enabled without an `api_token`. It disabled itself before setting up the state `stop()` uses, so stopping it raised `AttributeError`. The Discord bridge had the same gap when neither aiohttp nor requests is installed. diff --git a/docs/develop-command-scripts.md b/docs/develop-command-scripts.md index cc44422..2e765bd 100644 --- a/docs/develop-command-scripts.md +++ b/docs/develop-command-scripts.md @@ -227,6 +227,12 @@ settings_schema = [ **Supported types:** `bool`, `int`, `float`, `str`, `enum`, `list`, `password` +**Other field keys:** + +- `section`: read and write a shared section (such as `[Weather]`) instead of the plugin's own. A plugin can have the same key in its own section and a shared one; the form tells them apart. +- `inherit_section`: when the plugin's own section doesn't set the key, show the value it inherits from this section. +- An `enum` option with the value `""` means "not set": choosing it removes the key, so the plugin's own fallback applies. A blank `int` or `float` is removed the same way. + **Note:** `enabled` and `channels` do not need to be defined in the `settings_schema` as they get automatically included. diff --git a/modules/commands/feed_command.py b/modules/commands/feed_command.py index 9be8c11..cab9a75 100644 --- a/modules/commands/feed_command.py +++ b/modules/commands/feed_command.py @@ -30,6 +30,7 @@ class FeedCommand(BaseCommand): # Web-viewer settings schema (see modules/settings_schema.py). settings_schema = [ {"key": "allow_private_urls", "label": "Allow private URLs", "type": "bool", "default": False, + "inherit_section": "Feed_Manager", "help": "Accept feed URLs on private or internal addresses. Off guards against SSRF. " "Unset falls back to [Feed_Manager] allow_private_urls."}, ] diff --git a/modules/commands/greeter_command.py b/modules/commands/greeter_command.py index b26ac3d..157bf89 100644 --- a/modules/commands/greeter_command.py +++ b/modules/commands/greeter_command.py @@ -16,6 +16,11 @@ from ..template_reference import template_spec from ..utils import decode_escape_sequences from .base_command import BaseCommand +# What the greeter uses when the keys are missing; the settings form shows the same. +DEFAULT_GREETING = 'Welcome to the mesh, {sender}!' +# As written in config.ini: decode_escape_sequences turns the \\n into newlines. +DEFAULT_MESH_INFO_FORMAT = '\\n\\nMesh Info: {total_contacts} contacts, {repeaters} repeaters' + # A plausible channel name at the head of a ``channel_greetings`` entry: no # whitespace, no punctuation beyond the ones channels actually use. _CHANNEL_KEY_RE = re.compile(r"^#?[A-Za-z0-9][A-Za-z0-9_-]{0,31}$") @@ -97,12 +102,12 @@ class GreeterCommand(BaseCommand): # Web-viewer settings schema (see modules/settings_schema.py) settings_schema = [ {"key": "greeting_message", "label": "Greeting message", "type": "str", - "default": "Welcome to the mesh, @[{sender}]!", + "default": DEFAULT_GREETING, "help": "Default greeting. Separate multi-part messages with |.", "template": template_spec("format", ("sender",), notes=[ "| splits the greeting into separate messages.", "\\n starts a new line.", - ], blank_default="Welcome to the mesh, @[{sender}]!", preview="greeting")}, + ], blank_default=DEFAULT_GREETING, preview="greeting")}, {"key": "rollout_days", "label": "Rollout period", "type": "int", "min": 0, "default": 7, "unit": "days", "help": "Days the greeter rollout runs before auto-ending."}, @@ -120,7 +125,7 @@ class GreeterCommand(BaseCommand): "default": True, "help": "Append mesh statistics to the greeting."}, {"key": "mesh_info_format", "label": "Mesh info format", "type": "str", - "default": "", + "default": DEFAULT_MESH_INFO_FORMAT, "help": "Template for mesh info, appended to the last greeting part.", "template": template_spec("format", { "total_contacts": "Contacts ever heard", @@ -247,7 +252,7 @@ class GreeterCommand(BaseCommand): """Load configuration for greeter command.""" self.enabled = self.get_config_value('Greeter_Command', 'enabled', fallback=False, value_type='bool') self.greeting_message = self.get_config_value('Greeter_Command', 'greeting_message', - fallback='Welcome to the mesh, {sender}!') + fallback=DEFAULT_GREETING) # Decode escape sequences (e.g., \n for newlines) self.greeting_message = decode_escape_sequences(self.greeting_message) @@ -255,7 +260,7 @@ class GreeterCommand(BaseCommand): self.include_mesh_info = self.get_config_value('Greeter_Command', 'include_mesh_info', fallback=True, value_type='bool') self.mesh_info_format = self.get_config_value('Greeter_Command', 'mesh_info_format', - fallback='\n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters') + fallback=DEFAULT_MESH_INFO_FORMAT) # Decode escape sequences (e.g., \n for newlines) self.mesh_info_format = decode_escape_sequences(self.mesh_info_format) diff --git a/modules/commands/multitest_command.py b/modules/commands/multitest_command.py index 5fb075e..3cefad3 100644 --- a/modules/commands/multitest_command.py +++ b/modules/commands/multitest_command.py @@ -495,7 +495,7 @@ class MultitestCommand(BaseCommand): "help": "How collected paths are displayed."}, {"key": "require_path_bytes_greater_or_equal_to", "label": "Require path bytes ≥", "type": "int", "min": 0, "max": 3, "default": 0, - "help": "Only include paths with at least this many bytes per hop (3 = exactly 3). 0/1 = allow all."}, + "help": "Only respond when the path's total size is at least 2 bytes (2) or exactly 3 bytes (3), counting every hop. 0/1 = allow all."}, {"key": "require_path_bytes_failure_response", "label": "Path-byte reject reply", "type": "str", "default": "", "help": "Reply when rejected by the path-byte requirement. Empty = silent reject."}, diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py index a3c2f99..4dadb52 100644 --- a/modules/commands/path_command.py +++ b/modules/commands/path_command.py @@ -157,7 +157,7 @@ class PathCommand(BaseCommand): # Path-byte gating {"key": "require_path_bytes_greater_or_equal_to", "label": "Require path bytes ≥", "type": "int", "min": 0, "max": 3, "default": 0, - "help": "Only respond when the path has at least this many bytes. 0/1 = allow all."}, + "help": "Only respond when the path's total size is at least 2 bytes (2) or exactly 3 bytes (3), counting every hop. 0/1 = allow all."}, {"key": "require_path_bytes_failure_response", "label": "Path-byte reject reply", "type": "str", "default": "", "help": "Reply when rejected by the path-byte requirement. Empty = silent reject."}, diff --git a/modules/commands/sports_command.py b/modules/commands/sports_command.py index 49bc3e4..0cce01c 100644 --- a/modules/commands/sports_command.py +++ b/modules/commands/sports_command.py @@ -87,7 +87,7 @@ class SportsCommand(BaseCommand): bot: The MeshCoreBot instance that owns this command. """ super().__init__(bot) - self.url_timeout = 10 # seconds + self.url_timeout = self.get_config_value('Sports_Command', 'api_timeout', fallback=10, value_type='int') # Load enabled (standard enabled; sports_enabled legacy) self.sports_enabled = self.get_config_value('Sports_Command', 'enabled', fallback=None, value_type='bool') diff --git a/modules/commands/test_command.py b/modules/commands/test_command.py index 19fee09..3224082 100644 --- a/modules/commands/test_command.py +++ b/modules/commands/test_command.py @@ -56,6 +56,7 @@ class TestCommand(BaseCommand): ], samples={"path_distance": {"direct": "N/A"}, "firstlast_distance": {"direct": "N/A"}}, blank_default="ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}", + blank_config=("Keywords", "test"), )}, {"key": "distance_unit", "label": "Distance unit", "type": "enum", "options": [ @@ -67,7 +68,7 @@ class TestCommand(BaseCommand): "help": "Unit for {path_distance} and {firstlast_distance}."}, {"key": "require_path_bytes_greater_or_equal_to", "label": "Require path bytes ≥", "type": "int", "min": 0, "max": 3, "default": 0, - "help": "Only respond when the path has at least this many bytes per hop (3 = exactly 3). 0/1 = allow all."}, + "help": "Only respond when the path's total size is at least 2 bytes (2) or exactly 3 bytes (3), counting every hop. 0/1 = allow all."}, {"key": "require_path_bytes_failure_response", "label": "Path-byte reject reply", "type": "str", "default": "", "help": "Reply when rejected by the path-byte requirement. Empty = silent reject."}, diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index cc1d246..afeeb61 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -27,6 +27,7 @@ from ..location import get_bot_lat_lon, get_companion_lat_lon from ..models import MeshMessage from ..nws_alerts import WX_SPECIAL_RULES, entry_nws_headline, entry_summary, entry_title, parse_alert_fields from ..nws_coverage import NWSNoCoverageCache +from ..template_reference import template_spec from ..utils import ( format_temperature_high_low, geocode_city_sync, @@ -46,6 +47,19 @@ WXSIM_PARSER_AVAILABLE = True # Multiday: plain digits (e.g. 7), 7day/7-day, or suffix form 7d/10d (min 2, max below). WX_MULTIDAY_MAX_DAYS = 16 +# Placeholders of the [Weather] temperature_*_format templates (utils.format_temperature_high_low). +_TEMP_FORMAT_PLACEHOLDERS = { + "high": "Day's high, rounded", + "low": "Day's low, rounded", + "units": "Unit symbol, e.g. °F", + "high_label": "H, or its translation in the reply language", + "low_label": "L, or its translation in the reply language", +} +_TEMP_FORMAT_NOTES = [ + "A mistake makes the bot log a warning and use the default format instead.", + "Keep {high_label}/{low_label} to follow the reply language; the preview shows English.", +] + _P = ParamSpec("_P") _T = TypeVar("_T") @@ -186,29 +200,52 @@ class WxCommand(WeatherCommandMixin, BaseCommand): # Web-viewer settings schema (see modules/settings_schema.py) settings_schema = [ + {"key": "temperature_unit", "label": "Temperature unit", "type": "enum", "section": "Weather", + "options": [ + {"value": "fahrenheit", "label": "Fahrenheit (°F)"}, + {"value": "celsius", "label": "Celsius (°C)"}, + ], + "default": "fahrenheit", + "help": "For wx, gwx, rain and the weather service. Celsius also shows visibility in km. " + "Shared weather setting."}, + {"key": "wind_speed_unit", "label": "Wind speed unit", "type": "enum", "section": "Weather", + "options": [ + {"value": "mph", "label": "Miles per hour (mph)"}, + {"value": "kmh", "label": "Kilometers per hour (km/h)"}, + {"value": "ms", "label": "Meters per second (m/s)"}, + {"value": "kn", "label": "Knots (kn)"}, + ], + "default": "mph", "help": "For wx, gwx and the weather service. Shared weather setting."}, + {"key": "precipitation_unit", "label": "Precipitation unit", "type": "enum", "section": "Weather", + "options": [{"value": "inch", "label": "Inches"}, {"value": "mm", "label": "Millimeters"}], + "default": "inch", "help": "For wx, gwx and the weather service. Shared weather setting."}, + # [Wx_Command] overrides of the shared units. "" (the default) means no + # override; the save endpoint removes the key rather than writing it blank. { "key": "temperature_unit", - "label": "Temperature unit", + "label": "wx-only temperature unit", "type": "enum", "options": [ + {"value": "", "label": "Same as [Weather]"}, {"value": "fahrenheit", "label": "Fahrenheit (°F)"}, {"value": "celsius", "label": "Celsius (°C)"}, ], - "default": "fahrenheit", - "help": "Unit used when reporting temperatures.", + "default": "", + "help": "Overrides the shared temperature unit for wx only.", }, { "key": "wind_speed_unit", - "label": "Wind speed unit", + "label": "wx-only wind speed unit", "type": "enum", "options": [ + {"value": "", "label": "Same as [Weather]"}, {"value": "mph", "label": "Miles per hour (mph)"}, {"value": "kmh", "label": "Kilometers per hour (km/h)"}, {"value": "ms", "label": "Meters per second (m/s)"}, {"value": "kn", "label": "Knots (kn)"}, ], - "default": "mph", - "help": "Unit used when reporting wind speed.", + "default": "", + "help": "Overrides the shared wind speed unit for wx only.", }, {"key": "weather_provider", "label": "Weather provider", "type": "enum", "section": "Weather", "options": [ @@ -231,6 +268,24 @@ class WxCommand(WeatherCommandMixin, BaseCommand): "help": "Name the place in every wx/gwx reply a place is found for, not only when it is outside the " "default state or country. Costs message length, and a reverse lookup for ZIP codes. " "Shared weather setting."}, + {"key": "temperature_high_low_format", "label": "High/low format", "type": "str", "section": "Weather", + "default": "{high_label}:{high}{units} {low_label}:{low}{units}", + "help": "How a day's high and low are shown in wx, gwx and the weather service. Shared weather setting.", + "template": template_spec("format", _TEMP_FORMAT_PLACEHOLDERS, notes=_TEMP_FORMAT_NOTES, + escapes=False, preview="temp_pair", + blank_default="{high_label}:{high}{units} {low_label}:{low}{units}")}, + {"key": "temperature_high_only_format", "label": "High-only format", "type": "str", "section": "Weather", + "default": "{high_label}:{high}{units}", + "help": "Used when a day has only a high. Shared weather setting.", + "template": template_spec("format", _TEMP_FORMAT_PLACEHOLDERS, notes=_TEMP_FORMAT_NOTES, + escapes=False, preview="temp_high", + blank_default="{high_label}:{high}{units}")}, + {"key": "temperature_low_only_format", "label": "Low-only format", "type": "str", "section": "Weather", + "default": "{low_label}:{low}{units}", + "help": "Used when a day has only a low, such as tonight's forecast. Shared weather setting.", + "template": template_spec("format", _TEMP_FORMAT_PLACEHOLDERS, notes=_TEMP_FORMAT_NOTES, + escapes=False, preview="temp_low", + blank_default="{low_label}:{low}{units}")}, {"key": "use_bot_location_when_no_location", "label": "Bot location as last resort", "type": "bool", "section": "Weather", "default": False, "help": "A bare wx/gwx with no custom source, companion location or default city uses [Bot] bot_latitude/bot_longitude instead of showing usage. Shared weather setting."}, diff --git a/modules/service_plugins/mqtt_weather_service.py b/modules/service_plugins/mqtt_weather_service.py index 8859700..6177013 100644 --- a/modules/service_plugins/mqtt_weather_service.py +++ b/modules/service_plugins/mqtt_weather_service.py @@ -51,9 +51,9 @@ class MqttWeatherService(BaseServicePlugin): {"key": "password", "label": "Password", "type": "str", "default": "", "help": "Broker password (optional)."}, {"key": "client_id", "label": "Client ID", "type": "str", "default": "", "help": "Optional MQTT client id."}, {"key": "qos", "label": "QoS", "type": "int", "min": 0, "max": 2, "default": 0, "help": "MQTT quality of service."}, - {"key": "max_payload_bytes", "label": "Max payload", "type": "int", "min": 1, "default": 65536, "unit": "bytes", + {"key": "max_payload_bytes", "label": "Max payload", "type": "int", "min": 256, "max": 1048576, "default": 65536, "unit": "bytes", "help": "Drop incoming payloads larger than this."}, - {"key": "stale_after_seconds", "label": "Stale after", "type": "int", "min": 1, "default": 3600, "unit": "s", + {"key": "stale_after_seconds", "label": "Stale after", "type": "int", "min": 5, "max": 604800, "default": 3600, "unit": "s", "help": "Cached reading is stale after this long without a fresh message."}, {"key": "output_mode", "label": "Output mode", "type": "enum", "options": [{"value": "passthrough", "label": "Passthrough"}, @@ -77,7 +77,7 @@ class MqttWeatherService(BaseServicePlugin): "help": "Optional filter: JSON key to match."}, {"key": "json_device_value", "label": "JSON device value", "type": "str", "default": "", "help": "Optional filter: required value for the device key."}, - {"key": "passthrough_max_length", "label": "Passthrough max length", "type": "int", "min": 1, "default": 500, "unit": "chars", + {"key": "passthrough_max_length", "label": "Passthrough max length", "type": "int", "min": 64, "max": 4000, "default": 500, "unit": "chars", "help": "Max output length after sanitize."}, ] diff --git a/modules/service_plugins/packet_capture_service.py b/modules/service_plugins/packet_capture_service.py index 6fd89f7..1ea2c4d 100644 --- a/modules/service_plugins/packet_capture_service.py +++ b/modules/service_plugins/packet_capture_service.py @@ -306,6 +306,10 @@ class PacketCaptureService(BaseServicePlugin): {"key": "tls_insecure", "label": "Skip TLS verification", "type": "bool", "default": False, "help": "INSECURE — accept any broker certificate. Only for self-signed brokers on a " "trusted network; leave off so certificate and hostname are verified."}, + {"key": "username", "label": "Username", "type": "str", "default": "", + "help": "Broker username, when not using a JWT."}, + {"key": "password", "label": "Password", "type": "password", "default": "", + "help": "Broker password, when not using a JWT."}, {"key": "use_auth_token", "label": "Use JWT auth token", "type": "bool", "default": False, "help": "Authenticate with a signed JWT instead of username/password."}, {"key": "token_audience", "label": "Token audience", "type": "str", "default": "", @@ -315,6 +319,13 @@ class PacketCaptureService(BaseServicePlugin): "help": "Reconnect right after renewing the token, so the fresh one is in " "force. Brokers that enforce the JWT's expiry drop the session " "otherwise. Turn off only if your broker ignores expiry."}, + {"key": "jwt_renewal_interval", "label": "JWT renewal interval", "type": "int", "min": 0, + "default": "", "unit": "s", + "help": "Blank = the service-wide JWT renewal interval."}, + {"key": "jwt_ttl_seconds", "label": "JWT TTL", "type": "int", "min": 1, "default": "", "unit": "s", + "help": "Blank = the service-wide JWT TTL."}, + {"key": "topic_prefix", "label": "Topic prefix", "type": "str", "default": "", + "help": "Base for the status/packets topics when those aren't set."}, {"key": "topic_status", "label": "Status topic", "type": "str", "default": ""}, {"key": "topic_packets", "label": "Packets topic", "type": "str", "default": ""}, {"key": "neighbors", "label": "Publish neighbours", "type": "bool", "default": True, @@ -335,6 +346,11 @@ class PacketCaptureService(BaseServicePlugin): "that drops idle connections."}, {"key": "upload_packet_types", "label": "Upload packet types", "type": "str", "default": "", "help": "Comma-separated type numbers (e.g. 2,4). Empty = upload all."}, + {"key": "include_decoded", "label": "Publish decoded", "type": "enum", + "options": [{"value": "", "label": "Same as all brokers"}, + {"value": "true", "label": "On"}, {"value": "false", "label": "Off"}], + "default": "", + "help": "Publish the decoded object to this broker. Needs Decode payloads on."}, ], } ] diff --git a/modules/service_plugins/repeater_prefix_collision_service.py b/modules/service_plugins/repeater_prefix_collision_service.py index 7599f41..f089a04 100644 --- a/modules/service_plugins/repeater_prefix_collision_service.py +++ b/modules/service_plugins/repeater_prefix_collision_service.py @@ -39,9 +39,10 @@ class RepeaterPrefixCollisionService(BaseServicePlugin): settings_schema = [ {"key": "channels", "label": "Alert channels", "type": "list", "default": "", "help": "Channels to post collision alerts to (comma-separated)."}, - {"key": "notify_on_prefix_bytes", "label": "Notify on prefix bytes", "type": "int", - "min": 1, "max": 3, "default": 1, - "help": "Prefix length that counts as a collision: 1 byte (01), 2 (0101), or 3 (010101)."}, + {"key": "notify_on_prefix_bytes", "label": "Notify on prefix bytes", "type": "list", + "pattern": "[123]", "default": "1", + "help": "Prefix lengths that count as a collision, comma-separated: 1 byte (01), 2 (0101), " + "3 (010101). E.g. 2,3."}, {"key": "heard_window_days", "label": "Heard window", "type": "int", "min": 0, "default": 30, "unit": "days", "help": "Only treat an existing prefix as in-use if heard within this window."}, {"key": "prefix_free_days", "label": "Free window", "type": "int", "min": 0, "default": 30, "unit": "days", @@ -69,6 +70,9 @@ class RepeaterPrefixCollisionService(BaseServicePlugin): {"key": "post_process_timeout_seconds", "label": "Post-process timeout", "type": "float", "min": 0, "default": 15.0, "unit": "s", "help": "Give up waiting for the advert to be stored after this long."}, + {"key": "post_process_poll_interval_seconds", "label": "Post-process poll interval", "type": "float", + "min": 0.01, "default": 0.2, "unit": "s", + "help": "How often to check whether the advert has been stored."}, ] def __init__(self, bot: Any) -> None: diff --git a/modules/settings_schema.py b/modules/settings_schema.py index 307ff02..6fbcc39 100644 --- a/modules/settings_schema.py +++ b/modules/settings_schema.py @@ -27,6 +27,8 @@ Schema field format (a list of these dicts on ``settings_schema``):: "required": False, "pattern": None, # validation regex (str/list) "unit": "ms", # optional display suffix + "section": None, # read/write a shared section instead + "inherit_section": None, # show this section's value when unset } """ @@ -131,6 +133,17 @@ def validate_field(field: dict, raw: Any) -> tuple[bool, Any, Optional[str]]: return True, s, None +def field_id(field: dict) -> str: + """A field's identity within its plugin: ``key``, or ``key@Section`` for a shared section. + + A plugin may expose the same key in two sections (``wx``'s shared ``[Weather]`` + units and its own ``[Wx_Command]`` overrides), so the key alone can't name the + field in a save payload. + """ + section = field.get("section") + return f"{field['key']}@{section}" if section else field["key"] + + def to_config_string(field: dict, coerced: Any) -> str: """Convert a coerced value into its config.ini string form.""" ftype = field.get("type", "str") @@ -173,6 +186,11 @@ def _read_typed(config: configparser.ConfigParser, section: str, field: dict) -> ftype = field.get("type", "str") default = field.get("default") if not config.has_section(section) or not config.has_option(section, key): + # A field the plugin inherits from another section when its own is unset + # shows the inherited value, so the form doesn't misstate what runs. + inherit = field.get("inherit_section") + if inherit and config.has_section(inherit) and config.has_option(inherit, key): + return _read_typed(config, inherit, {**field, "inherit_section": None, "section": None}) return default # raw=True so values containing '%' (cron/strftime/templates) don't trip # configparser's interpolation, which would raise InterpolationError. @@ -493,6 +511,7 @@ def _assemble_entry( if not isinstance(field, dict) or "key" not in field: continue resolved = dict(field) + resolved["id"] = field_id(field) resolved["value"] = _read_typed(config, section, field) if field.get("type") == "password": # Never send the plaintext secret to the browser; the UI shows a @@ -512,6 +531,7 @@ def _assemble_entry( "global monitored channels (default). DMs always work."), } channels_field["value"] = _read_typed(config, section, channels_field) + channels_field["id"] = "channels" fields.append(channels_field) # Keys handled elsewhere shouldn't appear in the raw "Other config values" diff --git a/modules/template_reference.py b/modules/template_reference.py index 1303e67..7fb9535 100644 --- a/modules/template_reference.py +++ b/modules/template_reference.py @@ -23,7 +23,10 @@ filter list in step with :data:`~modules.response_template.RESPONSE_TEMPLATE_FIL from __future__ import annotations +import configparser +import dataclasses import json +import re from pathlib import Path from typing import Any, Callable, Optional @@ -144,6 +147,7 @@ def template_spec( escapes: bool = True, samples: Optional[dict[str, Any]] = None, blank_default: Optional[str] = None, + blank_config: Optional[tuple[str, str]] = None, preview: Optional[str] = None, ) -> dict[str, Any]: """Build the ``"template"`` attribute of a settings_schema field. @@ -154,7 +158,8 @@ def template_spec( newline. ``samples`` overrides preview values for this field, as ``{name: value_or_{scenario_id: value}}``. ``blank_default`` is the template the plugin uses when the field is blank, which the preview renders - in that case. ``preview`` names the :data:`FORMAT_PREVIEWS` renderer for a + in that case; ``blank_config`` is a ``(section, key)`` the plugin tries + first. ``preview`` names the :data:`FORMAT_PREVIEWS` renderer for a ``format`` field; piped fields always preview. """ if preview is not None and preview not in FORMAT_PREVIEWS: @@ -175,6 +180,8 @@ def template_spec( spec["samples"] = samples if blank_default is not None: spec["blank_default"] = blank_default + if blank_config is not None: + spec["blank_config"] = list(blank_config) if syntax == "piped": spec["filters"] = PIPED_FILTERS spec["syntax_notes"] = PIPED_SYNTAX_NOTES @@ -220,32 +227,87 @@ def _row(row_id: str, label: str, output: str, error: Optional[str] = None) -> d return row -def render_preview(spec: dict[str, Any], template: str) -> list[dict[str, Any]]: +def _strip_quotes(text: str) -> str: + """BaseCommand._strip_quotes_from_config: drop one pair of surrounding double quotes.""" + if text and text.startswith('"') and text.endswith('"'): + return text[1:-1] + return text + + +# A preview renders str.format specs for real, and `{sender:999999999}` would build +# a gigabyte string; the endpoint is admin-only but still shouldn't be a memory lever. +_MAX_FORMAT_WIDTH = 500 +_FORMAT_SPEC_RE = re.compile(r"\{[^{}:]*:([^{}]*)\}") + + +def _oversized_format_spec(text: str) -> bool: + return any( + int(number) > _MAX_FORMAT_WIDTH + for spec in _FORMAT_SPEC_RE.findall(text) + for number in re.findall(r"\d+", spec) + ) + + +Preview = dict[str, Any] +Renderer = Callable[[dict[str, Any], str, Optional[configparser.ConfigParser]], Preview] + + +def _preview(rows: list[dict[str, Any]], note: Optional[str] = None) -> Preview: + return {"scenarios": rows, "note": note} + + +def render_preview( + spec: dict[str, Any], + template: str, + config: Optional[configparser.ConfigParser] = None, +) -> Preview: """Render a template field as its plugin would, against sample data. - Each row is ``{id, label, output, bytes}``, plus ``error`` when the plugin - would hit a problem; ``output`` is then what the plugin sends instead. + Returns ``{"scenarios": rows, "note": text_or_None}``. Each row is + ``{id, label, output, bytes}``, plus ``error`` when the plugin would hit a + problem; ``output`` is then what the plugin sends instead. ``note`` says when + the preview shows a fallback (a blank field) rather than the template. + ``config`` is the bot's current configuration, for the settings beyond the + template that change the output (a [Keywords] fallback, the multitest path + layout, the MQTT length limit); without it the defaults apply. """ if spec.get("syntax") == "piped": - return _preview_piped(spec, template) + return _preview_piped(spec, template, config) renderer = FORMAT_PREVIEWS.get(spec.get("preview") or "") if renderer is None: raise ValueError("this template field has no preview") - return renderer[0](spec, template) + if _oversized_format_spec(template): + return _preview([_row("template", "Template", "", + f"A format width or precision over {_MAX_FORMAT_WIDTH} isn't previewed.")]) + return renderer[0](spec, template, config) -def _preview_piped(spec: dict[str, Any], template: str) -> list[dict[str, Any]]: +def _config_get(config: Optional[configparser.ConfigParser], section: str, key: str) -> Optional[str]: + if config is None or not config.has_section(section) or not config.has_option(section, key): + return None + return config.get(section, key, raw=True) + + +def _preview_piped(spec: dict[str, Any], template: str, config: Optional[configparser.ConfigParser]) -> Preview: """Render a piped template against every sample scenario. - Mirrors what the commands do before rendering: a blank field falls back to - the plugin's ``blank_default``, surrounding double quotes are stripped and, - when the field takes them, escape sequences are decoded. The ``shorten`` - filter gets no config, so it passes the URL through instead of calling the - shortener. + Mirrors what the commands do before rendering: surrounding double quotes are + stripped, a blank result falls back (to ``blank_config`` in the bot's config, + then ``blank_default``) and, when the field takes them, escape sequences are + decoded. The ``shorten`` filter gets no config, so it passes the URL through + instead of calling the shortener. """ - text = template.strip() or spec.get("blank_default") or "" - if len(text) >= 2 and text.startswith('"') and text.endswith('"'): - text = text[1:-1] + text = _strip_quotes(template.strip()).strip() + note = None + if not text: + fallback_key = spec.get("blank_config") + configured = _config_get(config, *fallback_key) if fallback_key else None + if configured: + text = _strip_quotes(configured) + note = f"The field is blank, so the bot uses [{fallback_key[0]}] {fallback_key[1]}." + elif spec.get("blank_default"): + text = spec["blank_default"] + note = "The field is blank, so this is the built-in default." if spec.get("escapes", True): text = decode_escape_sequences(text) @@ -263,11 +325,22 @@ def _preview_piped(spec: dict[str, Any], template: str) -> list[dict[str, Any]]: fields[name] = _sample(ALL_PLACEHOLDERS[name][1], sid) output = format_piped_template(text, fields, message=_scenario_message(scenario)) results.append(_row(sid, scenario["label"], output)) - return results + return _preview(results, note) # --- str.format fields ------------------------------------------------------ +_SAVES_DEFAULT = "The field is blank; saving it stores the default, shown here." + + +def _blank_or(spec: dict[str, Any], template: str) -> tuple[str, Optional[str]]: + """The template a blank field saves as (the schema default), with a note saying so.""" + text = template.strip() + if text or not spec.get("blank_default"): + return text, None + return spec["blank_default"], _SAVES_DEFAULT + + def _describe_format_error(exc: Exception) -> str: """Say what is wrong with a str.format template, in the operator's terms.""" if isinstance(exc, KeyError): @@ -296,12 +369,14 @@ def _greeting_rows(text: str, row_id: str, label: str) -> list[dict[str, Any]]: ] -def _preview_greeting(spec: dict[str, Any], template: str) -> list[dict[str, Any]]: - text = template.strip() or spec.get("blank_default") or "" - return _greeting_rows(decode_escape_sequences(text), "greeting", "Greeting") +def _preview_greeting(spec: dict[str, Any], template: str, config: Optional[configparser.ConfigParser]) -> Preview: + text, note = _blank_or(spec, template) + return _preview(_greeting_rows(decode_escape_sequences(text), "greeting", "Greeting"), note) -def _preview_channel_greetings(spec: dict[str, Any], template: str) -> list[dict[str, Any]]: +def _preview_channel_greetings( + spec: dict[str, Any], template: str, config: Optional[configparser.ConfigParser] +) -> Preview: from .commands.greeter_command import parse_channel_greetings greetings, ignored = parse_channel_greetings(template.strip()) @@ -312,50 +387,50 @@ def _preview_channel_greetings(spec: dict[str, Any], template: str) -> list[dict ] for key, entry in greetings.items(): rows.extend(_greeting_rows(entry["greeting"], f"ch-{key}", entry["channel"])) - return rows + note = None if rows else "No channel greetings: every channel gets the default greeting." + return _preview(rows, note) _MESH_INFO_SAMPLE = {"total_contacts": 412, "repeaters": 57, "companions": 331, "recent_activity_24h": 38} -def _preview_mesh_info(spec: dict[str, Any], template: str) -> list[dict[str, Any]]: - text = decode_escape_sequences(template.strip()) +def _preview_mesh_info(spec: dict[str, Any], template: str, config: Optional[configparser.ConfigParser]) -> Preview: + text, note = _blank_or(spec, template) + text = decode_escape_sequences(text) try: - return [_row("mesh_info", "Appended", text.format(**_MESH_INFO_SAMPLE))] + return _preview([_row("mesh_info", "Appended", text.format(**_MESH_INFO_SAMPLE))], note) except Exception as exc: # noqa: BLE001 - the greeter catches everything here - return [_row("mesh_info", "Appended", "", - f"{_describe_format_error(exc)}. The greeting goes out without mesh info.")] + return _preview([_row("mesh_info", "Appended", "", + f"{_describe_format_error(exc)}. The greeting goes out without mesh info.")], note) -_MULTITEST_SAMPLE = { - "sender": "Alice", - "path_count": 3, - "paths": "a1,c3,e5\na1,b7,e5\nf2,e5", - "listening_duration": 6, -} +_MULTITEST_PATHS = ["a1,c3,e5", "a1,b7,e5", "a1,c3,f2"] -def _preview_multitest(spec: dict[str, Any], template: str) -> list[dict[str, Any]]: - """As MultitestCommand._load_config and its reply: unicode_escape decoding, default on error.""" - sample = _MULTITEST_SAMPLE - default = f"Paths({sample['path_count']}):\n{sample['paths']}" - text = template.strip() +def _preview_multitest(spec: dict[str, Any], template: str, config: Optional[configparser.ConfigParser]) -> Preview: + """As MultitestCommand._load_config and its reply: layout, decoding, default on error.""" + from .commands.multitest_command import _condense_path_lines, _parse_condense_paths_mode + + mode = _parse_condense_paths_mode(_config_get(config, "Multitest_Command", "condense_paths") or "true") + paths = sorted(_MULTITEST_PATHS) + paths_text = _condense_path_lines(paths, mode) if mode in ("flat", "nested") else "\n".join(paths) + sample = {"sender": "Alice", "path_count": len(paths), "paths": paths_text, "listening_duration": 6} + default = f"Paths({len(paths)}):\n{paths_text}" + + text = _strip_quotes(template.strip()).strip() if not text: - return [_row("reply", "Reply", default)] - if len(text) >= 2 and text.startswith('"') and text.endswith('"'): - text = text[1:-1] - text = text.strip() + return _preview([_row("reply", "Reply", default)], "The field is blank, so multitest uses its default format.") try: text = text.encode("latin-1").decode("unicode_escape") except (UnicodeDecodeError, UnicodeEncodeError): pass try: - return [_row("reply", "Reply", text.format(**sample))] + return _preview([_row("reply", "Reply", text.format(**sample))]) except (KeyError, ValueError) as exc: - return [_row("reply", "Reply", default, - f"{_describe_format_error(exc)}. Multitest sends its default format instead.")] + return _preview([_row("reply", "Reply", default, + f"{_describe_format_error(exc)}. Multitest sends its default format instead.")]) except Exception as exc: # noqa: BLE001 - shows what reaches the command's caller - return [_row("reply", "Reply", "", f"{_describe_format_error(exc)}. The multitest reply fails.")] + return _preview([_row("reply", "Reply", "", f"{_describe_format_error(exc)}. The multitest reply fails.")]) _MQTT_SAMPLES = [ @@ -382,37 +457,78 @@ def _mqtt_reply_for_error(err: str) -> str: return wx.get("mqtt_weather_payload_error", "{detail}").format(detail=err.replace("_", " ")) -def _preview_mqtt_json(spec: dict[str, Any], template: str) -> list[dict[str, Any]]: - from .clients.mqtt_weather import ( - _DEFAULT_PASSTHROUGH_MAX, - MqttWeatherFormatConfig, - format_mqtt_weather_payload, - ) +def _preview_mqtt_json(spec: dict[str, Any], template: str, config: Optional[configparser.ConfigParser]) -> Preview: + """Through format_mqtt_weather_payload, with the configured length limit.""" + from .clients.mqtt_weather import format_mqtt_weather_payload, load_mqtt_weather_format_config - fmt = MqttWeatherFormatConfig( + text, note = _blank_or(spec, template) + fmt = dataclasses.replace( + load_mqtt_weather_format_config(config or configparser.ConfigParser()), output_mode="json_template", - json_template=template.strip() or spec.get("blank_default") or "", + json_template=text, json_device_key="", json_device_value="", - max_payload_bytes=65536, - passthrough_max_length=_DEFAULT_PASSTHROUGH_MAX, - stale_after_seconds=3600.0, ) rows = [] for row_id, label, data in _MQTT_SAMPLES: - text, err = format_mqtt_weather_payload(json.dumps(data).encode(), fmt) + out, err = format_mqtt_weather_payload(json.dumps(data).encode(), fmt) if err: rows.append(_row(row_id, label, _mqtt_reply_for_error(err), f"The template failed ({err}); wx sends this instead.")) else: - rows.append(_row(row_id, label, text or "")) - return rows + rows.append(_row(row_id, label, out or "")) + return _preview(rows, note) + + +class _WarningCatcher: + """Logger stand-in that keeps the warnings a renderer logs.""" + + def __init__(self) -> None: + self.warnings: list[str] = [] + + def warning(self, msg: str, *args: Any) -> None: + self.warnings.append(msg % args if args else msg) + + +def _temperature_preview(key: str, high: Optional[int], low: Optional[int], label: str) -> Renderer: + """Preview one [Weather] temperature_*_format through utils.format_temperature_high_low.""" + + def render(spec: dict[str, Any], template: str, config: Optional[configparser.ConfigParser]) -> Preview: + from .utils import format_temperature_high_low + + text, note = _blank_or(spec, template) + weather = configparser.ConfigParser(interpolation=None) + weather.add_section("Weather") + weather.set("Weather", key, text) + catcher = _WarningCatcher() + try: + out = format_temperature_high_low(weather, high, low, "°F", logger=catcher) + except Exception as exc: # noqa: BLE001 - what escapes reaches the weather reply + return _preview([_row(key, label, "", f"{_describe_format_error(exc)}. The weather reply fails.")], note) + if catcher.warnings: + # The template failed and the default went out; say why in the template's terms. + try: + text.format(high=high, low=low, units="°F", high_label="H", low_label="L") + reason = "Template error" + except Exception as exc: # noqa: BLE001 + reason = _describe_format_error(exc) + return _preview([_row(key, label, out, f"{reason}. The bot uses the default format instead.")], note) + return _preview([_row(key, label, out)], note) + + return render # name -> (renderer, description of the sample data shown under the preview) -FORMAT_PREVIEWS: dict[str, tuple[Callable[[dict[str, Any], str], list[dict[str, Any]]], str]] = { +FORMAT_PREVIEWS: dict[str, tuple[Renderer, str]] = { "greeting": (_preview_greeting, "Greeting a user named Alice. Mesh info, when on, is added to the last message."), "channel_greetings": (_preview_channel_greetings, "Greeting a user named Alice on each listed channel."), "mesh_info": (_preview_mesh_info, "Sample counts: 412 contacts, 57 repeaters, 331 companions, 38 active."), - "multitest": (_preview_multitest, "Alice heard over 3 paths in 6 seconds, one path per line."), - "mqtt_json": (_preview_mqtt_json, "Two sample station payloads; the second has no humidity."), + "multitest": (_preview_multitest, "Alice heard over 3 paths in 6 seconds, laid out per the saved Path layout."), + "mqtt_json": (_preview_mqtt_json, "Two sample station payloads; the second has no humidity. " + "Uses the saved passthrough length limit."), + "temp_pair": (_temperature_preview("temperature_high_low_format", 68, 51, "High 68, low 51"), + "A day with a high of 68 and a low of 51 °F."), + "temp_high": (_temperature_preview("temperature_high_only_format", 68, None, "High 68 only"), + "A day with only a high of 68 °F."), + "temp_low": (_temperature_preview("temperature_low_only_format", None, 51, "Low 51 only"), + "A night with only a low of 51 °F."), } diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index aee860b..486d8f3 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -79,6 +79,7 @@ from modules.security_utils import ( ) from modules.settings_schema import ( build_plugin_settings_view, + field_id, to_config_string, validate_field, ) @@ -1205,7 +1206,16 @@ class BotDataViewer(DashboardSnapshotMixin, LiveStreamMixin, SocketClientsMixin, return jsonify({'success': False, 'error': 'Unknown plugin'}), 404 section = entry['section'] - schema_by_key = {f['key']: f for f in entry['fields']} + # Fields are addressed by field_id (key, or key@Section for a shared + # section). A bare key still resolves when only one field has it, so + # scripted saves written before field ids keep working. + schema_by_id = {field_id(f): f for f in entry['fields']} + key_counts: dict[str, int] = {} + for f in entry['fields']: + key_counts[f['key']] = key_counts.get(f['key'], 0) + 1 + for f in entry['fields']: + if key_counts[f['key']] == 1: + schema_by_id.setdefault(f['key'], f) raw_values = data.get('values', {}) or {} errors: dict[str, str] = {} @@ -1215,22 +1225,24 @@ class BotDataViewer(DashboardSnapshotMixin, LiveStreamMixin, SocketClientsMixin, # schema, so a partial schema never hides remaining settings). updates: dict[str, dict[str, str]] = {section: {}} deletes: dict[str, list[str]] = {} - for key, val in raw_values.items(): - field = schema_by_key.get(key) + for submitted, val in raw_values.items(): + field = schema_by_id.get(submitted) if field is not None: + key = field['key'] ok, coerced, err = validate_field(field, val) if not ok: - errors[key] = err + errors[submitted] = err else: tsec = field.get('section') or section - if field.get('type') in ('int', 'float') and coerced == '': - # A cleared number means unset: `key =` would make - # getint/getfloat raise instead of using their fallback. + if field.get('type') in ('int', 'float', 'enum') and coerced == '': + # A cleared number, or an enum's "" (inherit) option, + # means unset: `key =` would make getint/getfloat + # raise, or read as an invalid choice. deletes.setdefault(tsec, []).append(key) else: updates.setdefault(tsec, {})[key] = to_config_string(field, coerced) else: - updates[section][str(key)] = '' if val is None else str(val) + updates[section][str(submitted)] = '' if val is None else str(val) if errors: return jsonify({'success': False, 'errors': errors}), 400 @@ -1302,6 +1314,10 @@ class BotDataViewer(DashboardSnapshotMixin, LiveStreamMixin, SocketClientsMixin, if not ok: return jsonify({'success': False, 'error': f'{rb["label"]} #{i}: {err}'}), 400 + if field.get('type') in ('int', 'float', 'enum') and coerced == '': + # Unset, as for top-level fields; leaving it out of + # `written` deletes any existing key below. + continue updates[section][full] = to_config_string(field, coerced) else: updates[section][full] = '' if val is None else str(val) @@ -1401,7 +1417,7 @@ class BotDataViewer(DashboardSnapshotMixin, LiveStreamMixin, SocketClientsMixin, if not spec or not spec.get('previewable'): return jsonify({'error': 'This field has no preview'}), 404 try: - return jsonify({'scenarios': render_preview(spec, template)}) + return jsonify(render_preview(spec, template, self.config)) except Exception: self.logger.exception("Error rendering template preview") return jsonify({'error': 'Internal error — see server logs'}), 500 diff --git a/modules/web_viewer/templates/plugins.html b/modules/web_viewer/templates/plugins.html index 53d757f..114f113 100644 --- a/modules/web_viewer/templates/plugins.html +++ b/modules/web_viewer/templates/plugins.html @@ -405,7 +405,7 @@ class PluginsManager { }); }; - const schemaKeys = new Set((plugin.fields || []).map(f => f.key)); + const schemaKeys = new Set((plugin.fields || []).filter(f => !f.section).map(f => f.key)); const values = plugin.values || {}; // Keys managed by a dynamic editor on this same section (by prefix) are // excluded from the raw "Other config values" list to avoid duplication. @@ -524,7 +524,7 @@ class PluginsManager { markInvalid(input, err); if (!firstError) firstError = err; } - values[field.key] = raw; + values[field.id || field.key] = raw; }); // Dynamic sections: collect rows, validate keys, detect changes. @@ -578,7 +578,8 @@ class PluginsManager { if (!resp.ok) { if (data.errors) { inputs.forEach(({ field, input }) => { - if (data.errors[field.key]) markInvalid(input, data.errors[field.key]); + const err = data.errors[field.id || field.key]; + if (err) markInvalid(input, err); }); throw new Error('Please fix the highlighted fields'); } @@ -880,7 +881,7 @@ function buildTemplateReference(field, input, refBtn, plugin) { let timer = null; let seq = 0; const render = async () => { - const mine = ++seq; + const mine = seq; try { const resp = await fetch(`/api/plugins/${plugin.kind}/${plugin.name}/template-preview`, { method: 'POST', @@ -888,16 +889,16 @@ function buildTemplateReference(field, input, refBtn, plugin) { body: JSON.stringify({ key: field.key, template: input.value }), }); const data = await resp.json(); - if (mine !== seq) return; // a newer keystroke already asked + if (mine !== seq) return; // the field changed while this was in flight if (!resp.ok) throw new Error(data.error || 'Preview failed'); const rows = []; - if (!input.value.trim() && spec.blank_default) { + if (data.note) { const note = document.createElement('div'); note.className = 'template-ref-intro mb-0'; - note.textContent = 'The field is blank, so this is the default reply.'; + note.textContent = data.note; rows.push(note); } - if (!data.scenarios.length) { + if (!data.scenarios.length && !data.note) { const none = document.createElement('div'); none.className = 'template-ref-intro mb-0'; none.textContent = 'Nothing to preview.'; @@ -937,6 +938,9 @@ function buildTemplateReference(field, input, refBtn, plugin) { } }; refreshPreview = () => { + // Invalidate any request in flight now, not when the timer fires: a + // response that lands inside the debounce would show a stale template. + seq++; clearTimeout(timer); timer = setTimeout(render, 300); }; diff --git a/tests/characterization/golden/web_endpoint_inventory.json b/tests/characterization/golden/web_endpoint_inventory.json index 7498dd5..0a58a8b 100644 --- a/tests/characterization/golden/web_endpoint_inventory.json +++ b/tests/characterization/golden/web_endpoint_inventory.json @@ -1109,6 +1109,20 @@ "POST" ] }, + "/api/plugins///template-preview": { + "POST locked anon": { + "content_type": "application/json", + "csp_script_src": "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com", + "location": "", + "status": 401, + "x_content_type_options": "nosniff", + "x_frame_options": "SAMEORIGIN" + }, + "endpoint": "api_plugins_template_preview", + "methods": [ + "POST" + ] + }, "/api/plugins/reload-status": { "GET locked anon": { "content_type": "application/json", diff --git a/tests/test_settings_ui.py b/tests/test_settings_ui.py index c7acbdf..3bb7beb 100644 --- a/tests/test_settings_ui.py +++ b/tests/test_settings_ui.py @@ -564,6 +564,54 @@ class TestPluginsApi: ) assert rows and rows[0]["status"] == "pending" + def test_same_key_in_two_sections_saves_to_each(self, viewer, tmp_path): + """wx has temperature_unit in [Weather] and as a [Wx_Command] override.""" + client = viewer.app.test_client() + resp = client.post("/api/plugins/command/wx", json={"enabled": True, "values": { + "temperature_unit@Weather": "celsius", + "temperature_unit": "fahrenheit", + }}) + assert resp.status_code == 200, resp.get_json() + cfg = configparser.ConfigParser() + cfg.read(tmp_path / "config.ini", encoding="utf-8") + assert cfg.get("Weather", "temperature_unit") == "celsius" + assert cfg.get("Wx_Command", "temperature_unit") == "fahrenheit" + + def test_bare_key_of_a_unique_shared_field_still_resolves(self, viewer, tmp_path): + client = viewer.app.test_client() + resp = client.post("/api/plugins/command/wx", json={"enabled": True, "values": {"default_city": "Tacoma"}}) + assert resp.status_code == 200, resp.get_json() + cfg = configparser.ConfigParser() + cfg.read(tmp_path / "config.ini", encoding="utf-8") + assert cfg.get("Weather", "default_city") == "Tacoma" + assert not cfg.has_option("Wx_Command", "default_city") + + def test_inherit_option_removes_the_override(self, viewer, tmp_path): + client = viewer.app.test_client() + client.post("/api/plugins/command/wx", json={"enabled": True, "values": {"temperature_unit": "celsius"}}) + resp = client.post("/api/plugins/command/wx", json={"enabled": True, "values": {"temperature_unit": ""}}) + assert resp.status_code == 200, resp.get_json() + cfg = configparser.ConfigParser() + cfg.read(tmp_path / "config.ini", encoding="utf-8") + assert not cfg.has_option("Wx_Command", "temperature_unit") + + def test_blank_inheriting_broker_fields_are_not_written(self, viewer, tmp_path): + """getint/getboolean read these when present, so a blank must not be saved.""" + client = viewer.app.test_client() + resp = client.post("/api/plugins/service/packetcapture", json={ + "enabled": False, + "values": {}, + "repeating_blocks": {"mqtt": [{"enabled": True, "values": { + "server": "mqtt.example.org", "jwt_renewal_interval": "", "include_decoded": "", + }}]}, + }) + assert resp.status_code == 200, resp.get_json() + cfg = configparser.ConfigParser() + cfg.read(tmp_path / "config.ini", encoding="utf-8") + assert cfg.get("PacketCapture", "mqtt1_server") == "mqtt.example.org" + assert not cfg.has_option("PacketCapture", "mqtt1_jwt_renewal_interval") + assert not cfg.has_option("PacketCapture", "mqtt1_include_decoded") + def test_cleared_number_field_removes_key(self, viewer, tmp_path): """A blank number must unset the key: `key =` makes getfloat raise.""" client = viewer.app.test_client() diff --git a/tests/unit/test_template_reference.py b/tests/unit/test_template_reference.py index 73e3689..bb65ed6 100644 --- a/tests/unit/test_template_reference.py +++ b/tests/unit/test_template_reference.py @@ -17,9 +17,15 @@ from modules.service_plugins.mqtt_weather_service import MqttWeatherService from modules.template_reference import ( MESSAGE_PLACEHOLDERS, PIPED_FILTERS, - render_preview, template_spec, ) +from modules.template_reference import render_preview as _render_preview +from modules.utils import decode_escape_sequences + + +def render_preview(spec, template, config=None): + """The preview's rows; see TestPreviewNotes for the note beside them.""" + return _render_preview(spec, template, config)["scenarios"] def _field(cls, key): @@ -168,7 +174,7 @@ class TestFormatPreviews: def test_blank_greeting_previews_the_default(self): (row,) = _preview(GreeterCommand, "greeting_message", "") - assert row["output"] == "Welcome to the mesh, @[Alice]!" + assert row["output"] == "Welcome to the mesh, Alice!" def test_channel_greetings_use_the_greeters_parser(self): rows = _preview(GreeterCommand, "channel_greetings", "stray, Public:Hi, {sender}!,#local:Hey") @@ -218,3 +224,90 @@ class TestFormatPreviews: def test_mqtt_unknown_placeholder(self): full, _ = _preview(MqttWeatherService, "json_template", "{bogus}") assert "Invalid template placeholder: {bogus}" in full["output"] + + +def _config(text): + config = configparser.ConfigParser(interpolation=None) + config.read_string(text) + return config + + +@pytest.mark.unit +class TestCodexReviewFixes: + def test_blank_test_format_previews_the_keywords_fallback(self): + spec = _field(MeshTestCommand, "response_format")["template"] + config = _config('[Keywords]\ntest = "ack {hops}"\n') + result = _render_preview(spec, "", config) + assert _by_id(result["scenarios"])["direct"] == "ack 0" + assert "[Keywords] test" in result["note"] + + def test_quoted_empty_test_format_is_blank_too(self): + spec = _field(MeshTestCommand, "response_format")["template"] + result = _render_preview(spec, '""') + assert _by_id(result["scenarios"])["direct"].startswith("ack @[Alice]: radio check") + assert "built-in default" in result["note"] + + def test_quoted_empty_multitest_format_is_the_default(self): + (row,) = _preview(MultitestCommand, "response_format", '""') + assert row["output"].startswith("Paths(3):\n") + + def test_multitest_follows_the_saved_path_layout(self): + spec = _field(MultitestCommand, "response_format")["template"] + flat = _render_preview(spec, "{paths}", _config("[Multitest_Command]\ncondense_paths = false\n")) + condensed = _render_preview(spec, "{paths}", _config("[Multitest_Command]\ncondense_paths = true\n")) + assert flat["scenarios"][0]["output"] == "a1,b7,e5\na1,c3,e5\na1,c3,f2" + assert condensed["scenarios"][0]["output"] != flat["scenarios"][0]["output"] + + def test_mqtt_uses_the_configured_length_limit(self): + spec = _field(MqttWeatherService, "json_template")["template"] + config = _config("[MqttWeather]\npassthrough_max_length = 64\n") + rows = _render_preview(spec, "{time}" + "x" * 100, config)["scenarios"] + assert len(rows[0]["output"]) == 64 + + def test_huge_format_width_is_not_rendered(self): + (row,) = _preview(GreeterCommand, "greeting_message", "{sender:100000000}") + assert row["output"] == "" + assert "over 500" in row["error"] + + def test_greeter_form_defaults_are_its_missing_key_fallbacks(self, command_mock_bot): + command_mock_bot.config.add_section("Greeter_Command") + with patch.object(GreeterCommand, "_init_greeter_tables"): + cmd = GreeterCommand(command_mock_bot) + assert cmd.greeting_message == _field(GreeterCommand, "greeting_message")["default"] + assert cmd.mesh_info_format == decode_escape_sequences(_field(GreeterCommand, "mesh_info_format")["default"]) + + def test_temperature_format_preview_and_fallback(self): + from modules.commands.wx_command import WxCommand + + spec = _field(WxCommand, "temperature_high_low_format")["template"] + assert render_preview(spec, "↓{low}°↑{high}{units}")[0]["output"] == "↓51°↑68°F" + (row,) = render_preview(spec, "{hi}") + assert row["output"] == "H:68°F L:51°F" + assert "default format" in row["error"] + + +@pytest.mark.unit +class TestSchemaFixes: + def test_feed_shows_the_inherited_feed_manager_value(self): + from modules.commands.feed_command import FeedCommand + from modules.settings_schema import _read_typed + + field = _field(FeedCommand, "allow_private_urls") + assert _read_typed(_config("[Feed_Manager]\nallow_private_urls = true\n"), "Feed_Command", field) is True + own = _config("[Feed_Manager]\nallow_private_urls = true\n[Feed_Command]\nallow_private_urls = false\n") + assert _read_typed(own, "Feed_Command", field) is False + + def test_sports_reads_its_api_timeout(self, command_mock_bot): + from modules.commands.sports_command import SportsCommand + + command_mock_bot.config.add_section("Sports_Command") + command_mock_bot.config.set("Sports_Command", "api_timeout", "42") + assert SportsCommand(command_mock_bot).url_timeout == 42 + + def test_prefix_collision_accepts_a_list_of_lengths(self): + from modules.service_plugins.repeater_prefix_collision_service import RepeaterPrefixCollisionService + from modules.settings_schema import validate_field + + field = _field(RepeaterPrefixCollisionService, "notify_on_prefix_bytes") + assert validate_field(field, "2, 3") == (True, ["2", "3"], None) + assert validate_field(field, "4")[0] is False