mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-31 01:52:38 +00:00
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).
535 lines
23 KiB
Python
535 lines
23 KiB
Python
"""Placeholder and filter reference for template settings, and their preview.
|
|
|
|
A plugin's ``settings_schema`` field marks itself as a template with
|
|
``"template": template_spec(...)``. The web viewer's Plugins page reads that to
|
|
show which placeholders the field takes (and, for piped templates, the filters),
|
|
and the preview endpoint renders a piped template against sample messages with
|
|
:func:`render_preview`.
|
|
|
|
Three template syntaxes exist, and a field must say which one it uses, because a
|
|
reference that offered filters to a ``str.format`` field would be wrong:
|
|
|
|
- ``piped``: :mod:`modules.response_template` (``{field|filter:arg}``, quoted
|
|
literals, conditionals). Used by the test reply and the path reply prefix.
|
|
- ``format``: Python ``str.format``. Placeholders only, no filters. What an
|
|
unknown name does depends on the plugin (the greeter sends nothing, multitest
|
|
falls back to its default), so each such field names a renderer in
|
|
:data:`FORMAT_PREVIEWS` that reproduces its plugin's handling.
|
|
|
|
The catalog here is data for the UI. The fields themselves come from the
|
|
commands that render them; ``tests/test_template_reference.py`` keeps the
|
|
filter list in step with :data:`~modules.response_template.RESPONSE_TEMPLATE_FILTERS`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
import dataclasses
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional
|
|
|
|
from .models import MeshMessage
|
|
from .response_template import format_piped_template
|
|
from .utils import decode_escape_sequences
|
|
|
|
# Sample messages the preview renders against. Each differs in what the piped
|
|
# filters test (hop count and bytes per hop), so a conditional shows its effect.
|
|
SCENARIOS: list[dict[str, Any]] = [
|
|
{
|
|
"id": "multibyte",
|
|
"label": "3 hops, 2-byte path",
|
|
"nodes": ["a1b2", "c3d4", "e5f6"],
|
|
"bytes_per_hop": 2,
|
|
},
|
|
{
|
|
"id": "onebyte",
|
|
"label": "3 hops, 1-byte path",
|
|
"nodes": ["a1", "c3", "e5"],
|
|
"bytes_per_hop": 1,
|
|
},
|
|
{
|
|
"id": "direct",
|
|
"label": "Direct (0 hops)",
|
|
"nodes": [],
|
|
"bytes_per_hop": None,
|
|
},
|
|
]
|
|
|
|
# name -> (description, sample value or {scenario_id: value}).
|
|
_MESSAGE_PLACEHOLDERS: dict[str, tuple[str, Any]] = {
|
|
"sender": ("Sender's name", "Alice"),
|
|
"connection_info": ("Path, SNR and RSSI together", {
|
|
"multibyte": "a1b2,c3d4,e5f6 (3 hops) | SNR: 9.5 dB | RSSI: -98 dBm",
|
|
"onebyte": "a1,c3,e5 (3 hops) | SNR: 9.5 dB | RSSI: -98 dBm",
|
|
"direct": "Direct | SNR: 9.5 dB | RSSI: -98 dBm",
|
|
}),
|
|
"path": ("Route the message took", {
|
|
"multibyte": "a1b2,c3d4,e5f6 (3 hops)",
|
|
"onebyte": "a1,c3,e5 (3 hops)",
|
|
"direct": "Direct",
|
|
}),
|
|
"hops": ("Hop count; ? when unknown", {"multibyte": "3", "onebyte": "3", "direct": "0"}),
|
|
"hops_label": ("Hop count with the word: 1 hop, 3 hops", {
|
|
"multibyte": "3 hops", "onebyte": "3 hops", "direct": "0 hops",
|
|
}),
|
|
"timestamp": ("Bot's time when the message arrived (HH:MM:SS)", "14:32:07"),
|
|
"snr": ("Signal-to-noise ratio in dB", "9.5"),
|
|
"rssi": ("Signal strength in dBm", "-98"),
|
|
"packet_hash": ("16-character packet hash; empty without RF data", "3F2A9C1B7E4D0A65"),
|
|
}
|
|
|
|
_EXTRA_PLACEHOLDERS: dict[str, tuple[str, Any]] = {
|
|
"phrase": ("Text after the trigger word (test radio check → radio check)", "radio check"),
|
|
"phrase_part": ("': <phrase>', or empty when there is none", ": radio check"),
|
|
"elapsed": ("Time the message took to arrive, e.g. 842ms", "842ms"),
|
|
"path_distance": (
|
|
"Distance travelled, sender → hops → bot; empty unless every node has a location",
|
|
{"multibyte": "12.4km", "onebyte": "12.4km", "direct": ""},
|
|
),
|
|
"firstlast_distance": (
|
|
"Distance between the first and last repeater; empty if either has no location",
|
|
{"multibyte": "8.1km", "onebyte": "8.1km", "direct": ""},
|
|
),
|
|
}
|
|
|
|
ALL_PLACEHOLDERS = {**_MESSAGE_PLACEHOLDERS, **_EXTRA_PLACEHOLDERS}
|
|
MESSAGE_PLACEHOLDERS = tuple(_MESSAGE_PLACEHOLDERS)
|
|
|
|
# Filters for piped templates, in the order the reference lists them. Aliases
|
|
# resolve to the same filter and are listed with it rather than on their own.
|
|
PIPED_FILTERS: list[dict[str, Any]] = [
|
|
{
|
|
"name": "hops_min", "arg": "N", "aliases": [],
|
|
"help": "Clear the value unless the message travelled at least N hops. "
|
|
"hops_min:1 drops it on a direct message.",
|
|
"example": "{firstlast_distance|hops_min:1}",
|
|
},
|
|
{
|
|
"name": "pathbytes_min", "arg": "N", "aliases": ["pathbytes"],
|
|
"help": "Clear the value unless the path uses at least N bytes per hop (1-3).",
|
|
"example": "{path_distance|pathbytes_min:2}",
|
|
},
|
|
{
|
|
"name": "prefix_if_nonempty", "arg": "text", "aliases": [],
|
|
"help": "Put text in front of the value, only when the value is not empty. "
|
|
"Unquoted, the text runs to the closing } (it may contain |), so it must "
|
|
"come last; quote it to add filters after it.",
|
|
"example": "{path_distance|prefix_if_nonempty: | Dist: }",
|
|
},
|
|
{
|
|
"name": "if_nonempty", "arg": "text", "aliases": ["if_notempty"],
|
|
"help": "Replace the value with text when the value is not empty.",
|
|
"example": "{packet_hash|if_nonempty:#}",
|
|
},
|
|
{
|
|
"name": "shorten", "arg": None, "aliases": ["shorten_url"],
|
|
"help": "Shorten a URL with the [External_Data] shortener; the long URL is kept "
|
|
"if that fails. Not applied in the preview.",
|
|
"example": "{\"https://example.com/a/long/link\"|shorten}",
|
|
},
|
|
]
|
|
|
|
PIPED_SYNTAX_NOTES = [
|
|
"A filter argument in double quotes ends at the closing quote, so more filters can "
|
|
"follow: {path_distance|prefix_if_nonempty:\" Dist \"|hops_min:1}",
|
|
"A quoted literal can hold placeholders: {\"via {path}\"|hops_min:1}",
|
|
"An unknown placeholder renders as nothing.",
|
|
]
|
|
|
|
|
|
def template_spec(
|
|
syntax: str,
|
|
placeholders: tuple[str, ...] | list[str] | dict[str, str],
|
|
*,
|
|
notes: Optional[list[str]] = None,
|
|
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.
|
|
|
|
``placeholders`` is a sequence of names from :data:`ALL_PLACEHOLDERS`, or a
|
|
``{name: description}`` mapping for fields with their own set (greeter mesh
|
|
info, MQTT weather). ``escapes`` says whether ``\\n`` in the value becomes a
|
|
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; ``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:
|
|
raise ValueError(f"unknown format preview {preview!r}")
|
|
if syntax not in ("piped", "format"):
|
|
raise ValueError(f"unknown template syntax {syntax!r}")
|
|
if isinstance(placeholders, dict):
|
|
entries = [{"name": n, "help": h} for n, h in placeholders.items()]
|
|
else:
|
|
entries = [{"name": n, "help": ALL_PLACEHOLDERS[n][0]} for n in placeholders]
|
|
spec: dict[str, Any] = {
|
|
"syntax": syntax,
|
|
"placeholders": entries,
|
|
"notes": list(notes or []),
|
|
"escapes": escapes,
|
|
}
|
|
if samples:
|
|
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
|
|
spec["previewable"] = True
|
|
spec["sample_note"] = "Sample values from a message by Alice."
|
|
elif preview:
|
|
spec["preview"] = preview
|
|
spec["previewable"] = True
|
|
spec["sample_note"] = FORMAT_PREVIEWS[preview][1]
|
|
return spec
|
|
|
|
|
|
def _scenario_message(scenario: dict[str, Any]) -> MeshMessage:
|
|
nodes = scenario["nodes"]
|
|
routing_info: dict[str, Any] = {"path_length": len(nodes), "path_nodes": list(nodes)}
|
|
if scenario["bytes_per_hop"]:
|
|
routing_info["bytes_per_hop"] = scenario["bytes_per_hop"]
|
|
return MeshMessage(
|
|
content="test radio check",
|
|
sender_id="Alice",
|
|
hops=len(nodes),
|
|
snr=9.5,
|
|
rssi=-98,
|
|
routing_info=routing_info,
|
|
)
|
|
|
|
|
|
def _sample(value: Any, scenario_id: str) -> str:
|
|
if isinstance(value, dict):
|
|
return str(value.get(scenario_id, ""))
|
|
return str(value)
|
|
|
|
|
|
def _row(row_id: str, label: str, output: str, error: Optional[str] = None) -> dict[str, Any]:
|
|
row: dict[str, Any] = {
|
|
"id": row_id,
|
|
"label": label,
|
|
"output": output,
|
|
"bytes": len(output.encode("utf-8")),
|
|
}
|
|
if error:
|
|
row["error"] = error
|
|
return row
|
|
|
|
|
|
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.
|
|
|
|
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, config)
|
|
renderer = FORMAT_PREVIEWS.get(spec.get("preview") or "")
|
|
if renderer is None:
|
|
raise ValueError("this template field has no preview")
|
|
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 _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: 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 = _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)
|
|
|
|
overrides = spec.get("samples") or {}
|
|
names = [p["name"] for p in spec.get("placeholders", [])]
|
|
results = []
|
|
for scenario in SCENARIOS:
|
|
sid = scenario["id"]
|
|
fields = {}
|
|
for name in names:
|
|
override = overrides.get(name)
|
|
if override is not None and (not isinstance(override, dict) or sid in override):
|
|
fields[name] = _sample(override, sid)
|
|
elif name in ALL_PLACEHOLDERS:
|
|
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 _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):
|
|
return f"Unknown placeholder {{{exc.args[0]}}}"
|
|
if isinstance(exc, IndexError):
|
|
return "Numbered placeholders such as {0} aren't supported"
|
|
if isinstance(exc, ValueError):
|
|
return f"Unbalanced braces ({exc}); write {{{{ or }}}} for a literal brace"
|
|
return f"Template error: {exc}"
|
|
|
|
|
|
def _greeting_rows(text: str, row_id: str, label: str) -> list[dict[str, Any]]:
|
|
"""Rows for one greeting template, as GreeterCommand formats and sends it."""
|
|
from .commands.greeter_command import split_greeting_parts
|
|
|
|
parts = split_greeting_parts(text)
|
|
try:
|
|
formatted = [part.format(sender="Alice") for part in parts]
|
|
except Exception as exc: # noqa: BLE001 - mirrors the greeter's catch-all
|
|
return [_row(row_id, label, "", f"{_describe_format_error(exc)}. The greeter sends no greeting.")]
|
|
if len(formatted) == 1:
|
|
return [_row(row_id, label, formatted[0])]
|
|
return [
|
|
_row(f"{row_id}-{i}", f"{label}, message {i} of {len(formatted)}", part)
|
|
for i, part in enumerate(formatted, 1)
|
|
]
|
|
|
|
|
|
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, config: Optional[configparser.ConfigParser]
|
|
) -> Preview:
|
|
from .commands.greeter_command import parse_channel_greetings
|
|
|
|
greetings, ignored = parse_channel_greetings(template.strip())
|
|
rows = [
|
|
_row(f"ignored-{i}", "Ignored", fragment.strip(),
|
|
"Has no channel: in front and no entry before it to join, so the greeter skips it.")
|
|
for i, fragment in enumerate(ignored)
|
|
]
|
|
for key, entry in greetings.items():
|
|
rows.extend(_greeting_rows(entry["greeting"], f"ch-{key}", entry["channel"]))
|
|
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, config: Optional[configparser.ConfigParser]) -> Preview:
|
|
text, note = _blank_or(spec, template)
|
|
text = decode_escape_sequences(text)
|
|
try:
|
|
return _preview([_row("mesh_info", "Appended", text.format(**_MESH_INFO_SAMPLE))], note)
|
|
except Exception as exc: # noqa: BLE001 - the greeter catches everything here
|
|
return _preview([_row("mesh_info", "Appended", "",
|
|
f"{_describe_format_error(exc)}. The greeting goes out without mesh info.")], note)
|
|
|
|
|
|
_MULTITEST_PATHS = ["a1,c3,e5", "a1,b7,e5", "a1,c3,f2"]
|
|
|
|
|
|
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 _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 _preview([_row("reply", "Reply", text.format(**sample))])
|
|
except (KeyError, ValueError) as exc:
|
|
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 _preview([_row("reply", "Reply", "", f"{_describe_format_error(exc)}. The multitest reply fails.")])
|
|
|
|
|
|
_MQTT_SAMPLES = [
|
|
("full", "Full station reading", {
|
|
"time": "2026-10-04 14:30:00", "device": "WS-2902", "temperature_F": 61.3, "humidity": 72,
|
|
"dewpoint_f": 52.1, "pressure_hpa": 1016.4, "wind_current_kmh": 9.7, "wind_peak_kmh": 22.5,
|
|
"wind_direction_deg": 225, "rain_today_in": 0.12, "battery_voltage_v": 3.1,
|
|
}),
|
|
("partial", "Reading without humidity", {
|
|
"time": "2026-10-04 14:30:00", "device": "WS-2902", "temperature_F": 61.3,
|
|
}),
|
|
]
|
|
|
|
|
|
def _mqtt_reply_for_error(err: str) -> str:
|
|
"""The reply wx sends for a payload error (WeatherCommon._mqtt_weather_error_key, in English)."""
|
|
try:
|
|
path = Path(__file__).resolve().parent.parent / "translations" / "en.json"
|
|
wx = json.loads(path.read_text(encoding="utf-8"))["commands"]["wx"]
|
|
except (OSError, ValueError, KeyError):
|
|
wx = {}
|
|
if err in ("no_data", "empty_payload", "empty_after_sanitize"):
|
|
return wx.get("mqtt_weather_no_data", err)
|
|
return wx.get("mqtt_weather_payload_error", "{detail}").format(detail=err.replace("_", " "))
|
|
|
|
|
|
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
|
|
|
|
text, note = _blank_or(spec, template)
|
|
fmt = dataclasses.replace(
|
|
load_mqtt_weather_format_config(config or configparser.ConfigParser()),
|
|
output_mode="json_template",
|
|
json_template=text,
|
|
json_device_key="",
|
|
json_device_value="",
|
|
)
|
|
rows = []
|
|
for row_id, label, data in _MQTT_SAMPLES:
|
|
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, 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[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, 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."),
|
|
}
|