Compare commits

..

18 Commits

Author SHA1 Message Date
Jack Kingsman f95745cb05 Updating changelog + build for 3.13.0 2026-04-30 20:31:32 -07:00
Jack Kingsman 39ba88bc4b Fix up e2e tests 2026-04-30 20:22:59 -07:00
Jack Kingsman e814653300 Add non-markdown option. Closes #232. 2026-04-30 19:54:43 -07:00
Jack Kingsman e76d922752 Add recieved time to packet display. Closes #238. 2026-04-30 19:07:50 -07:00
Jack Kingsman d0e02a42f8 Merge pull request #237 from Bjorkan/TraceFix
Return HTTP 422 for missing trace responses to avoid confusing proxies in front of RemoteTerm. Closes #236.
2026-04-30 18:51:24 -07:00
Jack Kingsman dbf14259dc Do full rewrite of 5xx => 4xx 2026-04-30 18:47:35 -07:00
Jack Kingsman a9ac87e668 Allow newlines in text input. Closes #234. 2026-04-30 18:36:36 -07:00
Björkan f710a1f2d9 Change failed trace from using 504 to instead use 422 2026-04-30 23:03:08 +02:00
Björkan 9f6c0f12c5 Don't include .codex file 2026-04-30 22:58:59 +02:00
Jack Kingsman 466f693c21 Fix page to dvh. Closes #233. 2026-04-28 14:41:56 -07:00
Jack Kingsman 16f87e640f Attempt up to three Apprise retries. Closes #232. 2026-04-28 14:40:14 -07:00
Jack Kingsman 761fd82da6 Backoff MQTT failures all the way up to 1hr on connection failure, and also don't multi-toast on connection error. Closes #231. 2026-04-28 12:00:03 -07:00
Jack Kingsman 2c1279eb9e Add error rate percentage to metrics graph 2026-04-27 11:21:02 -07:00
Jack Kingsman 047d713003 Permit hourly checks for direct/routed repeaters. Closes #226. 2026-04-27 09:51:57 -07:00
Jack Kingsman 25041e1367 Add dynamic text replacement. Closes #223. 2026-04-25 15:00:36 -07:00
Jack Kingsman b3fe717416 Correct packet sum for repeater error rate. Closes #225. 2026-04-25 14:48:44 -07:00
Jack Kingsman 9a4e78c504 Show RX error percentage 2026-04-25 14:01:39 -07:00
Jack Kingsman d436de67a2 Merge pull request #224 from jkingsman/repeater-error-count
Repeater error count
2026-04-25 13:54:42 -07:00
59 changed files with 1846 additions and 323 deletions
+1
View File
@@ -25,6 +25,7 @@ references/
# ancillary LLM files
.claude/
.codex
# local Docker compose files
docker-compose.yml
+15
View File
@@ -1,3 +1,18 @@
## [3.13.0] - 2026-04-30
* Feature: Error counts included in repeater telemetry
* Feature: RX error rate + percentage surfaced and tracked for repeaters
* Feature: Dynamic as-you-type text replacement for Cyrillic byte optimization
* Feature: Permit hourly checks for direct/routed repeaters
* Feature: Allow newlines in input
* Feature: Packet-send radio time added to packet analyzer
* Feature: Enable forced plaintext for Apprise
* Bugfix: Less annoying MQTT failure notifications with backoff
* Bugfis: Don't obscure input; use dvh everywhere
* Bugfix: Clearer save button for advert interval
* Misc: Library updates
* Misc: Rewrite 5xx to 4xx to avoid issues with proxies that don't react well to 503/504
## [3.12.3] - 2026-04-24
* Feature: Customizable Apprise strings
+1 -1
View File
@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
</details>
### meshcore (2.3.2) — MIT
### meshcore (2.3.7) — MIT
<details>
<summary>Full license text</summary>
+93 -24
View File
@@ -11,6 +11,9 @@ from app.path_utils import split_path_hex
logger = logging.getLogger(__name__)
_MAX_SEND_ATTEMPTS = 3
_RETRY_DELAY_S = 2
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
DEFAULT_BODY_FORMAT_CHANNEL = (
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
@@ -18,6 +21,12 @@ DEFAULT_BODY_FORMAT_CHANNEL = (
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
# Plain-text variants (no markdown formatting)
DEFAULT_BODY_FORMAT_DM_PLAIN = "DM: {sender_name}: {text} via: [{hops}]"
DEFAULT_BODY_FORMAT_CHANNEL_PLAIN = "{channel_name}: {sender_name}: {text} via: [{hops}]"
_DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN = "DM: {sender_name}: {text}"
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN = "{channel_name}: {sender_name}: {text}"
# Variables available for user format strings
FORMAT_VARIABLES = (
"type",
@@ -130,10 +139,17 @@ def _apply_format(fmt: str, variables: dict[str, str]) -> str:
def _format_body(
data: dict,
*,
body_format_dm: str = DEFAULT_BODY_FORMAT_DM,
body_format_channel: str = DEFAULT_BODY_FORMAT_CHANNEL,
body_format_dm: str | None = None,
body_format_channel: str | None = None,
markdown: bool = True,
) -> str:
"""Build a notification body from message data using format strings."""
if body_format_dm is None:
body_format_dm = DEFAULT_BODY_FORMAT_DM if markdown else DEFAULT_BODY_FORMAT_DM_PLAIN
if body_format_channel is None:
body_format_channel = (
DEFAULT_BODY_FORMAT_CHANNEL if markdown else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
)
variables = _build_template_vars(data)
msg_type = data.get("type", "")
fmt = body_format_dm if msg_type == "PRIV" else body_format_channel
@@ -141,13 +157,21 @@ def _format_body(
return _apply_format(fmt, variables)
except Exception:
logger.warning("Apprise format string error, falling back to default")
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
if markdown:
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
else:
default = (
DEFAULT_BODY_FORMAT_DM_PLAIN
if msg_type == "PRIV"
else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
)
return _apply_format(default, variables)
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool, markdown: bool = True) -> bool:
"""Send notification synchronously via Apprise. Returns True on success."""
import apprise as apprise_lib
from apprise import NotifyFormat
urls = _parse_urls(urls_raw)
if not urls:
@@ -159,7 +183,8 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
url = _normalize_discord_url(url)
notifier.add(url)
return bool(notifier.notify(title="", body=body))
body_fmt = NotifyFormat.MARKDOWN if markdown else NotifyFormat.TEXT
return bool(notifier.notify(title="", body=body, body_format=body_fmt))
class AppriseModule(FanoutModule):
@@ -178,6 +203,7 @@ class AppriseModule(FanoutModule):
return
preserve_identity = self.config.get("preserve_identity", True)
markdown = self.config.get("markdown_format", True)
# Read format strings; treat empty/whitespace as unset (use default).
# Fall back to legacy include_path for pre-migration configs.
@@ -186,30 +212,73 @@ class AppriseModule(FanoutModule):
if body_format_dm is None or body_format_channel is None:
include_path = self.config.get("include_path", True)
if body_format_dm is None:
body_format_dm = (
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
)
if markdown:
body_format_dm = (
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
)
else:
body_format_dm = (
DEFAULT_BODY_FORMAT_DM_PLAIN
if include_path
else _DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN
)
if body_format_channel is None:
body_format_channel = (
DEFAULT_BODY_FORMAT_CHANNEL
if include_path
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
)
if markdown:
body_format_channel = (
DEFAULT_BODY_FORMAT_CHANNEL
if include_path
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
)
else:
body_format_channel = (
DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
if include_path
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN
)
body = _format_body(
data, body_format_dm=body_format_dm, body_format_channel=body_format_channel
data,
body_format_dm=body_format_dm,
body_format_channel=body_format_channel,
markdown=markdown,
)
try:
success = await asyncio.to_thread(
_send_sync, urls, body, preserve_identity=preserve_identity
)
self._set_last_error(None if success else "Apprise notify returned failure")
if not success:
logger.warning("Apprise notification failed for module %s", self.config_id)
except Exception as exc:
self._set_last_error(str(exc))
logger.exception("Apprise send error for module %s", self.config_id)
last_exc: Exception | None = None
for attempt in range(_MAX_SEND_ATTEMPTS):
try:
success = await asyncio.to_thread(
_send_sync,
urls,
body,
preserve_identity=preserve_identity,
markdown=markdown,
)
if success:
self._set_last_error(None)
return
logger.warning(
"Apprise notification failed for module %s (attempt %d/%d)",
self.config_id,
attempt + 1,
_MAX_SEND_ATTEMPTS,
)
except Exception as exc:
last_exc = exc
logger.warning(
"Apprise send error for module %s (attempt %d/%d): %s",
self.config_id,
attempt + 1,
_MAX_SEND_ATTEMPTS,
exc,
)
if attempt < _MAX_SEND_ATTEMPTS - 1:
await asyncio.sleep(_RETRY_DELAY_S)
# All attempts exhausted
if last_exc is not None:
self._set_last_error(str(last_exc))
else:
self._set_last_error("Apprise notify returned failure")
@property
def status(self) -> str:
+1 -1
View File
@@ -245,7 +245,7 @@ def _get_client_version() -> str:
class CommunityMqttPublisher(BaseMqttPublisher):
"""Manages the community MQTT connection and publishes raw packets."""
_backoff_max = 60
_backoff_max = 3600
_log_prefix = "Community MQTT"
_not_configured_timeout: float | None = 30
+1 -1
View File
@@ -27,7 +27,7 @@ class PrivateMqttSettings(Protocol):
class MqttPublisher(BaseMqttPublisher):
"""Manages an MQTT connection and publishes mesh network events."""
_backoff_max = 30
_backoff_max = 3600
_log_prefix = "MQTT"
def _is_configured(self) -> bool:
+8 -3
View File
@@ -65,6 +65,7 @@ class BaseMqttPublisher(ABC):
self.connected: bool = False
self.integration_name: str = ""
self._last_error: str | None = None
self._error_notified: bool = False
def set_integration_name(self, name: str) -> None:
"""Attach the configured fanout-module name for operator-facing logs."""
@@ -104,6 +105,7 @@ class BaseMqttPublisher(ABC):
self._client = None
self.connected = False
self._last_error = None
self._error_notified = False
async def restart(self, settings: object) -> None:
"""Called when settings change — stop + start."""
@@ -217,6 +219,7 @@ class BaseMqttPublisher(ABC):
self._client = client
self.connected = True
self._last_error = None
self._error_notified = False
backoff = _BACKOFF_MIN
title, detail = self._on_connected(settings)
@@ -281,9 +284,11 @@ class BaseMqttPublisher(ABC):
)
return
title, detail = self._on_error()
broadcast_error(title, detail)
_broadcast_health()
if not self._error_notified:
title, detail = self._on_error()
broadcast_error(title, detail)
_broadcast_health()
self._error_notified = True
logger.warning(
"%s connection error. This is usually transient network noise; "
"if it self-resolves, it is generally not a concern: %s "
+1 -1
View File
@@ -316,7 +316,7 @@ def _device_payload(
class _HaMqttPublisher(BaseMqttPublisher):
"""Thin MQTT lifecycle wrapper for the HA discovery module."""
_backoff_max = 30
_backoff_max = 3600
_log_prefix = "HA-MQTT"
def __init__(self) -> None:
+2 -2
View File
@@ -176,8 +176,8 @@ app.add_middleware(
@app.exception_handler(RadioDisconnectedError)
async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError):
"""Return 503 when a radio disconnect race occurs during an operation."""
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
"""Return 423 when a radio disconnect race occurs during an operation."""
return JSONResponse(status_code=423, content={"detail": "Radio not connected"})
@app.middleware("http")
@@ -0,0 +1,20 @@
import logging
import aiosqlite
logger = logging.getLogger(__name__)
async def migrate(conn: aiosqlite.Connection) -> None:
"""Add telemetry_routed_hourly boolean column to app_settings."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "telemetry_routed_hourly" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN telemetry_routed_hourly INTEGER DEFAULT 0"
)
await conn.commit()
+9
View File
@@ -448,6 +448,8 @@ class RawPacketDecryptedInfo(BaseModel):
sender: str | None = None
channel_key: str | None = None
contact_key: str | None = None
sender_timestamp: int | None = None
message: str | None = None
class RawPacketBroadcast(BaseModel):
@@ -855,6 +857,13 @@ class AppSettings(BaseModel):
"tracked repeaters so daily checks stay under a 24/day ceiling."
),
)
telemetry_routed_hourly: bool = Field(
default=False,
description=(
"When enabled, tracked repeaters with a direct or routed (non-flood) "
"path are polled every hour instead of on the normal scheduled interval."
),
)
auto_resend_channel: bool = Field(
default=False,
description=(
+6
View File
@@ -366,6 +366,8 @@ async def process_raw_packet(
sender=result["sender"],
channel_key=result.get("channel_key"),
contact_key=result.get("contact_key"),
sender_timestamp=result.get("sender_timestamp"),
message=result.get("message"),
)
if result["decrypted"]
else None,
@@ -428,6 +430,8 @@ async def _process_group_text(
"sender": decrypted.sender,
"message_id": msg_id, # None if duplicate, msg_id if new
"channel_key": channel.key,
"sender_timestamp": decrypted.timestamp,
"message": decrypted.message,
}
# Couldn't decrypt with any known key
@@ -694,6 +698,8 @@ async def _process_direct_message(
"sender": contact.name or contact.public_key[:12],
"message_id": msg_id,
"contact_key": contact.public_key,
"sender_timestamp": result.timestamp,
"message": result.message,
}
# Couldn't decrypt with any known contact
+38 -11
View File
@@ -1890,8 +1890,13 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
return False
async def _run_telemetry_cycle() -> None:
"""Collect one telemetry sample from every tracked repeater."""
async def _run_telemetry_cycle(*, routed_only: bool = False) -> None:
"""Collect one telemetry sample from tracked repeaters.
When *routed_only* is True, only repeaters whose effective route is
``"direct"`` or ``"override"`` (i.e. not ``"flood"``) are collected.
This is used by the hourly routed-path fast-poll feature.
"""
if not radio_manager.is_connected:
logger.debug("Telemetry collect: radio not connected, skipping cycle")
return
@@ -1901,9 +1906,7 @@ async def _run_telemetry_cycle() -> None:
if not tracked:
return
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
collected = 0
candidates: list[tuple[str, Contact]] = []
for pub_key in tracked:
contact = await ContactRepository.get_by_key(pub_key)
if not contact or contact.type != 2:
@@ -1912,7 +1915,24 @@ async def _run_telemetry_cycle() -> None:
pub_key[:12],
)
continue
if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0):
continue
candidates.append((pub_key, contact))
if not candidates:
if routed_only:
logger.debug("Telemetry collect: no routed repeaters to poll this hour")
return
label = "routed" if routed_only else "full"
logger.info(
"Telemetry collect: starting %s cycle for %d repeater(s)",
label,
len(candidates),
)
collected = 0
for _pub_key, contact in candidates:
try:
async with radio_manager.radio_operation(
"telemetry_collect",
@@ -1924,13 +1944,14 @@ async def _run_telemetry_cycle() -> None:
except RadioOperationBusyError:
logger.debug(
"Telemetry collect: radio busy, skipping %s",
pub_key[:12],
contact.public_key[:12],
)
logger.info(
"Telemetry collect: cycle complete, %d/%d successful",
"Telemetry collect: %s cycle complete, %d/%d successful",
label,
collected,
len(tracked),
len(candidates),
)
@@ -1960,9 +1981,15 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None:
effective_hours = clamp_telemetry_interval(app_settings.telemetry_interval_hours, tracked_count)
if effective_hours <= 0:
return
if now.hour % effective_hours != 0:
return
await _run_telemetry_cycle()
is_normal_cycle = now.hour % effective_hours == 0
if is_normal_cycle:
# Normal scheduled boundary: collect ALL tracked repeaters.
await _run_telemetry_cycle()
elif app_settings.telemetry_routed_hourly:
# Hourly routed-path fast-poll: only repeaters with a non-flood route.
await _run_telemetry_cycle(routed_only=True)
async def _telemetry_collect_loop() -> None:
+15 -1
View File
@@ -42,7 +42,7 @@ class AppSettingsRepository:
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types,
tracked_telemetry_repeaters, auto_resend_channel,
telemetry_interval_hours
telemetry_interval_hours, telemetry_routed_hourly
FROM app_settings WHERE id = 1
"""
) as cursor:
@@ -113,6 +113,12 @@ class AppSettingsRepository:
except (KeyError, TypeError, ValueError):
telemetry_interval_hours = DEFAULT_TELEMETRY_INTERVAL_HOURS
# Parse telemetry_routed_hourly boolean
try:
telemetry_routed_hourly = bool(row["telemetry_routed_hourly"])
except (KeyError, TypeError):
telemetry_routed_hourly = False
return AppSettings(
max_radio_contacts=row["max_radio_contacts"],
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
@@ -126,6 +132,7 @@ class AppSettingsRepository:
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
auto_resend_channel=auto_resend_channel,
telemetry_interval_hours=telemetry_interval_hours,
telemetry_routed_hourly=telemetry_routed_hourly,
)
@staticmethod
@@ -144,6 +151,7 @@ class AppSettingsRepository:
tracked_telemetry_repeaters: list[str] | None = None,
auto_resend_channel: bool | None = None,
telemetry_interval_hours: int | None = None,
telemetry_routed_hourly: bool | None = None,
) -> None:
"""Apply field updates using an already-acquired connection.
@@ -201,6 +209,10 @@ class AppSettingsRepository:
updates.append("telemetry_interval_hours = ?")
params.append(telemetry_interval_hours)
if telemetry_routed_hourly is not None:
updates.append("telemetry_routed_hourly = ?")
params.append(1 if telemetry_routed_hourly else 0)
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
async with conn.execute(query, params):
@@ -229,6 +241,7 @@ class AppSettingsRepository:
tracked_telemetry_repeaters: list[str] | None = None,
auto_resend_channel: bool | None = None,
telemetry_interval_hours: int | None = None,
telemetry_routed_hourly: bool | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
async with db.tx() as conn:
@@ -246,6 +259,7 @@ class AppSettingsRepository:
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
auto_resend_channel=auto_resend_channel,
telemetry_interval_hours=telemetry_interval_hours,
telemetry_routed_hourly=telemetry_routed_hourly,
)
return await AppSettingsRepository._get_in_conn(conn)
+6 -6
View File
@@ -66,11 +66,11 @@ async def _resolve_contact_or_404(
async def _ensure_on_radio(mc, contact: Contact) -> None:
"""Add a contact to the radio for routing, raising 500 on failure."""
"""Add a contact to the radio for routing, raising 422 on failure."""
add_result = await mc.commands.add_contact(contact.to_radio_dict())
if add_result is not None and add_result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to add contact to radio: {add_result.payload}"
status_code=422, detail=f"Failed to add contact to radio: {add_result.payload}"
)
@@ -452,7 +452,7 @@ async def request_trace(public_key: str) -> TraceResponse:
)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
raise HTTPException(status_code=422, detail=f"Failed to send trace: {result.payload}")
# Wait for the matching TRACE_DATA event
event = await mc.wait_for_event(
@@ -462,7 +462,7 @@ async def request_trace(public_key: str) -> TraceResponse:
)
if event is None:
raise HTTPException(status_code=504, detail="No trace response heard")
raise HTTPException(status_code=408, detail="No trace response heard")
trace = event.payload
path = trace.get("path", [])
@@ -506,7 +506,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
result = await mc.commands.send_path_discovery(contact.public_key)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
status_code=422,
detail=f"Failed to send path discovery: {result.payload}",
)
@@ -518,7 +518,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
await response_task
if event is None:
raise HTTPException(status_code=504, detail="No path discovery response heard")
raise HTTPException(status_code=408, detail="No path discovery response heard")
payload = event.payload
forward_path = str(payload.get("out_path") or "")
+4
View File
@@ -274,6 +274,10 @@ def _validate_apprise_config(config: dict) -> None:
status_code=400, detail=f"Invalid format string in {field}"
) from None
markdown_format = config.get("markdown_format")
if markdown_format is not None:
config["markdown_format"] = bool(markdown_format)
def _validate_webhook_config(config: dict) -> None:
"""Validate webhook config blob."""
+4
View File
@@ -128,11 +128,15 @@ async def get_raw_packet(packet_id: int) -> RawPacketDetail:
sender=message.sender_name,
channel_key=message.conversation_key,
contact_key=message.sender_key,
sender_timestamp=message.sender_timestamp,
message=message.text,
)
else:
decrypted_info = RawPacketDecryptedInfo(
sender=message.sender_name,
contact_key=message.conversation_key,
sender_timestamp=message.sender_timestamp,
message=message.text,
)
return RawPacketDetail(
+5 -5
View File
@@ -48,7 +48,7 @@ async def vapid_public_key() -> VapidPublicKeyResponse:
"""Return the VAPID public key for browser PushManager.subscribe()."""
key = get_vapid_public_key()
if not key:
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
raise HTTPException(status_code=423, detail="VAPID keys not initialized")
return VapidPublicKeyResponse(public_key=key)
@@ -103,7 +103,7 @@ async def test_push(subscription_id: str) -> dict:
vapid_key = get_vapid_private_key()
if not vapid_key:
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
raise HTTPException(status_code=423, detail="VAPID keys not initialized")
payload = json.dumps(
{
@@ -127,7 +127,7 @@ async def test_push(subscription_id: str) -> dict:
)
return {"status": "sent"}
except TimeoutError:
raise HTTPException(status_code=504, detail="Push delivery timed out") from None
raise HTTPException(status_code=408, detail="Push delivery timed out") from None
except WebPushException as e:
status_code = getattr(getattr(e, "response", None), "status_code", 0)
if status_code in (403, 404, 410):
@@ -143,10 +143,10 @@ async def test_push(subscription_id: str) -> dict:
"Re-enable push from a conversation header.",
) from None
logger.warning("Test push failed: %s", e)
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None
raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None
except Exception as e:
logger.warning("Test push failed: %s", e)
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None
raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None
# ── Global push conversation management ──────────────────────────────────
+15 -15
View File
@@ -338,7 +338,7 @@ async def get_radio_config() -> RadioConfigResponse:
info = mc.self_info
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
raise HTTPException(status_code=423, detail="Radio info not available")
adv_loc_policy = info.get("adv_loc_policy", 1)
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
@@ -380,7 +380,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
except PathHashModeUnsupportedError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RadioCommandRejectedError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
raise HTTPException(status_code=422, detail=str(exc)) from exc
return await get_radio_config()
@@ -430,7 +430,7 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
export_and_store_private_key_fn=export_and_store_private_key,
)
except (RadioCommandRejectedError, KeystoreRefreshError) as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
raise HTTPException(status_code=422, detail=str(exc)) from exc
return {"status": "ok"}
@@ -454,7 +454,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
success = await do_send_advertisement(mc, force=True, mode=mode)
if not success:
raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement")
raise HTTPException(status_code=422, detail=f"Failed to send {mode} advertisement")
return {"status": "ok"}
@@ -486,7 +486,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
tag=tag,
)
if send_result is None or send_result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to start mesh discovery")
raise HTTPException(status_code=422, detail="Failed to start mesh discovery")
deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS
results_by_key: dict[str, RadioDiscoveryResult] = {}
@@ -538,7 +538,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
if len(local_public_key) != 64:
raise HTTPException(status_code=503, detail="Local radio public key is unavailable")
raise HTTPException(status_code=423, detail="Local radio public key is unavailable")
local_name = (mc.self_info or {}).get("name")
response_task = asyncio.create_task(
@@ -555,13 +555,13 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
flags=trace_flags,
)
if send_result is None or send_result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to send trace")
raise HTTPException(status_code=422, detail="Failed to send trace")
timeout_seconds = _trace_timeout_seconds(send_result)
try:
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
except TimeoutError as exc:
raise HTTPException(status_code=504, detail="No trace response heard") from exc
raise HTTPException(status_code=408, detail="No trace response heard") from exc
finally:
if not response_task.done():
response_task.cancel()
@@ -569,12 +569,12 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
await response_task
if event is None:
raise HTTPException(status_code=504, detail="No trace response heard")
raise HTTPException(status_code=408, detail="No trace response heard")
payload = event.payload if isinstance(event.payload, dict) else {}
path_len = payload.get("path_len")
if not isinstance(path_len, int):
raise HTTPException(status_code=500, detail="Trace response was malformed")
raise HTTPException(status_code=422, detail="Trace response was malformed")
raw_path = payload.get("path")
path_nodes = raw_path if isinstance(raw_path, list) else []
@@ -588,7 +588,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
if len(hashed_nodes) < len(trace_nodes):
raise HTTPException(status_code=500, detail="Trace response was incomplete")
raise HTTPException(status_code=422, detail="Trace response was incomplete")
nodes: list[RadioTraceNode] = []
for index, trace_node in enumerate(trace_nodes):
@@ -641,13 +641,13 @@ async def _attempt_reconnect() -> dict:
except Exception as e:
logger.exception("Post-connect setup failed after reconnect")
raise HTTPException(
status_code=503,
status_code=423,
detail=f"Radio connected but setup failed: {e}",
) from e
if not success:
raise HTTPException(
status_code=503, detail="Failed to reconnect. Check radio connection and power."
status_code=423, detail="Failed to reconnect. Check radio connection and power."
)
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
@@ -702,14 +702,14 @@ async def reconnect_radio() -> dict:
logger.info("Radio connected but setup incomplete, retrying setup")
try:
if not await _prepare_connected(broadcast_on_success=True):
raise HTTPException(status_code=503, detail="Radio connection is paused")
raise HTTPException(status_code=423, detail="Radio connection is paused")
return {"status": "ok", "message": "Setup completed", "connected": True}
except HTTPException:
raise
except Exception as e:
logger.exception("Post-connect setup failed")
raise HTTPException(
status_code=503,
status_code=423,
detail=f"Radio connected but setup failed: {e}",
) from e
+2 -2
View File
@@ -113,7 +113,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
logger.debug("LPP sensor fetch failed for %s (non-fatal): %s", public_key[:12], e)
if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater")
raise HTTPException(status_code=408, detail="No status response from repeater")
response = RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
@@ -222,7 +222,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
)
if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from repeater")
raise HTTPException(status_code=408, detail="No telemetry response from repeater")
sensors: list[LppSensor] = []
for entry in telemetry:
+2 -2
View File
@@ -58,7 +58,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
if status is None:
raise HTTPException(status_code=504, detail="No status response from room server")
raise HTTPException(status_code=408, detail="No status response from room server")
return RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
@@ -98,7 +98,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
)
if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from room server")
raise HTTPException(status_code=408, detail="No telemetry response from room server")
sensors = [
LppSensor(
+1 -1
View File
@@ -291,7 +291,7 @@ async def send_contact_cli_command(
if send_result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send command: {send_result.payload}"
status_code=422, detail=f"Failed to send command: {send_result.payload}"
)
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
+44 -5
View File
@@ -73,6 +73,13 @@ class AppSettingsUpdate(BaseModel):
"based on the current tracked-repeater count."
),
)
telemetry_routed_hourly: bool | None = Field(
default=None,
description=(
"When enabled, tracked repeaters with a direct or routed (non-flood) "
"path are polled every hour instead of on the normal scheduled interval."
),
)
class BlockKeyRequest(BaseModel):
@@ -126,7 +133,18 @@ class TelemetrySchedule(BaseModel):
max_tracked: int = Field(description="Maximum number of repeaters that can be tracked")
next_run_at: int | None = Field(
default=None,
description="Unix timestamp (UTC seconds) of the next scheduled cycle",
description="Unix timestamp (UTC seconds) of the next scheduled flood cycle",
)
routed_hourly: bool = Field(
default=False,
description="Whether hourly routed/direct-path telemetry is enabled",
)
next_routed_run_at: int | None = Field(
default=None,
description=(
"Unix timestamp (UTC seconds) of the next hourly routed/direct check, "
"or None when routed_hourly is off or no repeaters are tracked"
),
)
@@ -140,20 +158,27 @@ class TrackedTelemetryResponse(BaseModel):
schedule: TelemetrySchedule = Field(description="Current scheduling state")
def _build_schedule(tracked_count: int, preferred_hours: int | None) -> TelemetrySchedule:
def _build_schedule(
tracked_count: int,
preferred_hours: int | None,
routed_hourly: bool = False,
) -> TelemetrySchedule:
pref = (
preferred_hours
if preferred_hours in TELEMETRY_INTERVAL_OPTIONS_HOURS
else DEFAULT_TELEMETRY_INTERVAL_HOURS
)
effective = clamp_telemetry_interval(pref, tracked_count)
has_tracked = tracked_count > 0
return TelemetrySchedule(
preferred_hours=pref,
effective_hours=effective,
options=legal_interval_options(tracked_count),
tracked_count=tracked_count,
max_tracked=MAX_TRACKED_TELEMETRY_REPEATERS,
next_run_at=next_run_timestamp_utc(effective) if tracked_count > 0 else None,
next_run_at=next_run_timestamp_utc(effective) if has_tracked else None,
routed_hourly=routed_hourly,
next_routed_run_at=(next_run_timestamp_utc(1) if has_tracked and routed_hourly else None),
)
@@ -216,6 +241,11 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating telemetry_interval_hours to %d", raw_interval)
kwargs["telemetry_interval_hours"] = raw_interval
# Telemetry routed hourly
if update.telemetry_routed_hourly is not None:
logger.info("Updating telemetry_routed_hourly to %s", update.telemetry_routed_hourly)
kwargs["telemetry_routed_hourly"] = update.telemetry_routed_hourly
# Flood scope
flood_scope_changed = False
if update.flood_scope is not None:
@@ -328,7 +358,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
return TrackedTelemetryResponse(
tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list),
schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours),
schedule=_build_schedule(
len(new_list),
settings.telemetry_interval_hours,
settings.telemetry_routed_hourly,
),
)
# Validate it's a repeater
@@ -355,7 +389,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
return TrackedTelemetryResponse(
tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list),
schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours),
schedule=_build_schedule(
len(new_list),
settings.telemetry_interval_hours,
settings.telemetry_routed_hourly,
),
)
@@ -371,4 +409,5 @@ async def get_telemetry_schedule() -> TelemetrySchedule:
return _build_schedule(
len(app_settings.tracked_telemetry_repeaters),
app_settings.telemetry_interval_hours,
app_settings.telemetry_routed_hourly,
)
+16 -16
View File
@@ -159,7 +159,7 @@ async def send_channel_message_with_effective_scope(
override_result.payload,
)
raise HTTPException(
status_code=500,
status_code=422,
detail=(
f"Failed to apply regional override {override_scope!r} before {action_label}: "
f"{override_result.payload}"
@@ -189,7 +189,7 @@ async def send_channel_message_with_effective_scope(
phm_result.payload,
)
raise HTTPException(
status_code=500,
status_code=422,
detail=(
f"Failed to apply path hash mode override before {action_label}: "
f"{phm_result.payload}"
@@ -233,7 +233,7 @@ async def send_channel_message_with_effective_scope(
set_result.payload,
)
raise HTTPException(
status_code=500,
status_code=422,
detail=f"Failed to configure channel on radio before {action_label}",
)
radio_manager.note_channel_slot_loaded(channel_key, channel_slot)
@@ -256,7 +256,7 @@ async def send_channel_message_with_effective_scope(
action_label,
channel.name,
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if send_result.type == EventType.ERROR:
logger.error(
"Radio returned error during %s for channel %s: %s",
@@ -598,10 +598,10 @@ async def send_direct_message_to_contact(
"No response from radio after direct send to %s; send outcome is unknown",
contact.public_key[:12],
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
raise HTTPException(status_code=422, detail=f"Failed to send message: {result.payload}")
message = await create_outgoing_direct_message(
conversation_key=contact.public_key.lower(),
@@ -613,7 +613,7 @@ async def send_direct_message_to_contact(
)
if message is None:
raise HTTPException(
status_code=500,
status_code=422,
detail="Failed to store outgoing message - unexpected duplicate",
)
finally:
@@ -626,7 +626,7 @@ async def send_direct_message_to_contact(
)
if sent_at is None or sender_timestamp is None or message is None or result is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
raise HTTPException(status_code=422, detail="Failed to store outgoing message")
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
@@ -791,7 +791,7 @@ async def send_channel_message_to_channel(
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
status_code=422,
detail="Failed to store outgoing message - unexpected duplicate",
)
@@ -813,11 +813,11 @@ async def send_channel_message_to_channel(
"No response from radio after channel send to %s; send outcome is unknown",
channel.name,
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send message: {result.payload}"
status_code=422, detail=f"Failed to send message: {result.payload}"
)
except Exception:
if outgoing_message is not None:
@@ -834,7 +834,7 @@ async def send_channel_message_to_channel(
)
if sent_at is None or sender_timestamp is None or outgoing_message is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
raise HTTPException(status_code=422, detail="Failed to store outgoing message")
outgoing_message = await build_stored_outgoing_channel_message(
message_id=outgoing_message.id,
@@ -928,7 +928,7 @@ async def resend_channel_message_record(
)
if new_message is None:
raise HTTPException(
status_code=500,
status_code=422,
detail="Failed to store resent message - unexpected duplicate",
)
@@ -949,10 +949,10 @@ async def resend_channel_message_record(
"No response from radio after channel resend to %s; send outcome is unknown",
channel.name,
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
status_code=422,
detail=f"Failed to resend message: {result.payload}",
)
except Exception:
@@ -971,7 +971,7 @@ async def resend_channel_message_record(
if new_timestamp:
if sent_at is None or new_message is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
raise HTTPException(status_code=422, detail="Failed to assign resend timestamp")
new_message = await build_stored_outgoing_channel_message(
message_id=new_message.id,
+3 -3
View File
@@ -52,12 +52,12 @@ class RadioRuntime:
def require_connected(self):
"""Return MeshCore when available, mirroring existing HTTP semantics."""
if self.is_setup_in_progress:
raise HTTPException(status_code=503, detail="Radio is initializing")
raise HTTPException(status_code=423, detail="Radio is initializing")
if not self.is_connected:
raise HTTPException(status_code=503, detail="Radio not connected")
raise HTTPException(status_code=423, detail="Radio not connected")
mc = self.meshcore
if mc is None:
raise HTTPException(status_code=503, detail="Radio not connected")
raise HTTPException(status_code=423, detail="Radio not connected")
return mc
@asynccontextmanager
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.12.3",
"version": "3.13.0",
"type": "module",
"scripts": {
"dev": "vite",
+65 -14
View File
@@ -4,14 +4,20 @@ import {
useImperativeHandle,
forwardRef,
useRef,
useEffect,
useMemo,
type ChangeEvent,
type FormEvent,
type KeyboardEvent,
} from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
import { cn } from '@/lib/utils';
import {
getTextReplaceEnabled,
getTextReplaceMapJson,
applyTextReplacements,
} from '../utils/textReplace';
// MeshCore message size limits (empirically determined from LoRa packet constraints)
// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth.
@@ -53,19 +59,32 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
) {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
/** Resize textarea to fit content, clamped between 1 row and ~6 rows. */
const autoResize = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
// Clamp: min 40px (≈1 row), max 160px (≈6 rows)
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
}, []);
useImperativeHandle(ref, () => ({
appendText: (appendedText: string) => {
setText((prev) => prev + appendedText);
// Focus the input after appending
inputRef.current?.focus();
textareaRef.current?.focus();
},
focus: () => {
inputRef.current?.focus();
textareaRef.current?.focus();
},
}));
// Re-measure height whenever text changes (covers programmatic updates like appendText)
useEffect(() => {
autoResize();
}, [text, autoResize]);
// Calculate character limits based on conversation type
const limits = useMemo(() => {
if (conversationType === 'contact') {
@@ -133,18 +152,44 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
} finally {
setSending(false);
}
// Refocus after React re-enables the input
setTimeout(() => inputRef.current?.focus(), 0);
// Refocus after React re-enables the textarea
setTimeout(() => textareaRef.current?.focus(), 0);
},
[text, sending, disabled, onSend]
);
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const input = e.target;
const raw = input.value;
// Skip replacement during IME / dead-key composition to avoid garbling interim input
if (!e.nativeEvent || (e.nativeEvent as InputEvent).isComposing) {
setText(raw);
return;
}
if (getTextReplaceEnabled()) {
const result = applyTextReplacements(
raw,
input.selectionStart ?? raw.length,
getTextReplaceMapJson()
);
if (result) {
setText(result.text);
// Schedule cursor restore after React flushes the new value
const pos = result.cursor;
requestAnimationFrame(() => input.setSelectionRange(pos, pos));
return;
}
}
setText(raw);
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as unknown as FormEvent);
}
// Shift+Enter falls through naturally and inserts a newline
},
[handleSubmit]
);
@@ -162,22 +207,28 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
onSubmit={handleSubmit}
autoComplete="off"
>
<div className="flex gap-2">
<Input
ref={inputRef}
type="text"
<div className="flex gap-2 items-end">
<textarea
ref={textareaRef}
autoComplete="off"
name="chat-message-input"
aria-label={placeholder || 'Type a message'}
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder || 'Type a message...'}
disabled={disabled || sending}
className="flex-1 min-w-0"
className={cn(
'flex-1 min-w-0 resize-none overflow-y-auto',
'rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background',
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50 md:text-sm'
)}
style={{ minHeight: '40px', maxHeight: '160px' }}
/>
<Button
type="submit"
@@ -154,13 +154,19 @@ export function TelemetryHistoryPane({
const chartData = useMemo(() => {
return entries.map((e) => {
const d = e.data;
const recvErrors = d.recv_errors ?? undefined;
const packetsReceived = d.packets_received;
const point: Record<string, number | undefined> = {
timestamp: e.timestamp,
battery_volts: d.battery_volts,
noise_floor_dbm: d.noise_floor_dbm,
packets_received: d.packets_received,
packets_received: packetsReceived,
packets_sent: d.packets_sent,
recv_errors: d.recv_errors ?? undefined,
recv_errors: recvErrors,
recv_error_pct:
recvErrors != null && packetsReceived != null && packetsReceived + recvErrors > 0
? +((recvErrors / (packetsReceived + recvErrors)) * 100).toFixed(2)
: undefined,
uptime_seconds: d.uptime_seconds,
};
// Flatten LPP sensors into the point, converting units as needed
@@ -174,7 +180,11 @@ export function TelemetryHistoryPane({
}, [entries, distanceUnit]);
const dataKeys =
activeMetric === 'packets' ? ['packets_received', 'packets_sent'] : [activeMetric];
activeMetric === 'packets'
? ['packets_received', 'packets_sent']
: activeMetric === 'recv_errors'
? ['recv_errors', 'recv_error_pct']
: [activeMetric];
const yDomain = useMemo<[number, number] | undefined>(() => {
if (activeMetric !== 'battery_volts' || chartData.length === 0) return undefined;
@@ -185,6 +195,20 @@ export function TelemetryHistoryPane({
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
}, [activeMetric, chartData]);
const yDomainPct = useMemo<[number, number]>(() => {
const MIN_SPAN = 5;
const values = chartData.map((d) => d.recv_error_pct).filter((v) => v != null) as number[];
if (values.length === 0) return [0, MIN_SPAN];
const lo = Math.min(...values);
const hi = Math.max(...values);
const span = hi - lo;
if (span >= MIN_SPAN)
return [Math.max(0, Math.floor(lo - span * 0.1)), Math.ceil(hi + span * 0.1)];
const pad = (MIN_SPAN - span) / 2;
const bottom = Math.max(0, Math.floor(lo - pad));
return [bottom, Math.ceil(bottom + MIN_SPAN)];
}, [chartData]);
const handleToggle = async () => {
setToggling(true);
try {
@@ -221,16 +245,16 @@ export function TelemetryHistoryPane({
via the repeater pane, API calls to the endpoint (
<code className="text-[0.6875rem]">POST /api/contacts/&lt;key&gt;/repeater/status</code>
), or when the repeater is opted into interval telemetry polling, in which case the
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted
into this flow in the{' '}
repeater will be polled for metrics automatically. Fetch frequency can be configured in{' '}
<a
href="#settings/database"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Database &amp; Messaging
</a>{' '}
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
of keeping mesh congestion reasonable.
Settings &rarr; Database &amp; Messaging
</a>
, where you can also see which repeaters are currently opted in. A maximum of{' '}
{MAX_TRACKED} repeaters may be opted into this for the sake of keeping mesh congestion
reasonable.
</p>
{isTracked ? (
@@ -259,7 +283,7 @@ export function TelemetryHistoryPane({
disabled={toggling}
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
>
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
{toggling ? 'Updating...' : 'Opt Repeater into Interval Metrics Tracking'}
</Button>
)}
</div>
@@ -306,7 +330,15 @@ export function TelemetryHistoryPane({
</p>
) : (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<AreaChart
data={chartData}
margin={{
top: 4,
right: activeMetric === 'recv_errors' ? 8 : 4,
bottom: 0,
left: -8,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="timestamp"
@@ -318,6 +350,7 @@ export function TelemetryHistoryPane({
tickFormatter={formatTime}
/>
<YAxis
yAxisId="left"
domain={yDomain}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
@@ -326,6 +359,17 @@ export function TelemetryHistoryPane({
activeMetric === 'uptime_seconds' ? formatUptime(v) : `${v}`
}
/>
{activeMetric === 'recv_errors' && (
<YAxis
yAxisId="right"
orientation="right"
domain={yDomainPct}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${v}%`}
/>
)}
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
@@ -337,6 +381,10 @@ export function TelemetryHistoryPane({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const numVal = typeof value === 'number' ? value : Number(value);
if (activeMetric === 'recv_errors') {
if (name === 'recv_error_pct') return [`${numVal}%`, 'Error Rate'];
return [`${value}`, 'RX Errors'];
}
const display =
activeMetric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
const suffix =
@@ -354,51 +402,44 @@ export function TelemetryHistoryPane({
return [`${display}${suffix}`, label];
}}
/>
{dataKeys.map((key, i) => (
<Area
key={key}
type="linear"
dataKey={key}
stroke={
activeMetric === 'packets'
{dataKeys.map((key, i) => {
const color =
activeMetric === 'packets'
? i === 0
? '#0ea5e9'
: '#f43f5e'
: activeMetric === 'recv_errors'
? i === 0
? '#0ea5e9'
: '#f43f5e'
: activeConfig.color
}
fill={
activeMetric === 'packets'
? i === 0
? '#0ea5e9'
: '#f43f5e'
: activeConfig.color
}
fillOpacity={0.15}
strokeWidth={1.5}
dot={{
r: 4,
fill:
activeMetric === 'packets'
? i === 0
? '#0ea5e9'
: '#f43f5e'
: activeConfig.color,
strokeWidth: 1.5,
stroke: 'hsl(var(--popover))',
}}
activeDot={{
r: 6,
fill:
activeMetric === 'packets'
? i === 0
? '#0ea5e9'
: '#f43f5e'
: activeConfig.color,
strokeWidth: 2,
stroke: 'hsl(var(--popover))',
}}
/>
))}
? '#ef4444'
: '#f59e0b'
: activeConfig.color;
return (
<Area
key={key}
type="linear"
dataKey={key}
yAxisId={
activeMetric === 'recv_errors' && key === 'recv_error_pct' ? 'right' : 'left'
}
stroke={color}
fill={color}
fillOpacity={0.15}
strokeWidth={1.5}
dot={{
r: 4,
fill: color,
strokeWidth: 1.5,
stroke: 'hsl(var(--popover))',
}}
activeDot={{
r: 6,
fill: color,
strokeWidth: 2,
stroke: 'hsl(var(--popover))',
}}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
)}
@@ -92,7 +92,24 @@ export function TelemetryPane({
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
/>
{data.recv_errors != null && (
<KvRow label="RX Errors" value={data.recv_errors.toLocaleString()} />
<KvRow
label="RX Errors"
value={
<>
{data.recv_errors.toLocaleString()}
{data.packets_received > 0 && (
<Secondary>
(
{(
(data.recv_errors / (data.packets_received + data.recv_errors)) *
100
).toFixed(2)}
%)
</Secondary>
)}
</>
}
/>
)}
<Separator className="my-1" />
<KvRow label="TX Queue" value={data.tx_queue_len} />
@@ -92,7 +92,11 @@ export function SettingsDatabaseSection({
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters.length, appSettings.telemetry_interval_hours]);
}, [
trackedTelemetryRepeaters.length,
appSettings.telemetry_interval_hours,
appSettings.telemetry_routed_hourly,
]);
useEffect(() => {
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
@@ -346,13 +350,41 @@ export function SettingsDatabaseSection({
restored if you drop back to a supported count.
</p>
)}
{schedule?.next_run_at != null && (
<p className="text-xs text-muted-foreground">
Next run at {formatTime(schedule.next_run_at)} (UTC top of hour).
</p>
)}
</div>
{/* Routed hourly toggle */}
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={appSettings.telemetry_routed_hourly}
onChange={() => {
const next = !appSettings.telemetry_routed_hourly;
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
}}
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
/>
<div>
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
every hour instead of on the scheduled interval above. Flood-only repeaters still
follow the normal schedule.
</p>
</div>
</label>
{schedule?.next_run_at != null && (
<p className="text-xs text-muted-foreground">
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
{formatTime(schedule.next_run_at)} (UTC top of hour).
</p>
)}
{schedule?.next_routed_run_at != null && (
<p className="text-xs text-muted-foreground">
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
</p>
)}
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
@@ -362,6 +394,21 @@ export function SettingsDatabaseSection({
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const routeSource = contact?.effective_route_source ?? 'flood';
// A forced-flood override (path_len < 0) still reports source
// "override", but the actual route is flood. Check the real path.
const hasRealPath =
contact?.effective_route != null && contact.effective_route.path_len >= 0;
const routeLabel = !hasRealPath
? 'flood'
: routeSource === 'override'
? 'routed'
: routeSource === 'direct'
? 'direct'
: 'flood';
const routeColor = hasRealPath
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted';
const snap = latestTelemetry[key];
const d = snap?.data;
return (
@@ -369,9 +416,16 @@ export function SettingsDatabaseSection({
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<div className="flex items-center gap-1.5">
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<span
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
>
{routeLabel}
</span>
</div>
</div>
{onToggleTrackedTelemetry && (
<Button
@@ -287,6 +287,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
config: {
urls: '',
preserve_identity: true,
markdown_format: true,
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
body_format_channel:
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
@@ -2390,6 +2391,8 @@ function ScopeSelector({
const APPRISE_DEFAULT_DM = '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]';
const APPRISE_DEFAULT_CHANNEL =
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]';
const APPRISE_DEFAULT_DM_PLAIN = 'DM: {sender_name}: {text} via: [{hops}]';
const APPRISE_DEFAULT_CHANNEL_PLAIN = '{channel_name}: {sender_name}: {text} via: [{hops}]';
const APPRISE_SAMPLE_VARS: Record<string, string> = {
type: 'CHAN',
@@ -2420,19 +2423,32 @@ function appriseApplyFormat(fmt: string, vars: Record<string, string>): string {
return result;
}
/** Render a markdown-ish string into inline React elements (bold + code spans). */
/** Render a markdown-ish string into inline React elements (bold, italic, code). */
function appriseRenderMarkdown(s: string): ReactNode[] {
const nodes: ReactNode[] = [];
let key = 0;
// Split on **bold** and `code` spans
const parts = s.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
// Split on **bold**, __bold__, *italic*, _italic_, and `code` spans.
// Longer delimiters first so ** and __ match before * and _.
const parts = s.split(/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_)/g);
for (const part of parts) {
if (part.startsWith('**') && part.endsWith('**')) {
if (
(part.startsWith('**') && part.endsWith('**')) ||
(part.startsWith('__') && part.endsWith('__'))
) {
nodes.push(
<strong key={key++} className="font-bold">
{part.slice(2, -2)}
</strong>
);
} else if (
(part.startsWith('*') && part.endsWith('*')) ||
(part.startsWith('_') && part.endsWith('_'))
) {
nodes.push(
<em key={key++} className="italic">
{part.slice(1, -1)}
</em>
);
} else if (part.startsWith('`') && part.endsWith('`')) {
nodes.push(
<code key={key++} className="rounded bg-muted px-1 py-0.5 text-[0.6875rem] font-mono">
@@ -2446,19 +2462,29 @@ function appriseRenderMarkdown(s: string): ReactNode[] {
return nodes;
}
function AppriseFormatPreview({ format, vars }: { format: string; vars: Record<string, string> }) {
function AppriseFormatPreview({
format,
vars,
markdown = true,
}: {
format: string;
vars: Record<string, string>;
markdown?: boolean;
}) {
const raw = appriseApplyFormat(format, vars);
return (
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
{markdown && (
<div>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Rendered (Discord, Slack, Telegram)
</span>
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
</div>
)}
<div>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Rendered (Discord, Slack)
</span>
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
</div>
<div>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Raw (Telegram, email)
{markdown ? 'Raw (email, SMS)' : 'Preview'}
</span>
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
</div>
@@ -2483,9 +2509,11 @@ function AppriseConfigEditor({
onChange: (config: Record<string, unknown>) => void;
onScopeChange: (scope: Record<string, unknown>) => void;
}) {
const dmFormat = ((config.body_format_dm as string) || '').trim() || APPRISE_DEFAULT_DM;
const chanFormat =
((config.body_format_channel as string) || '').trim() || APPRISE_DEFAULT_CHANNEL;
const markdown = config.markdown_format !== false;
const defaultDm = markdown ? APPRISE_DEFAULT_DM : APPRISE_DEFAULT_DM_PLAIN;
const defaultChan = markdown ? APPRISE_DEFAULT_CHANNEL : APPRISE_DEFAULT_CHANNEL_PLAIN;
const dmFormat = ((config.body_format_dm as string) || '').trim() || defaultDm;
const chanFormat = ((config.body_format_channel as string) || '').trim() || defaultChan;
return (
<div className="space-y-3">
@@ -2549,6 +2577,39 @@ function AppriseConfigEditor({
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={markdown}
onChange={(e) => {
const md = e.target.checked;
const updates: Record<string, unknown> = { ...config, markdown_format: md };
const curDm = ((config.body_format_dm as string) || '').trim();
const curChan = ((config.body_format_channel as string) || '').trim();
if (md) {
if (!curDm || curDm === APPRISE_DEFAULT_DM_PLAIN)
updates.body_format_dm = APPRISE_DEFAULT_DM;
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL_PLAIN)
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL;
} else {
if (!curDm || curDm === APPRISE_DEFAULT_DM)
updates.body_format_dm = APPRISE_DEFAULT_DM_PLAIN;
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL)
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL_PLAIN;
}
onChange(updates);
}}
className="h-4 w-4 rounded border-border"
/>
<div>
<span className="text-sm">Markdown formatting</span>
<p className="text-[0.8125rem] text-muted-foreground">
If notifications fail on services like Telegram due to special characters in sender
names, disable this option.
</p>
</div>
</label>
<details className="group">
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
@@ -2604,12 +2665,12 @@ function AppriseConfigEditor({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="fanout-apprise-fmt-dm">DM format</Label>
{!appriseIsDefault(config.body_format_dm, APPRISE_DEFAULT_DM) && (
{!appriseIsDefault(config.body_format_dm, defaultDm) && (
<button
type="button"
aria-label="Reset DM format to default"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onChange({ ...config, body_format_dm: APPRISE_DEFAULT_DM })}
onClick={() => onChange({ ...config, body_format_dm: defaultDm })}
>
Reset to default
</button>
@@ -2618,23 +2679,23 @@ function AppriseConfigEditor({
<textarea
id="fanout-apprise-fmt-dm"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
placeholder={APPRISE_DEFAULT_DM}
placeholder={defaultDm}
value={(config.body_format_dm as string) ?? ''}
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
rows={2}
/>
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} />
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} markdown={markdown} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="fanout-apprise-fmt-chan">Channel format</Label>
{!appriseIsDefault(config.body_format_channel, APPRISE_DEFAULT_CHANNEL) && (
{!appriseIsDefault(config.body_format_channel, defaultChan) && (
<button
type="button"
aria-label="Reset channel format to default"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onChange({ ...config, body_format_channel: APPRISE_DEFAULT_CHANNEL })}
onClick={() => onChange({ ...config, body_format_channel: defaultChan })}
>
Reset to default
</button>
@@ -2643,12 +2704,12 @@ function AppriseConfigEditor({
<textarea
id="fanout-apprise-fmt-chan"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
placeholder={APPRISE_DEFAULT_CHANNEL}
placeholder={defaultChan}
value={(config.body_format_channel as string) ?? ''}
onChange={(e) => onChange({ ...config, body_format_channel: e.target.value })}
rows={2}
/>
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} />
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} markdown={markdown} />
</div>
<Separator />
@@ -33,6 +33,13 @@ import {
setSavedFontScale,
} from '../../utils/fontScale';
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
import {
getTextReplaceEnabled,
setTextReplaceEnabled as saveTextReplaceEnabled,
getTextReplaceMapJson,
setTextReplaceMapJson,
DEFAULT_MAP_JSON,
} from '../../utils/textReplace';
import {
BATTERY_DISPLAY_CHANGE_EVENT,
getShowBatteryPercent,
@@ -232,6 +239,9 @@ export function SettingsLocalSection({
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled);
const [textReplaceEnabled, setTextReplaceEnabled] = useState(getTextReplaceEnabled);
const [textReplaceJson, setTextReplaceJson] = useState(getTextReplaceMapJson);
const [textReplaceError, setTextReplaceError] = useState<string | null>(null);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
@@ -439,6 +449,63 @@ export function SettingsLocalSection({
</p>
</div>
</div>
<div className="rounded-md border border-border/60 p-3 space-y-2">
<div className="flex items-start gap-3">
<Checkbox
id="text-replace"
checked={textReplaceEnabled}
onCheckedChange={(checked) => {
const v = checked === true;
setTextReplaceEnabled(v);
saveTextReplaceEnabled(v);
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="text-replace">Replace as you Type</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Automatically replace characters as you type in the message input. Define
replacements as a JSON object mapping source strings to their replacements.
</p>
</div>
</div>
{textReplaceEnabled && (
<div className="space-y-2 pl-7">
<textarea
value={textReplaceJson}
onChange={(e) => {
const val = e.target.value;
setTextReplaceJson(val);
setTextReplaceError(setTextReplaceMapJson(val));
}}
spellCheck={false}
rows={10}
className={cn(
'w-full rounded-md border bg-background px-3 py-2 text-sm font-mono',
textReplaceError ? 'border-destructive' : 'border-input'
)}
aria-label="Text replacement map (JSON)"
/>
{textReplaceError && (
<p className="text-xs text-destructive">
{textReplaceError} Changes are not saved until this is resolved.
</p>
)}
<button
type="button"
onClick={() => {
setTextReplaceJson(DEFAULT_MAP_JSON);
setTextReplaceMapJson(DEFAULT_MAP_JSON);
setTextReplaceError(null);
}}
className="inline-flex h-8 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Reset to Default
</button>
</div>
)}
</div>
</div>
<div className="space-y-3">
+4 -1
View File
@@ -6,7 +6,10 @@
padding: 0;
}
html,
html {
height: 100dvh;
}
body,
#root {
height: 100%;
+2
View File
@@ -111,6 +111,7 @@ beforeEach(() => {
tracked_telemetry_repeaters: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
});
mockedApi.getRadioConfig.mockResolvedValue({
public_key: 'aa'.repeat(32),
@@ -1050,6 +1051,7 @@ describe('SettingsFanoutSection', () => {
tracked_telemetry_repeaters: ['cc'.repeat(32)],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
});
renderSection();
+1 -1
View File
@@ -51,7 +51,7 @@ describe('MessageInput', () => {
}
function getInput() {
return screen.getByPlaceholderText('Type a message...') as HTMLInputElement;
return screen.getByPlaceholderText('Type a message...') as HTMLTextAreaElement;
}
function getSendButton() {
+2
View File
@@ -94,6 +94,8 @@ describe('buildRawPacketStatsSnapshot', () => {
sender: 'Alpha',
channel_key: null,
contact_key: '0a'.repeat(32),
sender_timestamp: null,
message: null,
},
};
+70
View File
@@ -5,6 +5,7 @@ import { SettingsModal } from '../components/SettingsModal';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
RadioAdvertMode,
RadioConfig,
@@ -71,6 +72,7 @@ const baseSettings: AppSettings = {
tracked_telemetry_repeaters: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
};
function renderModal(overrides?: {
@@ -89,6 +91,8 @@ function renderModal(overrides?: {
meshDiscovery?: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
contacts?: Contact[];
trackedTelemetryRepeaters?: string[];
open?: boolean;
pageMode?: boolean;
externalSidebarNav?: boolean;
@@ -127,6 +131,8 @@ function renderModal(overrides?: {
onDiscoverMesh,
onHealthRefresh: vi.fn(async () => {}),
onRefreshAppSettings,
contacts: overrides?.contacts,
trackedTelemetryRepeaters: overrides?.trackedTelemetryRepeaters,
};
const view = overrides?.externalSidebarNav
@@ -794,4 +800,68 @@ describe('SettingsModal', () => {
expect(screen.getByText('Network')).toBeInTheDocument();
});
});
it('renders routed hourly checkbox and calls save on toggle', async () => {
const onSaveAppSettings = vi.fn(async () => {});
renderModal({
externalSidebarNav: true,
desktopSection: 'database',
onSaveAppSettings,
});
const checkbox = screen.getByRole('checkbox', {
name: /Poll direct\/routed-path repeaters hourly/i,
}) as HTMLInputElement;
expect(checkbox).toBeInTheDocument();
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith(
expect.objectContaining({ telemetry_routed_hourly: true })
);
});
});
it('shows route badge per tracked repeater', async () => {
const directKey = 'bb'.repeat(32);
renderModal({
externalSidebarNav: true,
desktopSection: 'database',
appSettings: {
...baseSettings,
tracked_telemetry_repeaters: [directKey],
},
trackedTelemetryRepeaters: [directKey],
contacts: [
{
public_key: directKey,
name: 'DirectRepeater',
type: 2,
flags: 0,
direct_path: 'aabb',
direct_path_len: 1,
direct_path_hash_mode: 1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
effective_route: { path: 'aabb', path_len: 1, path_hash_mode: 1 },
effective_route_source: 'direct',
},
],
});
expect(screen.getByText('DirectRepeater')).toBeInTheDocument();
expect(screen.getByText('direct')).toBeInTheDocument();
});
});
+192
View File
@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
getTextReplaceEnabled,
setTextReplaceEnabled,
getTextReplaceMapJson,
setTextReplaceMapJson,
applyTextReplacements,
DEFAULT_MAP_JSON,
} from '../utils/textReplace';
beforeEach(() => {
localStorage.clear();
});
describe('enabled toggle', () => {
it('defaults to disabled', () => {
expect(getTextReplaceEnabled()).toBe(false);
});
it('persists enabled state', () => {
setTextReplaceEnabled(true);
expect(getTextReplaceEnabled()).toBe(true);
setTextReplaceEnabled(false);
expect(getTextReplaceEnabled()).toBe(false);
});
});
describe('map JSON persistence', () => {
it('returns default map when nothing stored', () => {
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
});
it('persists valid JSON and returns null', () => {
const json = '{"a":"b"}';
expect(setTextReplaceMapJson(json)).toBeNull();
expect(getTextReplaceMapJson()).toBe(json);
});
it('rejects invalid JSON with error string', () => {
const err = setTextReplaceMapJson('not json');
expect(err).toBeTypeOf('string');
// localStorage unchanged — still returns default
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
});
it('rejects arrays', () => {
expect(setTextReplaceMapJson('["a","b"]')).toBeTypeOf('string');
});
it('rejects non-string values', () => {
expect(setTextReplaceMapJson('{"a":123}')).toBeTypeOf('string');
});
it('rejects null', () => {
expect(setTextReplaceMapJson('null')).toBeTypeOf('string');
});
it('accepts empty object', () => {
expect(setTextReplaceMapJson('{}')).toBeNull();
});
});
describe('re-expansion validation', () => {
it('rejects when a key appears in its own replacement', () => {
const err = setTextReplaceMapJson(JSON.stringify({ a: 'aa' }));
expect(err).toBeTypeOf('string');
expect(err).toContain('"a"');
expect(err).toContain('"aa"');
});
it('rejects when a key appears in another replacement', () => {
const err = setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'ab' }));
expect(err).toBeTypeOf('string');
expect(err).toContain('"a"');
expect(err).toContain('"ab"');
});
it('allows replacements that do not contain any key', () => {
expect(setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'Y' }))).toBeNull();
});
it('allows the default Cyrillic map', () => {
expect(setTextReplaceMapJson(DEFAULT_MAP_JSON)).toBeNull();
});
it('does not check empty keys for re-expansion', () => {
// Empty key is silently skipped by buildReplacements, so it should not
// cause a re-expansion rejection for other entries.
expect(setTextReplaceMapJson(JSON.stringify({ '': 'x', b: 'Y' }))).toBeNull();
});
});
describe('applyTextReplacements', () => {
const simpleMap = JSON.stringify({ a: 'X', b: 'Y' });
it('returns null when no replacements match', () => {
expect(applyTextReplacements('hello', 5, simpleMap)).toBeNull();
});
it('returns null for empty map', () => {
expect(applyTextReplacements('abc', 3, '{}')).toBeNull();
});
it('returns null for invalid JSON', () => {
expect(applyTextReplacements('abc', 3, 'broken')).toBeNull();
});
it('replaces a single character with cursor at end', () => {
const result = applyTextReplacements('a', 1, simpleMap);
expect(result).toEqual({ text: 'X', cursor: 1 });
});
it('replaces multiple characters in one pass', () => {
const result = applyTextReplacements('ab', 2, simpleMap);
expect(result).toEqual({ text: 'XY', cursor: 2 });
});
it('adjusts cursor when replacement is longer than needle', () => {
const map = JSON.stringify({ ':)': 'smiley' });
// "hello :)" cursor at end (8)
const result = applyTextReplacements('hello :)', 8, map);
expect(result).toEqual({ text: 'hello smiley', cursor: 12 });
});
it('adjusts cursor when replacement is shorter than needle', () => {
const map = JSON.stringify({ abc: 'Z' });
// "abcdef" cursor at end (6)
const result = applyTextReplacements('abcdef', 6, map);
expect(result).toEqual({ text: 'Zdef', cursor: 4 });
});
it('preserves cursor position when replacement is before cursor', () => {
const map = JSON.stringify({ a: 'XX' });
// "a_b" cursor at 2 (on 'b'), 'a' replaced with 'XX'
const result = applyTextReplacements('a_b', 2, map);
expect(result).toEqual({ text: 'XX_b', cursor: 3 });
});
it('does not adjust cursor for replacements after cursor', () => {
const map = JSON.stringify({ b: 'YY' });
// "ab" cursor at 1 (after 'a'), 'b' is after cursor
const result = applyTextReplacements('ab', 1, map);
expect(result).toEqual({ text: 'aYY', cursor: 1 });
});
it('places cursor after replacement when cursor is inside a multi-char match', () => {
const map = JSON.stringify({ abc: 'Z' });
// "abc" cursor at 2 (inside the match)
const result = applyTextReplacements('abc', 2, map);
expect(result).toEqual({ text: 'Z', cursor: 1 });
});
it('handles multiple replacements with cursor tracking', () => {
const map = JSON.stringify({ ':)': 'S' });
// ":):)" cursor at end (4) — two replacements, each shrinks by 1
const result = applyTextReplacements(':):)', 4, map);
expect(result).toEqual({ text: 'SS', cursor: 2 });
});
it('cursor between two replacements stays correct', () => {
const map = JSON.stringify({ ':)': 'S' });
// ":):)" cursor at 2 (between the two smileys)
const result = applyTextReplacements(':):)', 2, map);
expect(result).toEqual({ text: 'SS', cursor: 1 });
});
it('uses longest match first', () => {
const map = JSON.stringify({ ab: 'LONG', a: 'X' });
const result = applyTextReplacements('ab', 2, map);
expect(result).toEqual({ text: 'LONG', cursor: 4 });
});
it('ignores empty-string keys (no infinite loop)', () => {
const map = JSON.stringify({ '': 'oops', a: 'X' });
const result = applyTextReplacements('abc', 3, map);
expect(result).toEqual({ text: 'Xbc', cursor: 3 });
});
it('works with the default Cyrillic map', () => {
// "Привет" — П has no mapping, р→p, и has no mapping, в has no mapping, е→e, т has no mapping
const result = applyTextReplacements('Привет', 6, DEFAULT_MAP_JSON);
expect(result).not.toBeNull();
expect(result!.text).toBe('Пpивeт');
expect(result!.cursor).toBe(6);
});
it('handles paste with many replacements', () => {
const map = JSON.stringify({ А: 'A', В: 'B', С: 'C' });
const result = applyTextReplacements('АВС', 3, map);
expect(result).toEqual({ text: 'ABC', cursor: 3 });
});
});
+6
View File
@@ -343,6 +343,8 @@ export interface RawPacket {
sender: string | null;
channel_key: string | null;
contact_key: string | null;
sender_timestamp: number | null;
message: string | null;
} | null;
}
@@ -359,6 +361,7 @@ export interface AppSettings {
tracked_telemetry_repeaters: string[];
auto_resend_channel: boolean;
telemetry_interval_hours: number;
telemetry_routed_hourly: boolean;
}
export interface AppSettingsUpdate {
@@ -371,6 +374,7 @@ export interface AppSettingsUpdate {
blocked_names?: string[];
discovery_blocked_types?: number[];
telemetry_interval_hours?: number;
telemetry_routed_hourly?: boolean;
}
export interface TelemetrySchedule {
@@ -380,6 +384,8 @@ export interface TelemetrySchedule {
tracked_count: number;
max_tracked: number;
next_run_at: number | null;
routed_hourly: boolean;
next_routed_run_at: number | null;
}
export interface TrackedTelemetryResponse {
+50 -45
View File
@@ -324,51 +324,56 @@ export function inspectRawPacketWithOptions(
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
);
const enrichedPayloadFields =
decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded
? payloadFields.map((field) => {
if (field.name !== 'Ciphertext') {
return field;
}
const payload = decoded.payload.decoded as {
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
};
if (!payload.decrypted?.message) {
return field;
}
const detailLines = [
payload.decrypted.timestamp != null
? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}`
: null,
payload.decrypted.flags != null
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
: null,
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
`Message: ${payload.decrypted.message}`,
].filter((line): line is string => line !== null);
return {
...field,
description: describeCiphertextStructure(
decoded.payloadType,
field.endByte - field.startByte + 1,
field.description
),
decryptedMessage: detailLines.join('\n'),
};
})
: payloadFields.map((field) => {
if (!decoded?.isValid || field.name !== 'Ciphertext') {
return field;
}
return {
...field,
description: describeCiphertextStructure(
decoded.payloadType,
field.endByte - field.startByte + 1,
field.description
),
};
});
const enrichedPayloadFields = payloadFields.map((field) => {
if (!decoded?.isValid || field.name !== 'Ciphertext') {
return field;
}
const withStructure = {
...field,
description: describeCiphertextStructure(
decoded.payloadType,
field.endByte - field.startByte + 1,
field.description
),
};
// GroupText: client-side decoder has the decrypted content
if (decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded) {
const payload = decoded.payload.decoded as {
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
};
if (!payload.decrypted?.message) {
return withStructure;
}
const detailLines = [
payload.decrypted.timestamp != null
? `Sent (packet): ${formatUnixTimestamp(payload.decrypted.timestamp)}`
: null,
payload.decrypted.flags != null
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
: null,
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
`Message: ${payload.decrypted.message}`,
].filter((line): line is string => line !== null);
return { ...withStructure, decryptedMessage: detailLines.join('\n') };
}
// TextMessage (DM): server-side decryption via decrypted_info
if (decoded.payloadType === PayloadType.TextMessage && packet.decrypted_info?.message) {
const info = packet.decrypted_info;
const detailLines = [
info.sender_timestamp != null
? `Sent (packet): ${formatUnixTimestamp(info.sender_timestamp)}`
: null,
info.sender ? `Sender: ${info.sender}` : null,
`Message: ${info.message}`,
].filter((line): line is string => line !== null);
return { ...withStructure, decryptedMessage: detailLines.join('\n') };
}
return withStructure;
});
return {
decoded,
+142
View File
@@ -0,0 +1,142 @@
const ENABLED_KEY = 'remoteterm-text-replace-enabled';
const MAP_KEY = 'remoteterm-text-replace-map';
const DEFAULT_MAP: Record<string, string> = {
А: 'A',
В: 'B',
Е: 'E',
Ё: 'E',
З: '3',
К: 'K',
М: 'M',
Н: 'H',
О: 'O',
Р: 'P',
С: 'C',
Т: 'T',
Х: 'X',
Ь: 'b',
а: 'a',
е: 'e',
ё: 'e',
о: 'o',
р: 'p',
с: 'c',
у: 'y',
х: 'x',
};
export const DEFAULT_MAP_JSON = JSON.stringify(DEFAULT_MAP, null, 2);
export function getTextReplaceEnabled(): boolean {
try {
return localStorage.getItem(ENABLED_KEY) === 'true';
} catch {
return false;
}
}
export function setTextReplaceEnabled(enabled: boolean): void {
try {
if (enabled) {
localStorage.setItem(ENABLED_KEY, 'true');
} else {
localStorage.removeItem(ENABLED_KEY);
}
} catch {
// localStorage may be unavailable
}
}
export function getTextReplaceMapJson(): string {
try {
const raw = localStorage.getItem(MAP_KEY);
if (raw !== null) return raw;
} catch {
// fall through
}
return DEFAULT_MAP_JSON;
}
/** Persist the map JSON only if it's valid. Returns null on success or an error string. */
export function setTextReplaceMapJson(json: string): string | null {
try {
const parsed = JSON.parse(json);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
return 'Must be a JSON object.';
const rawEntries = Object.entries(parsed);
for (const [k, v] of rawEntries) {
if (typeof k !== 'string' || typeof v !== 'string')
return 'All keys and values must be strings.';
}
const entries = rawEntries as [string, string][];
// Check for re-expansion: no key may appear as a substring of any replacement value.
for (const [needle] of entries) {
if (needle.length === 0) continue;
for (const [, replacement] of entries) {
if (replacement.includes(needle)) {
return `Key "${needle}" appears inside replacement "${replacement}" and would re-expand on every keystroke.`;
}
}
}
localStorage.setItem(MAP_KEY, json);
return null;
} catch {
return 'Invalid JSON.';
}
}
/** Build a sorted-by-length-desc array of [needle, replacement] for efficient matching. */
function buildReplacements(json: string): [string, string][] {
try {
const parsed = JSON.parse(json) as Record<string, string>;
return Object.entries(parsed)
.filter(([k]) => k.length > 0)
.sort((a, b) => b[0].length - a[0].length);
} catch {
return [];
}
}
/**
* Apply text replacements and compute the adjusted cursor position.
* Returns null if nothing changed.
*/
export function applyTextReplacements(
text: string,
cursorPos: number,
mapJson: string
): { text: string; cursor: number } | null {
const replacements = buildReplacements(mapJson);
if (replacements.length === 0) return null;
let result = '';
let newCursor = cursorPos;
let i = 0;
while (i < text.length) {
let matched = false;
for (const [needle, replacement] of replacements) {
if (text.startsWith(needle, i)) {
result += replacement;
// Adjust cursor if this match is before or spans the cursor
if (i + needle.length <= cursorPos) {
newCursor += replacement.length - needle.length;
} else if (i < cursorPos) {
// Cursor is inside this match — place it after the replacement
newCursor = result.length;
}
i += needle.length;
matched = true;
break;
}
}
if (!matched) {
result += text[i];
i++;
}
}
if (result === text) return null;
return { text: result, cursor: newCursor };
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.12.3"
version = "3.13.0"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.11"
+7 -6
View File
@@ -23,8 +23,9 @@ test.describe('Channel messaging in #flightless', () => {
// Send it
await page.getByRole('button', { name: 'Send', exact: true }).click();
// Verify message appears in the message list
await expect(page.getByText(testMessage)).toBeVisible({ timeout: 15_000 });
// Verify message appears in the message list (use locator('span') to avoid
// matching the textarea which may briefly retain the sent text)
await expect(page.locator('span', { hasText: testMessage })).toBeVisible({ timeout: 15_000 });
});
test('outgoing message shows ack indicator', async ({ page }) => {
@@ -37,8 +38,8 @@ test.describe('Channel messaging in #flightless', () => {
await input.fill(testMessage);
await page.getByRole('button', { name: 'Send', exact: true }).click();
// Wait for the message to appear
const messageEl = page.getByText(testMessage);
// Wait for the message to appear in the message list
const messageEl = page.locator('span', { hasText: testMessage });
await expect(messageEl).toBeVisible({ timeout: 15_000 });
// Outgoing messages show either "?" (pending) or "✓" (acked)
@@ -58,7 +59,7 @@ test.describe('Channel messaging in #flightless', () => {
await input.fill(testMessage);
await page.getByRole('button', { name: 'Send', exact: true }).click();
const messageEl = page.getByText(testMessage).first();
const messageEl = page.locator('span', { hasText: testMessage }).first();
await expect(messageEl).toBeVisible({ timeout: 15_000 });
const messageContainer = messageEl.locator(
@@ -94,6 +95,6 @@ test.describe('Channel messaging in #flightless', () => {
await expect(page.getByText('Message resent')).toBeVisible({ timeout: 10_000 });
// Byte-perfect resend should not create a second visible row in this conversation.
await expect(page.getByText(testMessage)).toHaveCount(1);
await expect(page.locator('span', { hasText: testMessage })).toHaveCount(1);
});
});
+17 -17
View File
@@ -50,7 +50,7 @@ def _patch_require_connected(mc=None, *, detail="Radio not connected"):
if mc is None:
return patch(
"app.services.radio_runtime.radio_runtime.require_connected",
side_effect=HTTPException(status_code=503, detail=detail),
side_effect=HTTPException(status_code=423, detail=detail),
)
return patch("app.services.radio_runtime.radio_runtime.require_connected", return_value=mc)
@@ -422,11 +422,11 @@ class TestDebugEndpoint:
class TestRadioDisconnectedHandler:
"""Test that RadioDisconnectedError maps to 503."""
"""Test that RadioDisconnectedError maps to 423."""
@pytest.mark.asyncio
async def test_disconnect_race_returns_503(self, test_db, client):
"""If radio disconnects between require_connected() and lock acquisition, return 503."""
async def test_disconnect_race_returns_423(self, test_db, client):
"""If radio disconnects between require_connected() and lock acquisition, return 423."""
pub_key = "ab" * 32
await _insert_contact(pub_key, "Alice")
@@ -437,7 +437,7 @@ class TestRadioDisconnectedHandler:
"/api/messages/direct", json={"destination": pub_key, "text": "Hi"}
)
assert response.status_code == 503
assert response.status_code == 423
assert "not connected" in response.json()["detail"].lower()
@@ -500,25 +500,25 @@ class TestMessagesEndpoint:
@pytest.mark.asyncio
async def test_send_direct_message_requires_connection(self, test_db, client):
"""Sending message when disconnected returns 503."""
"""Sending message when disconnected returns 423."""
with _patch_require_connected():
response = await client.post(
"/api/messages/direct", json={"destination": "abc123", "text": "Hello"}
)
assert response.status_code == 503
assert response.status_code == 423
assert "not connected" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_send_channel_message_requires_connection(self, test_db, client):
"""Sending channel message when disconnected returns 503."""
"""Sending channel message when disconnected returns 423."""
with _patch_require_connected():
response = await client.post(
"/api/messages/channel",
json={"channel_key": "0123456789ABCDEF0123456789ABCDEF", "text": "Hello"},
)
assert response.status_code == 503
assert response.status_code == 423
@pytest.mark.asyncio
async def test_send_direct_message_emits_websocket_message_event(self, test_db, client):
@@ -603,8 +603,8 @@ class TestMessagesEndpoint:
assert "not found" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_send_direct_message_duplicate_returns_500(self, test_db):
"""If MessageRepository.create returns None (duplicate), returns 500."""
async def test_send_direct_message_duplicate_returns_422(self, test_db):
"""If MessageRepository.create returns None (duplicate), returns 422."""
from app.models import SendDirectMessageRequest
from app.routers.messages import send_direct_message
@@ -636,12 +636,12 @@ class TestMessagesEndpoint:
SendDirectMessageRequest(destination=pub_key, text="Hello")
)
assert exc_info.value.status_code == 500
assert exc_info.value.status_code == 422
assert "unexpected duplicate" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_send_channel_message_duplicate_returns_500(self, test_db):
"""If MessageRepository.create returns None (duplicate), returns 500."""
async def test_send_channel_message_duplicate_returns_422(self, test_db):
"""If MessageRepository.create returns None (duplicate), returns 422."""
from app.models import SendChannelMessageRequest
from app.routers.messages import send_channel_message
@@ -672,16 +672,16 @@ class TestMessagesEndpoint:
SendChannelMessageRequest(channel_key=chan_key, text="Hello")
)
assert exc_info.value.status_code == 500
assert exc_info.value.status_code == 422
assert "unexpected duplicate" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_resend_channel_message_requires_connection(self, test_db, client):
"""Resend endpoint returns 503 when radio is disconnected."""
"""Resend endpoint returns 423 when radio is disconnected."""
with _patch_require_connected():
response = await client.post("/api/messages/channel/1/resend")
assert response.status_code == 503
assert response.status_code == 423
assert "not connected" in response.json()["detail"].lower()
@pytest.mark.asyncio
+1 -1
View File
@@ -709,7 +709,7 @@ class TestBotMessageRateLimiting:
patch(
"app.routers.messages.send_direct_message",
new_callable=AsyncMock,
side_effect=HTTPException(status_code=500, detail="Send failed"),
side_effect=HTTPException(status_code=422, detail="Send failed"),
),
):
await process_bot_response(
+2 -2
View File
@@ -317,7 +317,7 @@ class TestPathDiscovery:
mock_broadcast.assert_called_once_with("contact", updated.model_dump())
@pytest.mark.asyncio
async def test_returns_504_when_no_response_is_heard(self, test_db, client):
async def test_returns_408_when_no_response_is_heard(self, test_db, client):
await _insert_contact(KEY_A, "Alice", type=1)
mc = MagicMock()
mc.commands = MagicMock()
@@ -332,7 +332,7 @@ class TestPathDiscovery:
mock_rm.radio_operation = _noop_radio_operation(mc)
response = await client.post(f"/api/contacts/{KEY_A}/path-discovery")
assert response.status_code == 504
assert response.status_code == 408
assert "No path discovery response heard" in response.json()["detail"]
+128
View File
@@ -1367,6 +1367,134 @@ class TestAppriseValidation:
assert scope["raw_packets"] == "none"
assert scope["messages"] == "all"
def test_validate_apprise_config_accepts_markdown_format_bool(self):
from app.routers.fanout import _validate_apprise_config
_validate_apprise_config({"urls": "discord://123/abc", "markdown_format": False})
def test_validate_apprise_config_normalizes_markdown_format(self):
from app.routers.fanout import _validate_apprise_config
config: dict = {"urls": "discord://123/abc", "markdown_format": 0}
_validate_apprise_config(config)
assert config["markdown_format"] is False
def test_validate_apprise_config_works_without_markdown_format(self):
from app.routers.fanout import _validate_apprise_config
_validate_apprise_config({"urls": "discord://123/abc"})
class TestAppriseMarkdownFormat:
def test_format_body_markdown_true_uses_markdown_fallback(self):
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
markdown=True,
)
assert "**DM:**" in body
def test_format_body_markdown_false_uses_plain_fallback(self):
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
markdown=False,
)
assert "**" not in body
assert "DM:" in body
assert "Alice" in body
def test_format_body_markdown_false_channel(self):
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#gen"},
markdown=False,
)
assert "**" not in body
assert "#gen:" in body
def test_send_sync_passes_markdown_body_format(self):
from unittest.mock import MagicMock, patch
with patch("app.fanout.apprise_mod.apprise_lib", create=True) as mock_lib:
mock_notifier = MagicMock()
mock_notifier.notify.return_value = True
mock_lib.Apprise.return_value = mock_notifier
with patch.dict("sys.modules", {"apprise": mock_lib}):
from app.fanout.apprise_mod import _send_sync
_send_sync("json://localhost", "test", preserve_identity=False, markdown=True)
call_kwargs = mock_notifier.notify.call_args
assert call_kwargs.kwargs.get("body_format") or call_kwargs[1].get("body_format")
def test_send_sync_passes_text_body_format_when_markdown_false(self):
from unittest.mock import MagicMock, patch
with patch("app.fanout.apprise_mod.apprise_lib", create=True) as mock_lib:
mock_notifier = MagicMock()
mock_notifier.notify.return_value = True
mock_lib.Apprise.return_value = mock_notifier
with patch.dict("sys.modules", {"apprise": mock_lib}):
from app.fanout.apprise_mod import _send_sync
_send_sync("json://localhost", "test", preserve_identity=False, markdown=False)
call_kwargs = mock_notifier.notify.call_args
assert call_kwargs.kwargs.get("body_format") or call_kwargs[1].get("body_format")
@pytest.mark.asyncio
async def test_on_message_reads_markdown_format_config(self):
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
mod = AppriseModule("test", {"urls": "json://localhost", "markdown_format": False})
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
await mod.on_message(
{"type": "PRIV", "text": "hello", "outgoing": False, "sender_name": "S_Borkin"}
)
mock_send.assert_called_once()
assert mock_send.call_args.kwargs.get("markdown") is False
@pytest.mark.asyncio
async def test_on_message_defaults_markdown_true(self):
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
mod = AppriseModule("test", {"urls": "json://localhost"})
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
await mod.on_message(
{"type": "PRIV", "text": "hello", "outgoing": False, "sender_name": "Alice"}
)
mock_send.assert_called_once()
assert mock_send.call_args.kwargs.get("markdown") is True
@pytest.mark.asyncio
async def test_on_message_markdown_false_uses_plain_default_format(self):
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
mod = AppriseModule("test", {"urls": "json://localhost", "markdown_format": False})
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
await mod.on_message(
{
"type": "CHAN",
"text": "hi",
"outgoing": False,
"sender_name": "Bob",
"channel_name": "#general",
}
)
body = mock_send.call_args[0][1]
assert "**" not in body
assert "#general:" in body
# ---------------------------------------------------------------------------
# Comprehensive scope/filter selection logic tests
+40
View File
@@ -1580,6 +1580,46 @@ class TestFanoutAppriseIntegration:
assert "Eve" in body_text
assert "routed msg" in body_text
@pytest.mark.asyncio
async def test_apprise_markdown_false_delivers_plain_text(
self, apprise_capture_server, integration_db
):
"""Apprise with markdown_format=False delivers without markdown formatting."""
cfg = await FanoutConfigRepository.create(
config_type="apprise",
name="Plain Apprise",
config={
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
"markdown_format": False,
},
scope={"messages": "all", "raw_packets": "none"},
enabled=True,
)
manager = FanoutManager()
try:
await manager.load_from_db()
assert cfg["id"] in manager._modules
await manager.broadcast_message(
{
"type": "PRIV",
"conversation_key": "pk1",
"text": "hello",
"sender_name": "S_Borkin",
}
)
results = await apprise_capture_server.wait_for(1)
finally:
await manager.stop_all()
assert len(results) >= 1
body_text = str(results[0])
assert "S_Borkin" in body_text
assert "hello" in body_text
assert "**" not in body_text
# ---------------------------------------------------------------------------
# Bot lifecycle tests
+1 -1
View File
@@ -2,4 +2,4 @@
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
# ``applied == LATEST - starting_version`` so only this constant needs to
# change, not every individual assertion.
LATEST_SCHEMA_VERSION = 60
LATEST_SCHEMA_VERSION = 61
+2 -2
View File
@@ -342,8 +342,8 @@ class TestConnectionLoop:
assert sleep_args[0] == _BACKOFF_MIN
assert sleep_args[1] == _BACKOFF_MIN * 2
assert sleep_args[2] == _BACKOFF_MIN * 4
# Fourth should be capped at _backoff_max (5*8=40 > 30)
assert sleep_args[3] == MqttPublisher._backoff_max
# Fourth is still doubling (5*8=40), not yet at _backoff_max
assert sleep_args[3] == _BACKOFF_MIN * 8
@pytest.mark.asyncio
async def test_waits_for_settings_when_unconfigured(self):
+2
View File
@@ -95,6 +95,8 @@ class TestGetRawPacket:
"sender": "Alice",
"channel_key": channel_key,
"contact_key": None,
"sender_timestamp": 1700000000,
"message": "Alice: hello",
}
+6 -6
View File
@@ -174,8 +174,8 @@ class TestRadioOperationYield:
class TestRequireConnected:
"""Test the require_connected() FastAPI dependency."""
def test_raises_503_when_setup_in_progress(self):
"""HTTPException 503 is raised when radio is connected but setup is still in progress."""
def test_raises_423_when_setup_in_progress(self):
"""HTTPException 423 is raised when radio is connected but setup is still in progress."""
from fastapi import HTTPException
from app.services.radio_runtime import radio_runtime
@@ -188,11 +188,11 @@ class TestRequireConnected:
with pytest.raises(HTTPException) as exc_info:
radio_runtime.require_connected()
assert exc_info.value.status_code == 503
assert exc_info.value.status_code == 423
assert "initializing" in exc_info.value.detail.lower()
def test_raises_503_when_not_connected(self):
"""HTTPException 503 is raised when radio is not connected."""
def test_raises_423_when_not_connected(self):
"""HTTPException 423 is raised when radio is not connected."""
from fastapi import HTTPException
from app.services.radio_runtime import radio_runtime
@@ -205,7 +205,7 @@ class TestRequireConnected:
with pytest.raises(HTTPException) as exc_info:
radio_runtime.require_connected()
assert exc_info.value.status_code == 503
assert exc_info.value.status_code == 423
def test_returns_meshcore_when_connected_and_setup_complete(self):
"""Returns meshcore instance when radio is connected and setup is complete."""
+11 -11
View File
@@ -131,14 +131,14 @@ class TestGetRadioConfig:
assert response.advert_location_source == "current"
@pytest.mark.asyncio
async def test_returns_503_when_self_info_missing(self):
async def test_returns_423_when_self_info_missing(self):
mc = MagicMock()
mc.self_info = None
with patch("app.routers.radio.radio_manager.require_connected", return_value=mc):
with pytest.raises(HTTPException) as exc:
await get_radio_config()
assert exc.value.status_code == 503
assert exc.value.status_code == 423
class TestUpdateRadioConfig:
@@ -278,7 +278,7 @@ class TestUpdateRadioConfig:
with pytest.raises(HTTPException) as exc:
await update_radio_config(RadioConfigUpdate(path_hash_mode=1))
assert exc.value.status_code == 500
assert exc.value.status_code == 422
assert "Failed to set path hash mode" in str(exc.value.detail)
assert radio_manager.path_hash_mode == 0
mc.commands.send_appstart.assert_not_awaited()
@@ -339,7 +339,7 @@ class TestPrivateKeyImport:
with pytest.raises(HTTPException) as exc:
await set_private_key(PrivateKeyUpdate(private_key="aa" * 64))
assert exc.value.status_code == 500
assert exc.value.status_code == 422
class TestDiscoverMesh:
@@ -699,7 +699,7 @@ class TestTracePath:
assert "not a repeater" in exc.value.detail
@pytest.mark.asyncio
async def test_returns_504_when_no_trace_response_is_heard(self):
async def test_returns_408_when_no_trace_response_is_heard(self):
mc = _mock_meshcore_with_info()
repeater = Contact(
public_key="44" * 32,
@@ -741,7 +741,7 @@ class TestTracePath:
)
)
assert exc.value.status_code == 504
assert exc.value.status_code == 408
assert "No trace response heard" in exc.value.detail
@pytest.mark.asyncio
@@ -850,7 +850,7 @@ class TestTracePath:
with pytest.raises(HTTPException) as exc:
await discover_mesh(RadioDiscoveryRequest(target="sensors"))
assert exc.value.status_code == 500
assert exc.value.status_code == 422
assert exc.value.detail == "Failed to start mesh discovery"
@pytest.mark.asyncio
@@ -887,7 +887,7 @@ class TestTracePath:
with pytest.raises(HTTPException) as exc:
await set_private_key(PrivateKeyUpdate(private_key="aa" * 64))
assert exc.value.status_code == 500
assert exc.value.status_code == 422
assert "keystore" in exc.value.detail.lower()
# Called twice: initial attempt + one retry
assert mock_export.await_count == 2
@@ -926,7 +926,7 @@ class TestAdvertise:
with pytest.raises(HTTPException) as exc:
await send_advertisement()
assert exc.value.status_code == 500
assert exc.value.status_code == 422
@pytest.mark.asyncio
async def test_defaults_to_flood_mode(self):
@@ -1059,7 +1059,7 @@ class TestRebootAndReconnect:
assert result["connected"] is True
@pytest.mark.asyncio
async def test_reconnect_raises_503_on_failure(self):
async def test_reconnect_raises_423_on_failure(self):
mock_rm = MagicMock()
mock_rm.is_connected = False
mock_rm.is_reconnecting = False
@@ -1070,7 +1070,7 @@ class TestRebootAndReconnect:
with pytest.raises(HTTPException) as exc:
await reconnect_radio()
assert exc.value.status_code == 503
assert exc.value.status_code == 423
@pytest.mark.asyncio
async def test_disconnect_pauses_connection_attempts_and_broadcasts_health(self):
+2 -2
View File
@@ -57,12 +57,12 @@ def test_require_connected_preserves_http_semantics():
)
with pytest.raises(HTTPException, match="Radio is initializing") as exc:
runtime.require_connected()
assert exc.value.status_code == 503
assert exc.value.status_code == 423
runtime = RadioRuntime(_Manager(meshcore=None, is_connected=False, is_setup_in_progress=False))
with pytest.raises(HTTPException, match="Radio not connected") as exc:
runtime.require_connected()
assert exc.value.status_code == 503
assert exc.value.status_code == 423
def test_require_connected_returns_fresh_meshcore_after_connectivity_check():
+363
View File
@@ -2219,6 +2219,262 @@ class TestCollectRepeaterTelemetryLpp:
assert "lpp_sensors" not in recorded_data
class TestRunTelemetryCycleRoutedOnly:
"""Verify that _run_telemetry_cycle(routed_only=True) skips flood repeaters."""
@pytest.mark.asyncio
async def test_routed_only_skips_flood_contacts(self):
from unittest.mock import AsyncMock, MagicMock, patch
from app.models import AppSettings, Contact
from app.radio_sync import _run_telemetry_cycle
flood_key = "aa" * 32
direct_key = "bb" * 32
override_key = "cc" * 32
flood_contact = Contact(
public_key=flood_key,
name="Flood",
type=2,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
)
direct_contact = Contact(
public_key=direct_key,
name="Direct",
type=2,
direct_path="aabb",
direct_path_len=1,
direct_path_hash_mode=1,
)
override_contact = Contact(
public_key=override_key,
name="Override",
type=2,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
route_override_path="ccdd",
route_override_len=1,
route_override_hash_mode=1,
)
settings = AppSettings(
tracked_telemetry_repeaters=[flood_key, direct_key, override_key],
)
contact_map = {
flood_key: flood_contact,
direct_key: direct_contact,
override_key: override_contact,
}
collected_keys: list[str] = []
async def fake_get_by_key(key):
return contact_map.get(key)
async def fake_collect(mc, contact):
collected_keys.append(contact.public_key)
return True
fake_radio_manager = MagicMock()
fake_radio_manager.is_connected = True
fake_radio_manager.radio_operation = MagicMock()
# Make radio_operation an async context manager that yields a MagicMock
fake_mc = MagicMock()
class FakeRadioOp:
async def __aenter__(self):
return fake_mc
async def __aexit__(self, *args):
pass
fake_radio_manager.radio_operation.return_value = FakeRadioOp()
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch(
"app.radio_sync.ContactRepository.get_by_key",
new_callable=AsyncMock,
side_effect=fake_get_by_key,
),
patch("app.radio_sync._collect_repeater_telemetry", new=fake_collect),
patch("app.radio_sync.radio_manager", fake_radio_manager),
):
await _run_telemetry_cycle(routed_only=True)
# Flood contact should be skipped; direct and override should be collected
assert flood_key not in collected_keys
assert direct_key in collected_keys
assert override_key in collected_keys
@pytest.mark.asyncio
async def test_routed_only_skips_forced_flood_override(self):
"""A contact with a forced-flood override (path_len=-1) should be
treated as flood even though effective_route_source is 'override'."""
from unittest.mock import AsyncMock, MagicMock, patch
from app.models import AppSettings, Contact
from app.radio_sync import _run_telemetry_cycle
forced_flood_key = "aa" * 32
direct_key = "bb" * 32
forced_flood_contact = Contact(
public_key=forced_flood_key,
name="ForcedFlood",
type=2,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
route_override_path="",
route_override_len=-1,
route_override_hash_mode=-1,
)
direct_contact = Contact(
public_key=direct_key,
name="Direct",
type=2,
direct_path="aabb",
direct_path_len=1,
direct_path_hash_mode=1,
)
# Verify the forced-flood contact reports "override" source
assert forced_flood_contact.effective_route_source == "override"
settings = AppSettings(
tracked_telemetry_repeaters=[forced_flood_key, direct_key],
)
contact_map = {forced_flood_key: forced_flood_contact, direct_key: direct_contact}
collected_keys: list[str] = []
async def fake_get_by_key(key):
return contact_map.get(key)
async def fake_collect(mc, contact):
collected_keys.append(contact.public_key)
return True
fake_radio_manager = MagicMock()
fake_radio_manager.is_connected = True
fake_mc = MagicMock()
class FakeRadioOp:
async def __aenter__(self):
return fake_mc
async def __aexit__(self, *args):
pass
fake_radio_manager.radio_operation.return_value = FakeRadioOp()
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch(
"app.radio_sync.ContactRepository.get_by_key",
new_callable=AsyncMock,
side_effect=fake_get_by_key,
),
patch("app.radio_sync._collect_repeater_telemetry", new=fake_collect),
patch("app.radio_sync.radio_manager", fake_radio_manager),
):
await _run_telemetry_cycle(routed_only=True)
# Forced-flood override should be excluded; direct should be collected
assert forced_flood_key not in collected_keys
assert direct_key in collected_keys
@pytest.mark.asyncio
async def test_full_cycle_includes_all_contacts(self):
from unittest.mock import AsyncMock, MagicMock, patch
from app.models import AppSettings, Contact
from app.radio_sync import _run_telemetry_cycle
flood_key = "aa" * 32
direct_key = "bb" * 32
flood_contact = Contact(
public_key=flood_key,
name="Flood",
type=2,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
)
direct_contact = Contact(
public_key=direct_key,
name="Direct",
type=2,
direct_path="aabb",
direct_path_len=1,
direct_path_hash_mode=1,
)
settings = AppSettings(
tracked_telemetry_repeaters=[flood_key, direct_key],
)
contact_map = {flood_key: flood_contact, direct_key: direct_contact}
collected_keys: list[str] = []
async def fake_get_by_key(key):
return contact_map.get(key)
async def fake_collect(mc, contact):
collected_keys.append(contact.public_key)
return True
fake_radio_manager = MagicMock()
fake_radio_manager.is_connected = True
fake_mc = MagicMock()
class FakeRadioOp:
async def __aenter__(self):
return fake_mc
async def __aexit__(self, *args):
pass
fake_radio_manager.radio_operation.return_value = FakeRadioOp()
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch(
"app.radio_sync.ContactRepository.get_by_key",
new_callable=AsyncMock,
side_effect=fake_get_by_key,
),
patch("app.radio_sync._collect_repeater_telemetry", new=fake_collect),
patch("app.radio_sync.radio_manager", fake_radio_manager),
):
await _run_telemetry_cycle(routed_only=False)
# Full cycle collects both
assert flood_key in collected_keys
assert direct_key in collected_keys
# ---------------------------------------------------------------------------
# _telemetry_collect_loop — UTC modulo scheduler
# ---------------------------------------------------------------------------
@@ -2518,6 +2774,113 @@ class TestTelemetryCollectSchedulerDecision:
)
class TestRoutedHourlySchedulerDecision:
"""Verify the routed_hourly feature in _maybe_run_scheduled_cycle."""
@pytest.mark.asyncio
async def test_routed_hourly_fires_on_non_modulo_hour(self):
"""At 09:00 UTC with 8h interval and routed_hourly=True, the scheduler
should call _run_telemetry_cycle(routed_only=True)."""
import datetime as real_datetime
from unittest.mock import AsyncMock, patch
from app import radio_sync
from app.models import AppSettings
settings = AppSettings(
tracked_telemetry_repeaters=["aa" * 32],
telemetry_interval_hours=8,
telemetry_routed_hourly=True,
)
calls = []
async def fake_cycle(*, routed_only=False):
calls.append({"routed_only": routed_only})
now = real_datetime.datetime(2026, 4, 16, 9, 0, 0, tzinfo=real_datetime.UTC)
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
):
await radio_sync._maybe_run_scheduled_cycle(now)
assert len(calls) == 1
assert calls[0]["routed_only"] is True
@pytest.mark.asyncio
async def test_routed_hourly_disabled_skips_non_modulo_hour(self):
"""At 09:00 UTC with 8h interval and routed_hourly=False, nothing runs."""
import datetime as real_datetime
from unittest.mock import AsyncMock, patch
from app import radio_sync
from app.models import AppSettings
settings = AppSettings(
tracked_telemetry_repeaters=["aa" * 32],
telemetry_interval_hours=8,
telemetry_routed_hourly=False,
)
calls = []
async def fake_cycle(*, routed_only=False):
calls.append({"routed_only": routed_only})
now = real_datetime.datetime(2026, 4, 16, 9, 0, 0, tzinfo=real_datetime.UTC)
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
):
await radio_sync._maybe_run_scheduled_cycle(now)
assert len(calls) == 0
@pytest.mark.asyncio
async def test_modulo_hour_runs_full_cycle_even_with_routed_hourly(self):
"""At 16:00 UTC with 8h interval, a normal full cycle runs regardless
of whether routed_hourly is enabled it covers all repeaters."""
import datetime as real_datetime
from unittest.mock import AsyncMock, patch
from app import radio_sync
from app.models import AppSettings
settings = AppSettings(
tracked_telemetry_repeaters=["aa" * 32],
telemetry_interval_hours=8,
telemetry_routed_hourly=True,
)
calls = []
async def fake_cycle(*, routed_only=False):
calls.append({"routed_only": routed_only})
now = real_datetime.datetime(2026, 4, 16, 16, 0, 0, tzinfo=real_datetime.UTC)
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
):
await radio_sync._maybe_run_scheduled_cycle(now)
assert len(calls) == 1
assert calls[0]["routed_only"] is False
# ---------------------------------------------------------------------------
# get_contacts_selected_for_radio_sync — DM-active prioritization
# ---------------------------------------------------------------------------
+13 -13
View File
@@ -302,7 +302,7 @@ class TestRepeaterCommandRoute:
with pytest.raises(HTTPException) as exc:
await send_repeater_command(KEY_A, CommandRequest(command="ver"))
assert exc.value.status_code == 500
assert exc.value.status_code == 422
mc.start_auto_message_fetching.assert_awaited_once()
@pytest.mark.asyncio
@@ -502,7 +502,7 @@ class TestTraceRoute:
with pytest.raises(HTTPException) as exc:
await request_trace(KEY_A)
assert exc.value.status_code == 500
assert exc.value.status_code == 422
mc.commands.send_trace.assert_awaited_once_with(
path=KEY_A[:8],
tag=1234,
@@ -510,7 +510,7 @@ class TestTraceRoute:
)
@pytest.mark.asyncio
async def test_wait_timeout_returns_504(self, test_db):
async def test_wait_timeout_returns_408(self, test_db):
mc = _mock_mc()
await _insert_contact(KEY_A, name="Client", contact_type=1)
mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK))
@@ -524,7 +524,7 @@ class TestTraceRoute:
with pytest.raises(HTTPException) as exc:
await request_trace(KEY_A)
assert exc.value.status_code == 504
assert exc.value.status_code == 408
mc.commands.send_trace.assert_awaited_once_with(
path=KEY_A[:8],
tag=1234,
@@ -745,7 +745,7 @@ class TestRepeaterStatus:
assert response.recv_errors == 42
@pytest.mark.asyncio
async def test_504_on_timeout(self, test_db):
async def test_408_on_timeout(self, test_db):
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.req_status_sync = AsyncMock(return_value=None)
@@ -756,7 +756,7 @@ class TestRepeaterStatus:
):
with pytest.raises(HTTPException) as exc:
await repeater_status(KEY_A)
assert exc.value.status_code == 504
assert exc.value.status_code == 408
@pytest.mark.asyncio
async def test_400_not_repeater(self, test_db):
@@ -819,7 +819,7 @@ class TestRepeaterLppTelemetry:
assert response.sensors == []
@pytest.mark.asyncio
async def test_504_on_timeout(self, test_db):
async def test_408_on_timeout(self, test_db):
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
@@ -830,7 +830,7 @@ class TestRepeaterLppTelemetry:
):
with pytest.raises(HTTPException) as exc:
await repeater_lpp_telemetry(KEY_A)
assert exc.value.status_code == 504
assert exc.value.status_code == 408
@pytest.mark.asyncio
async def test_400_not_repeater(self, test_db):
@@ -1234,7 +1234,7 @@ class TestBatchCliFetch:
with pytest.raises(HTTPException) as exc:
await _batch_cli_fetch(contact, "test_op", [("ver", "firmware_version")])
assert exc.value.status_code == 500
assert exc.value.status_code == 422
assert "Failed to add contact to radio" in exc.value.detail
@pytest.mark.asyncio
@@ -1307,7 +1307,7 @@ class TestRepeaterAddContactError:
with pytest.raises(HTTPException) as exc:
await repeater_status(KEY_A)
assert exc.value.status_code == 500
assert exc.value.status_code == 422
assert "Failed to add contact to radio" in exc.value.detail
@pytest.mark.asyncio
@@ -1325,7 +1325,7 @@ class TestRepeaterAddContactError:
with pytest.raises(HTTPException) as exc:
await repeater_lpp_telemetry(KEY_A)
assert exc.value.status_code == 500
assert exc.value.status_code == 422
assert "Failed to add contact to radio" in exc.value.detail
@pytest.mark.asyncio
@@ -1343,7 +1343,7 @@ class TestRepeaterAddContactError:
with pytest.raises(HTTPException) as exc:
await repeater_neighbors(KEY_A)
assert exc.value.status_code == 500
assert exc.value.status_code == 422
assert "Failed to add contact to radio" in exc.value.detail
@pytest.mark.asyncio
@@ -1361,5 +1361,5 @@ class TestRepeaterAddContactError:
with pytest.raises(HTTPException) as exc:
await repeater_acl(KEY_A)
assert exc.value.status_code == 500
assert exc.value.status_code == 422
assert "Failed to add contact to radio" in exc.value.detail
+10 -10
View File
@@ -646,7 +646,7 @@ class TestOutgoingChannelBroadcast:
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
await send_channel_message(request)
assert exc_info.value.status_code == 500
assert exc_info.value.status_code == 422
assert "regional override" in exc_info.value.detail.lower()
mc.commands.set_channel.assert_not_awaited()
mc.commands.send_chan_msg.assert_not_awaited()
@@ -790,7 +790,7 @@ class TestOutgoingChannelBroadcast:
SendChannelMessageRequest(channel_key=chan_key, text="this will fail")
)
assert exc_info.value.status_code == 500
assert exc_info.value.status_code == 422
assert radio_manager.get_cached_channel_slot(chan_key) is None
@@ -969,7 +969,7 @@ class TestResendChannelMessage:
assert sent_timestamp == now + 1
@pytest.mark.asyncio
async def test_resend_no_radio_response_returns_504_and_creates_no_new_row(self, test_db):
async def test_resend_no_radio_response_returns_408_and_creates_no_new_row(self, test_db):
"""When resend returns None, report unknown outcome and create no new message row."""
mc = _make_mc(name="MyNode")
chan_key = "c1" * 16
@@ -995,7 +995,7 @@ class TestResendChannelMessage:
):
await resend_channel_message(msg_id, new_timestamp=True)
assert exc_info.value.status_code == 504
assert exc_info.value.status_code == 408
assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL
messages = await MessageRepository.get_all(
@@ -1317,7 +1317,7 @@ class TestPathHashModeOverride:
SendChannelMessageRequest(channel_key=chan_key, text="hello")
)
assert exc_info.value.status_code == 500
assert exc_info.value.status_code == 422
assert "path hash mode" in exc_info.value.detail.lower()
mc.commands.send_chan_msg.assert_not_awaited()
@@ -1567,7 +1567,7 @@ class TestRadioExceptionMidSend:
assert len(messages) == 0
@pytest.mark.asyncio
async def test_dm_send_no_radio_response_returns_504_without_storing_message(self, test_db):
async def test_dm_send_no_radio_response_returns_408_without_storing_message(self, test_db):
"""When mc.commands.send_msg() returns None, report unknown outcome and store nothing."""
mc = _make_mc()
pub_key = "ac" * 32
@@ -1584,7 +1584,7 @@ class TestRadioExceptionMidSend:
SendDirectMessageRequest(destination=pub_key, text="Did this send?")
)
assert exc_info.value.status_code == 504
assert exc_info.value.status_code == 408
assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL
messages = await MessageRepository.get_all(
@@ -1593,7 +1593,7 @@ class TestRadioExceptionMidSend:
assert len(messages) == 0
@pytest.mark.asyncio
async def test_channel_send_no_radio_response_returns_504_without_storing_message(
async def test_channel_send_no_radio_response_returns_408_without_storing_message(
self, test_db
):
"""When mc.commands.send_chan_msg() returns None, report unknown outcome and store nothing."""
@@ -1612,7 +1612,7 @@ class TestRadioExceptionMidSend:
SendChannelMessageRequest(channel_key=chan_key, text="Did this send?")
)
assert exc_info.value.status_code == 504
assert exc_info.value.status_code == 408
assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL
messages = await MessageRepository.get_all(
@@ -1733,7 +1733,7 @@ class TestRadioExceptionMidSend:
SendChannelMessageRequest(channel_key=chan_key_b, text="Never sent")
)
assert exc_info.value.status_code == 500
assert exc_info.value.status_code == 422
assert radio_manager.get_cached_channel_slot(chan_key_a) is None
assert radio_manager.get_cached_channel_slot(chan_key_b) is None
mc.commands.send_chan_msg.assert_not_called()
+63
View File
@@ -330,3 +330,66 @@ class TestTelemetryScheduleEndpoint:
assert schedule.tracked_count == 5
assert schedule.options == [6, 8, 12, 24]
assert schedule.next_run_at is not None
class TestRoutedHourlySetting:
"""Tests for the telemetry_routed_hourly setting."""
@pytest.mark.asyncio
async def test_defaults_to_false(self, test_db):
settings = await AppSettingsRepository.get()
assert settings.telemetry_routed_hourly is False
@pytest.mark.asyncio
async def test_round_trip_via_patch(self, test_db):
result = await update_settings(AppSettingsUpdate(telemetry_routed_hourly=True))
assert result.telemetry_routed_hourly is True
result = await update_settings(AppSettingsUpdate(telemetry_routed_hourly=False))
assert result.telemetry_routed_hourly is False
@pytest.mark.asyncio
async def test_schedule_includes_routed_fields_when_enabled(self, test_db):
key = "aa" * 32
await ContactRepository.upsert(
ContactUpsert(public_key=key, name="R1", type=CONTACT_TYPE_REPEATER)
)
await AppSettingsRepository.update(
tracked_telemetry_repeaters=[key],
telemetry_routed_hourly=True,
)
schedule = await get_telemetry_schedule()
assert schedule.routed_hourly is True
assert schedule.next_routed_run_at is not None
assert schedule.next_run_at is not None
@pytest.mark.asyncio
async def test_schedule_omits_routed_run_when_disabled(self, test_db):
key = "aa" * 32
await ContactRepository.upsert(
ContactUpsert(public_key=key, name="R1", type=CONTACT_TYPE_REPEATER)
)
await AppSettingsRepository.update(
tracked_telemetry_repeaters=[key],
telemetry_routed_hourly=False,
)
schedule = await get_telemetry_schedule()
assert schedule.routed_hourly is False
assert schedule.next_routed_run_at is None
@pytest.mark.asyncio
async def test_toggle_response_carries_routed_hourly(self, test_db):
key = "bb" * 32
await ContactRepository.upsert(
ContactUpsert(public_key=key, name="R2", type=CONTACT_TYPE_REPEATER)
)
await AppSettingsRepository.update(telemetry_routed_hourly=True)
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
assert result.schedule.routed_hourly is True
assert result.schedule.next_routed_run_at is not None
Generated
+1 -1
View File
@@ -1533,7 +1533,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.12.3"
version = "3.13.0"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },