mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 20:36:05 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89cee49725 | |||
| b37ce89c96 | |||
| f0b7842c60 | |||
| 4eb29f376e | |||
| 82a6553539 | |||
| a69eb9c534 | |||
| 70aabb78aa | |||
| cafd9678ee | |||
| a8e346d0c5 | |||
| 55f05bf03b | |||
| 091ba06ccf | |||
| c5c828a4ed | |||
| 7eac3a9754 | |||
| 329df1a0d2 | |||
| ecb4c99a43 | |||
| 2f412e1a93 | |||
| 0353a98e87 | |||
| 3e2258c34b |
@@ -380,6 +380,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||||
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
||||||
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
||||||
|
| POST | `/api/settings/muted-channels/toggle` | Toggle muted status for a channel |
|
||||||
| GET | `/api/fanout` | List all fanout configs |
|
| GET | `/api/fanout` | List all fanout configs |
|
||||||
| POST | `/api/fanout` | Create new fanout config |
|
| POST | `/api/fanout` | Create new fanout config |
|
||||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
## [3.12.3] - 2026-04-24
|
||||||
|
|
||||||
|
* Feature: Customizable Apprise strings
|
||||||
|
* Feature: Choose contact addition type
|
||||||
|
* Featuer: Make bulk-delete sortable by last-heard
|
||||||
|
* Misc: Bypass error on fail-to-unload-contact when it's not there
|
||||||
|
* Misc: Docs & test updates
|
||||||
|
|
||||||
## [3.12.2] - 2026-04-21
|
## [3.12.2] - 2026-04-21
|
||||||
|
|
||||||
* Feature: Auto-disambiguate colliding LPP sensor names
|
* Feature: Auto-disambiguate colliding LPP sensor names
|
||||||
|
|||||||
+26
-8
@@ -1,26 +1,44 @@
|
|||||||
# Advanced Setup And Troubleshooting
|
# Advanced Setup And Troubleshooting
|
||||||
|
|
||||||
## Remediation Environment Variables
|
## Remediation & Advanced Environment Variables
|
||||||
|
|
||||||
These are intended for diagnosing or working around radios that behave oddly.
|
These are intended for diagnosing or working around radios that behave oddly, or enabling advanced functionality.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages ([docs](#message-poll-fallback)) |
|
||||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send ([docs](#force-channel-slot-reconfigure)) |
|
||||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading (see [Contact Loading Issues](#contact-loading-issues) below) |
|
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading ([docs](#autoevict-mode)) |
|
||||||
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | false | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Only enable on a trusted network when you need to retrieve the key (e.g. for backup or migration). |
|
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot ([docs](#clock-wraparound)) |
|
||||||
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
|
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | false | Enable `GET /api/radio/private-key` to return the in-memory private key as hex for backup or migration. Only enable on a trusted network. Import via `PUT /api/radio/private-key` is always available. ([docs](#private-key-export)) |
|
||||||
|
|
||||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
||||||
|
|
||||||
- whether messages were left on the radio without reaching the app through event subscription
|
- whether messages were left on the radio without reaching the app through event subscription
|
||||||
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
||||||
|
|
||||||
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
If the audit finds a mismatch, you'll see an error in the application UI and your logs.
|
||||||
|
|
||||||
|
### Message Poll Fallback
|
||||||
|
|
||||||
|
If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net.
|
||||||
|
|
||||||
|
### Force Channel Slot Reconfigure
|
||||||
|
|
||||||
|
If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
||||||
|
|
||||||
|
### Clock Wraparound
|
||||||
|
|
||||||
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
||||||
|
|
||||||
|
### Private Key Export
|
||||||
|
|
||||||
|
`MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true` enables `GET /api/radio/private-key`, which returns the in-memory private key as hex for backup or migration. The key is held in memory only (exported from the radio on connect) and is never persisted to disk. Only enable this on a trusted network when you need to retrieve the key.
|
||||||
|
|
||||||
|
Import via `PUT /api/radio/private-key` is always available regardless of this setting — it is write-only and does not expose key material.
|
||||||
|
|
||||||
|
The Radio Settings config export/import feature uses these endpoints. When export is disabled, config exports will omit the private key and show a notice.
|
||||||
|
|
||||||
## Contact Loading Issues
|
## Contact Loading Issues
|
||||||
|
|
||||||
RemoteTerm loads favorite and recently active contacts onto the radio so that the radio can automatically acknowledge incoming DMs on your behalf. To do this, it first enumerates the radio's existing contact table, then reconciles it with the desired working set.
|
RemoteTerm loads favorite and recently active contacts onto the radio so that the radio can automatically acknowledge incoming DMs on your behalf. To do this, it first enumerates the radio's existing contact table, then reconciles it with the desired working set.
|
||||||
|
|||||||
+9
-1
@@ -196,6 +196,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
### Radio
|
### Radio
|
||||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
||||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
||||||
|
- `GET /radio/private-key` — export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`)
|
||||||
- `PUT /radio/private-key`
|
- `PUT /radio/private-key`
|
||||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||||
@@ -266,6 +267,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
- `POST /settings/blocked-names/toggle`
|
- `POST /settings/blocked-names/toggle`
|
||||||
- `POST /settings/tracked-telemetry/toggle`
|
- `POST /settings/tracked-telemetry/toggle`
|
||||||
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
||||||
|
- `POST /settings/muted-channels/toggle`
|
||||||
|
|
||||||
### Fanout
|
### Fanout
|
||||||
- `GET /fanout` — list all fanout configs
|
- `GET /fanout` — list all fanout configs
|
||||||
@@ -396,7 +398,7 @@ tests/
|
|||||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||||
├── test_messages_search.py # Message search, around, forward pagination
|
├── test_messages_search.py # Message search, around, forward pagination
|
||||||
├── test_migrations.py # Schema migration system
|
├── test_mqtt_ha.py # MQTT HA (high-availability) behavior
|
||||||
├── test_packet_pipeline.py # End-to-end packet processing
|
├── test_packet_pipeline.py # End-to-end packet processing
|
||||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||||
├── test_path_utils.py # Path hex rendering helpers
|
├── test_path_utils.py # Path hex rendering helpers
|
||||||
@@ -415,7 +417,13 @@ tests/
|
|||||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||||
├── test_settings_router.py # Settings endpoints, advert validation
|
├── test_settings_router.py # Settings endpoints, advert validation
|
||||||
|
├── test_push_send.py # Web Push send/dispatch
|
||||||
|
├── test_radio_stats.py # Radio stats sampling and noise-floor history
|
||||||
|
├── test_repeater_telemetry.py # Repeater telemetry history recording
|
||||||
|
├── test_service_installer.py # Service installer script behavior
|
||||||
|
├── test_sqs_fanout.py # SQS fanout module
|
||||||
├── test_statistics.py # Statistics aggregation
|
├── test_statistics.py # Statistics aggregation
|
||||||
|
├── test_telemetry_interval.py # Telemetry interval scheduling math
|
||||||
├── test_version_info.py # Version/build metadata resolution
|
├── test_version_info.py # Version/build metadata resolution
|
||||||
├── test_websocket.py # WS manager broadcast/cleanup
|
├── test_websocket.py # WS manager broadcast/cleanup
|
||||||
└── test_websocket_route.py # WS endpoint lifecycle
|
└── test_websocket_route.py # WS endpoint lifecycle
|
||||||
|
|||||||
+18
-2
@@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
flood_scope_override TEXT,
|
flood_scope_override TEXT,
|
||||||
path_hash_mode_override INTEGER,
|
path_hash_mode_override INTEGER,
|
||||||
last_read_at INTEGER,
|
last_read_at INTEGER,
|
||||||
favorite INTEGER DEFAULT 0
|
favorite INTEGER DEFAULT 0,
|
||||||
|
muted INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@@ -112,7 +113,10 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
discovery_blocked_types TEXT DEFAULT '[]',
|
discovery_blocked_types TEXT DEFAULT '[]',
|
||||||
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
||||||
auto_resend_channel INTEGER DEFAULT 0,
|
auto_resend_channel INTEGER DEFAULT 0,
|
||||||
telemetry_interval_hours INTEGER DEFAULT 8
|
telemetry_interval_hours INTEGER DEFAULT 8,
|
||||||
|
vapid_private_key TEXT DEFAULT '',
|
||||||
|
vapid_public_key TEXT DEFAULT '',
|
||||||
|
push_conversations TEXT DEFAULT '[]'
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||||
|
|
||||||
@@ -134,6 +138,18 @@ CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
|||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_success_at INTEGER,
|
||||||
|
failure_count INTEGER DEFAULT 0,
|
||||||
|
UNIQUE(endpoint)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Indexes are created after migrations so that legacy databases have all
|
# Indexes are created after migrations so that legacy databases have all
|
||||||
|
|||||||
+127
-36
@@ -11,6 +11,28 @@ from app.path_utils import split_path_hex
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||||
|
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
)
|
||||||
|
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
|
||||||
|
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
|
||||||
|
|
||||||
|
# Variables available for user format strings
|
||||||
|
FORMAT_VARIABLES = (
|
||||||
|
"type",
|
||||||
|
"text",
|
||||||
|
"sender_name",
|
||||||
|
"sender_key",
|
||||||
|
"channel_name",
|
||||||
|
"conversation_key",
|
||||||
|
"hops",
|
||||||
|
"hops_backticked",
|
||||||
|
"hop_count",
|
||||||
|
"rssi",
|
||||||
|
"snr",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_urls(raw: str) -> list[str]:
|
def _parse_urls(raw: str) -> list[str]:
|
||||||
"""Split multi-line URL string into individual URLs."""
|
"""Split multi-line URL string into individual URLs."""
|
||||||
@@ -36,41 +58,91 @@ def _normalize_discord_url(url: str) -> str:
|
|||||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
def _compute_hops(data: dict) -> tuple[str, str, int]:
|
||||||
"""Build a human-readable notification body from message data."""
|
"""Extract hop info from message data. Returns (hops, hops_backticked, hop_count)."""
|
||||||
|
paths = data.get("paths")
|
||||||
|
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||||
|
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||||
|
path_str = first_path.get("path", "")
|
||||||
|
path_len = first_path.get("path_len")
|
||||||
|
else:
|
||||||
|
path_str = None
|
||||||
|
path_len = None
|
||||||
|
|
||||||
|
if path_str is None or path_str.strip() == "":
|
||||||
|
return ("direct", "`direct`", 0)
|
||||||
|
|
||||||
|
path_str = path_str.strip().lower()
|
||||||
|
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
||||||
|
hops = split_path_hex(path_str, hop_count)
|
||||||
|
if not hops:
|
||||||
|
return ("direct", "`direct`", 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
", ".join(hops),
|
||||||
|
", ".join(f"`{h}`" for h in hops),
|
||||||
|
len(hops),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_template_vars(data: dict) -> dict[str, str]:
|
||||||
|
"""Build the variable dict for format string substitution."""
|
||||||
|
hops_raw, hops_bt, hop_count = _compute_hops(data)
|
||||||
|
|
||||||
|
paths = data.get("paths")
|
||||||
|
rssi = ""
|
||||||
|
snr = ""
|
||||||
|
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||||
|
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||||
|
rssi_val = first_path.get("rssi")
|
||||||
|
snr_val = first_path.get("snr")
|
||||||
|
if rssi_val is not None:
|
||||||
|
rssi = str(rssi_val)
|
||||||
|
if snr_val is not None:
|
||||||
|
snr = str(snr_val)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": data.get("type", ""),
|
||||||
|
"text": get_fanout_message_text(data),
|
||||||
|
"sender_name": data.get("sender_name") or "Unknown",
|
||||||
|
"sender_key": data.get("sender_key") or "",
|
||||||
|
"channel_name": data.get("channel_name") or data.get("conversation_key", "channel"),
|
||||||
|
"conversation_key": data.get("conversation_key", ""),
|
||||||
|
"hops": hops_raw,
|
||||||
|
"hops_backticked": hops_bt,
|
||||||
|
"hop_count": str(hop_count),
|
||||||
|
"rssi": rssi,
|
||||||
|
"snr": snr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_format(fmt: str, variables: dict[str, str]) -> str:
|
||||||
|
"""Apply template variables in a single pass to avoid re-expanding substituted values."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
def _replacer(m: re.Match[str]) -> str:
|
||||||
|
key = m.group(1)
|
||||||
|
return variables.get(key, m.group(0))
|
||||||
|
|
||||||
|
return re.sub(r"\{(\w+)\}", _replacer, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_body(
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
body_format_dm: str = DEFAULT_BODY_FORMAT_DM,
|
||||||
|
body_format_channel: str = DEFAULT_BODY_FORMAT_CHANNEL,
|
||||||
|
) -> str:
|
||||||
|
"""Build a notification body from message data using format strings."""
|
||||||
|
variables = _build_template_vars(data)
|
||||||
msg_type = data.get("type", "")
|
msg_type = data.get("type", "")
|
||||||
text = get_fanout_message_text(data)
|
fmt = body_format_dm if msg_type == "PRIV" else body_format_channel
|
||||||
sender_name = data.get("sender_name") or "Unknown"
|
try:
|
||||||
|
return _apply_format(fmt, variables)
|
||||||
via = ""
|
except Exception:
|
||||||
if include_path:
|
logger.warning("Apprise format string error, falling back to default")
|
||||||
paths = data.get("paths")
|
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
|
||||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
return _apply_format(default, variables)
|
||||||
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
|
||||||
path_str = first_path.get("path", "")
|
|
||||||
path_len = first_path.get("path_len")
|
|
||||||
else:
|
|
||||||
path_str = None
|
|
||||||
path_len = None
|
|
||||||
|
|
||||||
if msg_type == "PRIV" and path_str is None:
|
|
||||||
via = " **via:** [`direct`]"
|
|
||||||
elif path_str is not None:
|
|
||||||
path_str = path_str.strip().lower()
|
|
||||||
if path_str == "":
|
|
||||||
via = " **via:** [`direct`]"
|
|
||||||
else:
|
|
||||||
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
|
||||||
hops = split_path_hex(path_str, hop_count)
|
|
||||||
if hops:
|
|
||||||
hop_list = ", ".join(f"`{h}`" for h in hops)
|
|
||||||
via = f" **via:** [{hop_list}]"
|
|
||||||
|
|
||||||
if msg_type == "PRIV":
|
|
||||||
return f"**DM:** {sender_name}: {text}{via}"
|
|
||||||
|
|
||||||
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
|
|
||||||
return f"**{channel_name}:** {sender_name}: {text}{via}"
|
|
||||||
|
|
||||||
|
|
||||||
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
||||||
@@ -106,8 +178,27 @@ class AppriseModule(FanoutModule):
|
|||||||
return
|
return
|
||||||
|
|
||||||
preserve_identity = self.config.get("preserve_identity", True)
|
preserve_identity = self.config.get("preserve_identity", True)
|
||||||
include_path = self.config.get("include_path", True)
|
|
||||||
body = _format_body(data, include_path=include_path)
|
# Read format strings; treat empty/whitespace as unset (use default).
|
||||||
|
# Fall back to legacy include_path for pre-migration configs.
|
||||||
|
body_format_dm = (self.config.get("body_format_dm") or "").strip() or None
|
||||||
|
body_format_channel = (self.config.get("body_format_channel") or "").strip() or None
|
||||||
|
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 body_format_channel is None:
|
||||||
|
body_format_channel = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL
|
||||||
|
if include_path
|
||||||
|
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||||
|
)
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
data, body_format_dm=body_format_dm, body_format_channel=body_format_channel
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = await asyncio.to_thread(
|
success = await asyncio.to_thread(
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ _REPEATER_SENSORS: list[dict[str, Any]] = [
|
|||||||
"unit": None,
|
"unit": None,
|
||||||
"precision": 0,
|
"precision": 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"field": "recv_errors",
|
||||||
|
"name": "RX Errors",
|
||||||
|
"object_id": "recv_errors",
|
||||||
|
"device_class": None,
|
||||||
|
"state_class": "total_increasing",
|
||||||
|
"unit": None,
|
||||||
|
"precision": 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"field": "uptime_seconds",
|
"field": "uptime_seconds",
|
||||||
"name": "Uptime",
|
"name": "Uptime",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||||
|
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
)
|
||||||
|
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
|
||||||
|
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Migrate apprise fanout configs from include_path boolean to format strings."""
|
||||||
|
table_check = await conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='fanout_configs'"
|
||||||
|
)
|
||||||
|
if not await table_check.fetchone():
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = await conn.execute("SELECT id, config FROM fanout_configs WHERE type = 'apprise'")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
config_id = row["id"] if isinstance(row, dict) else row[0]
|
||||||
|
config_raw = row["config"] if isinstance(row, dict) else row[1]
|
||||||
|
try:
|
||||||
|
config = json.loads(config_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if already migrated
|
||||||
|
if "body_format_dm" in config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
include_path = config.get("include_path", True)
|
||||||
|
config["body_format_dm"] = (
|
||||||
|
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
|
||||||
|
)
|
||||||
|
config["body_format_channel"] = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL if include_path else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||||
|
)
|
||||||
|
config.pop("include_path", None)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE fanout_configs SET config = ? WHERE id = ?",
|
||||||
|
(json.dumps(config), config_id),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Migrated apprise config %s: include_path=%s -> format strings", config_id, include_path
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
@@ -221,6 +221,9 @@ class CreateContactRequest(BaseModel):
|
|||||||
|
|
||||||
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
||||||
name: str | None = Field(default=None, description="Display name for the contact")
|
name: str | None = Field(default=None, description="Display name for the contact")
|
||||||
|
type: int = Field(
|
||||||
|
default=0, ge=0, le=3, description="Contact type (0=unknown, 1=client, 2=repeater, 3=room)"
|
||||||
|
)
|
||||||
try_historical: bool = Field(
|
try_historical: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Attempt to decrypt historical DM packets for this contact",
|
description="Attempt to decrypt historical DM packets for this contact",
|
||||||
@@ -537,6 +540,7 @@ class RepeaterStatusResponse(BaseModel):
|
|||||||
flood_dups: int = Field(description="Duplicate flood packets")
|
flood_dups: int = Field(description="Duplicate flood packets")
|
||||||
direct_dups: int = Field(description="Duplicate direct packets")
|
direct_dups: int = Field(description="Duplicate direct packets")
|
||||||
full_events: int = Field(description="Full event queue count")
|
full_events: int = Field(description="Full event queue count")
|
||||||
|
recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors")
|
||||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||||
default_factory=list, description="Recent telemetry history snapshots"
|
default_factory=list, description="Recent telemetry history snapshots"
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-1
@@ -1273,7 +1273,12 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
budget -= 1
|
budget -= 1
|
||||||
if remove_result.type == EventType.OK:
|
not_found = (
|
||||||
|
remove_result.type != EventType.OK
|
||||||
|
and isinstance(remove_result.payload, dict)
|
||||||
|
and remove_result.payload.get("error_code") == 2
|
||||||
|
)
|
||||||
|
if remove_result.type == EventType.OK or not_found:
|
||||||
radio_contacts.pop(public_key, None)
|
radio_contacts.pop(public_key, None)
|
||||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||||
removed += 1
|
removed += 1
|
||||||
@@ -1816,6 +1821,7 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
|||||||
"flood_dups": status.get("flood_dups", 0),
|
"flood_dups": status.get("flood_dups", 0),
|
||||||
"direct_dups": status.get("direct_dups", 0),
|
"direct_dups": status.get("direct_dups", 0),
|
||||||
"full_events": status.get("full_evts", 0),
|
"full_events": status.get("full_evts", 0),
|
||||||
|
"recv_errors": status.get("recv_errors"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Best-effort LPP sensor fetch — failure here does not fail the overall
|
# Best-effort LPP sensor fetch — failure here does not fail the overall
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ async def create_contact(
|
|||||||
contact_upsert = ContactUpsert(
|
contact_upsert = ContactUpsert(
|
||||||
public_key=lower_key,
|
public_key=lower_key,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
|
type=request.type,
|
||||||
on_radio=False,
|
on_radio=False,
|
||||||
)
|
)
|
||||||
await ContactRepository.upsert(contact_upsert)
|
await ContactRepository.upsert(contact_upsert)
|
||||||
|
|||||||
@@ -259,6 +259,21 @@ def _validate_apprise_config(config: dict) -> None:
|
|||||||
if not urls or not urls.strip():
|
if not urls or not urls.strip():
|
||||||
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
||||||
|
|
||||||
|
from app.fanout.apprise_mod import FORMAT_VARIABLES, _apply_format
|
||||||
|
|
||||||
|
dummy_vars: dict[str, str] = dict.fromkeys(FORMAT_VARIABLES, "test")
|
||||||
|
for field in ("body_format_dm", "body_format_channel"):
|
||||||
|
value = config.get(field)
|
||||||
|
if value is not None and not isinstance(value, str):
|
||||||
|
raise HTTPException(status_code=400, detail=f"{field} must be a string")
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
try:
|
||||||
|
_apply_format(value, dummy_vars)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Invalid format string in {field}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
def _validate_webhook_config(config: dict) -> None:
|
def _validate_webhook_config(config: dict) -> None:
|
||||||
"""Validate webhook config blob."""
|
"""Validate webhook config blob."""
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
flood_dups=status.get("flood_dups", 0),
|
flood_dups=status.get("flood_dups", 0),
|
||||||
direct_dups=status.get("direct_dups", 0),
|
direct_dups=status.get("direct_dups", 0),
|
||||||
full_events=status.get("full_evts", 0),
|
full_events=status.get("full_evts", 0),
|
||||||
|
recv_errors=status.get("recv_errors"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Record to telemetry history as a JSON blob (best-effort)
|
# Record to telemetry history as a JSON blob (best-effort)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
flood_dups=status.get("flood_dups", 0),
|
flood_dups=status.get("flood_dups", 0),
|
||||||
direct_dups=status.get("direct_dups", 0),
|
direct_dups=status.get("direct_dups", 0),
|
||||||
full_events=status.get("full_evts", 0),
|
full_events=status.get("full_evts", 0),
|
||||||
|
recv_errors=status.get("recv_errors"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -862,7 +862,7 @@ async def send_channel_message_to_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Never let watchdog setup failure break the send
|
logger.error("Echo watchdog setup failed", exc_info=True)
|
||||||
|
|
||||||
return outgoing_message
|
return outgoing_message
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ services:
|
|||||||
# MESHCORE_DISABLE_BOTS: "true"
|
# MESHCORE_DISABLE_BOTS: "true"
|
||||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||||
|
# MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT: "false"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
# MESHCORE_LOG_LEVEL: INFO
|
# MESHCORE_LOG_LEVEL: INFO
|
||||||
|
|||||||
+3
-6
@@ -75,7 +75,6 @@ frontend/src/
|
|||||||
├── utils/
|
├── utils/
|
||||||
│ ├── urlHash.ts # Hash parsing and encoding
|
│ ├── urlHash.ts # Hash parsing and encoding
|
||||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||||
│ ├── favorites.ts # LocalStorage migration for favorites
|
|
||||||
│ ├── messageParser.ts # Message text → rendered segments
|
│ ├── messageParser.ts # Message text → rendered segments
|
||||||
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
||||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||||
@@ -132,6 +131,9 @@ frontend/src/
|
|||||||
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||||
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||||
|
│ ├── ChannelPathHashModeOverrideModal.tsx # Per-channel path hash mode override editor
|
||||||
|
│ ├── BulkAddChannelResultModal.tsx # Results dialog for bulk channel creation
|
||||||
|
│ ├── CommandPalette.tsx # Command palette overlay
|
||||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||||
│ ├── settings/
|
│ ├── settings/
|
||||||
@@ -178,7 +180,6 @@ frontend/src/
|
|||||||
├── prefetch.test.ts
|
├── prefetch.test.ts
|
||||||
├── rawPacketDetailModal.test.tsx
|
├── rawPacketDetailModal.test.tsx
|
||||||
├── rawPacketFeedView.test.tsx
|
├── rawPacketFeedView.test.tsx
|
||||||
├── radioPresets.test.ts
|
|
||||||
├── rawPacketIdentity.test.ts
|
├── rawPacketIdentity.test.ts
|
||||||
├── repeaterDashboard.test.tsx
|
├── repeaterDashboard.test.tsx
|
||||||
├── repeaterFormatters.test.ts
|
├── repeaterFormatters.test.ts
|
||||||
@@ -350,10 +351,6 @@ It falls back to a 12-char prefix when `name` is missing.
|
|||||||
|
|
||||||
Distance/validation helpers used by path + map UI.
|
Distance/validation helpers used by path + map UI.
|
||||||
|
|
||||||
### `utils/favorites.ts`
|
|
||||||
|
|
||||||
LocalStorage migration helpers for favorites; canonical favorites are server-side.
|
|
||||||
|
|
||||||
## Types and Contracts (`types.ts`)
|
## Types and Contracts (`types.ts`)
|
||||||
|
|
||||||
`AppSettings` currently includes:
|
`AppSettings` currently includes:
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.12.0",
|
"version": "3.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.12.0",
|
"version": "3.12.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.10",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
@@ -5619,9 +5619,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.12.2",
|
"version": "3.12.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.10",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
|
|||||||
+2
-2
@@ -158,10 +158,10 @@ export const api = {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ public_keys: publicKeys }),
|
body: JSON.stringify({ public_keys: publicKeys }),
|
||||||
}),
|
}),
|
||||||
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
createContact: (publicKey: string, name?: string, tryHistorical?: boolean, type?: number) =>
|
||||||
fetchJson<Contact>('/contacts', {
|
fetchJson<Contact>('/contacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ public_key: publicKey, name, try_historical: tryHistorical }),
|
body: JSON.stringify({ public_key: publicKey, name, type, try_historical: tryHistorical }),
|
||||||
}),
|
}),
|
||||||
markContactRead: (publicKey: string) =>
|
markContactRead: (publicKey: string) =>
|
||||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
|
import {
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ComponentProps,
|
||||||
|
} from 'react';
|
||||||
import { useSwipeable } from 'react-swipeable';
|
import { useSwipeable } from 'react-swipeable';
|
||||||
|
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
@@ -140,6 +148,26 @@ export function AppShell({
|
|||||||
crackerMounted.current = true;
|
crackerMounted.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Position toasts below the conversation header when in chat, otherwise below the status bar
|
||||||
|
const TOAST_TOP_PADDING = 10;
|
||||||
|
const [toastTopOffset, setToastTopOffset] = useState<number | undefined>(undefined);
|
||||||
|
const hasLocalLabel = !!localLabel.text;
|
||||||
|
const activeType = conversationPaneProps.activeConversation?.type;
|
||||||
|
const activeId = conversationPaneProps.activeConversation?.id;
|
||||||
|
useEffect(() => {
|
||||||
|
const measure = () => {
|
||||||
|
const anchor =
|
||||||
|
document.querySelector('[data-toast-anchor="conversation"]') ??
|
||||||
|
document.querySelector('[data-toast-anchor="statusbar"]');
|
||||||
|
setToastTopOffset(
|
||||||
|
anchor ? anchor.getBoundingClientRect().top + TOAST_TOP_PADDING : undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => window.removeEventListener('resize', measure);
|
||||||
|
}, [hasLocalLabel, activeType, activeId, showSettings]);
|
||||||
|
|
||||||
const settingsSidebarContent = (
|
const settingsSidebarContent = (
|
||||||
<nav
|
<nav
|
||||||
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
||||||
@@ -220,6 +248,7 @@ export function AppShell({
|
|||||||
onSettingsClick={onToggleSettingsView}
|
onSettingsClick={onToggleSettingsView}
|
||||||
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
||||||
/>
|
/>
|
||||||
|
<div data-toast-anchor="statusbar" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
||||||
@@ -344,7 +373,11 @@ export function AppShell({
|
|||||||
<SecurityWarningModal health={statusProps.health} />
|
<SecurityWarningModal health={statusProps.health} />
|
||||||
<ContactInfoPane {...contactInfoPaneProps} />
|
<ContactInfoPane {...contactInfoPaneProps} />
|
||||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||||
<Toaster position="top-right" />
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
offset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||||
|
mobileOffset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ export function ConversationPane({
|
|||||||
{activeContactIsRoom && activeContact && (
|
{activeContactIsRoom && activeContact && (
|
||||||
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
||||||
)}
|
)}
|
||||||
|
{showRoomChat && <div data-toast-anchor="conversation" aria-hidden="true" />}
|
||||||
{showRoomChat && (
|
{showRoomChat && (
|
||||||
<MessageList
|
<MessageList
|
||||||
key={activeConversation.id}
|
key={activeConversation.id}
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ interface NewMessageModalProps {
|
|||||||
nonce: number;
|
nonce: number;
|
||||||
} | null;
|
} | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (
|
||||||
|
name: string,
|
||||||
|
publicKey: string,
|
||||||
|
tryHistorical: boolean,
|
||||||
|
type?: number
|
||||||
|
) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||||
@@ -91,6 +96,7 @@ export function NewMessageModal({
|
|||||||
}: NewMessageModalProps) {
|
}: NewMessageModalProps) {
|
||||||
const [tab, setTab] = useState<Tab>('new-contact');
|
const [tab, setTab] = useState<Tab>('new-contact');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [contactType, setContactType] = useState(1);
|
||||||
const [contactKey, setContactKey] = useState('');
|
const [contactKey, setContactKey] = useState('');
|
||||||
const [channelKey, setChannelKey] = useState('');
|
const [channelKey, setChannelKey] = useState('');
|
||||||
const [bulkChannelText, setBulkChannelText] = useState('');
|
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||||
@@ -103,6 +109,7 @@ export function NewMessageModal({
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('');
|
setName('');
|
||||||
|
setContactType(1);
|
||||||
setContactKey('');
|
setContactKey('');
|
||||||
setChannelKey('');
|
setChannelKey('');
|
||||||
setBulkChannelText('');
|
setBulkChannelText('');
|
||||||
@@ -161,7 +168,7 @@ export function NewMessageModal({
|
|||||||
setError('Name and public key are required');
|
setError('Name and public key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical, contactType);
|
||||||
} else if (tab === 'new-channel') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !channelKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
setError('Channel name and key are required');
|
setError('Channel name and key are required');
|
||||||
@@ -293,6 +300,19 @@ export function NewMessageModal({
|
|||||||
placeholder="64-character hex public key"
|
placeholder="64-character hex public key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contact-type">Type</Label>
|
||||||
|
<select
|
||||||
|
id="contact-type"
|
||||||
|
value={contactType}
|
||||||
|
onChange={(e) => setContactType(Number(e.target.value))}
|
||||||
|
className="block h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<option value={1}>Client</option>
|
||||||
|
<option value={2}>Repeater</option>
|
||||||
|
<option value={3}>Room Server</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export function RepeaterDashboard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
<div data-toast-anchor="conversation" aria-hidden="true" />
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ import type { TelemetryHistoryEntry, TelemetryLppSensor, Contact } from '../../t
|
|||||||
|
|
||||||
const MAX_TRACKED = 8;
|
const MAX_TRACKED = 8;
|
||||||
|
|
||||||
type BuiltinMetric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
type BuiltinMetric =
|
||||||
|
| 'battery_volts'
|
||||||
|
| 'noise_floor_dbm'
|
||||||
|
| 'packets'
|
||||||
|
| 'recv_errors'
|
||||||
|
| 'uptime_seconds';
|
||||||
|
|
||||||
interface MetricConfig {
|
interface MetricConfig {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -29,6 +34,7 @@ const BUILTIN_METRIC_CONFIG: Record<BuiltinMetric, MetricConfig> = {
|
|||||||
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||||
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||||
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||||
|
recv_errors: { label: 'RX Errors', unit: '', color: '#ef4444' },
|
||||||
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,6 +160,7 @@ export function TelemetryHistoryPane({
|
|||||||
noise_floor_dbm: d.noise_floor_dbm,
|
noise_floor_dbm: d.noise_floor_dbm,
|
||||||
packets_received: d.packets_received,
|
packets_received: d.packets_received,
|
||||||
packets_sent: d.packets_sent,
|
packets_sent: d.packets_sent,
|
||||||
|
recv_errors: d.recv_errors ?? undefined,
|
||||||
uptime_seconds: d.uptime_seconds,
|
uptime_seconds: d.uptime_seconds,
|
||||||
};
|
};
|
||||||
// Flatten LPP sensors into the point, converting units as needed
|
// Flatten LPP sensors into the point, converting units as needed
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export function TelemetryPane({
|
|||||||
label="Duplicates"
|
label="Duplicates"
|
||||||
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
|
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
|
||||||
/>
|
/>
|
||||||
|
{data.recv_errors != null && (
|
||||||
|
<KvRow label="RX Errors" value={data.recv_errors.toLocaleString()} />
|
||||||
|
)}
|
||||||
<Separator className="my-1" />
|
<Separator className="my-1" />
|
||||||
<KvRow label="TX Queue" value={data.tx_queue_len} />
|
<KvRow label="TX Queue" value={data.tx_queue_len} />
|
||||||
<KvRow label="Debug Flags" value={data.full_events} />
|
<KvRow label="Debug Flags" value={data.full_events} />
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const CONTACT_TYPE_LABELS: Record<number, string> = {
|
|||||||
4: 'Sensor',
|
4: 'Sensor',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SortField = 'name' | 'type' | 'key' | 'first_seen' | 'last_seen';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
function formatDate(ts: number): string {
|
||||||
return new Date(ts * 1000).toLocaleDateString([], {
|
return new Date(ts * 1000).toLocaleDateString([], {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -32,6 +35,32 @@ function datetimeToUnix(datetimeStr: string): number {
|
|||||||
return Math.floor(d.getTime() / 1000);
|
return Math.floor(d.getTime() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: SortField;
|
||||||
|
sortField: SortField;
|
||||||
|
sortDir: SortDir;
|
||||||
|
onSort: (field: SortField) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const active = sortField === field;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={`px-3 py-1.5 cursor-pointer select-none hover:text-foreground transition-colors ${className ?? ''}`}
|
||||||
|
onClick={() => onSort(field)}
|
||||||
|
>
|
||||||
|
{label} {active ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface BulkDeleteContactsModalProps {
|
interface BulkDeleteContactsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -49,22 +78,42 @@ export function BulkDeleteContactsModal({
|
|||||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [lastHeardAfter, setLastHeardAfter] = useState('');
|
||||||
|
const [lastHeardBefore, setLastHeardBefore] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
||||||
|
const [sortField, setSortField] = useState<SortField>('first_seen');
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const lastClickedKeyRef = useRef<string | null>(null);
|
const lastClickedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const handleSort = useCallback(
|
||||||
|
(field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDir(field === 'name' || field === 'key' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sortField]
|
||||||
|
);
|
||||||
|
|
||||||
const resetAndClose = useCallback(() => {
|
const resetAndClose = useCallback(() => {
|
||||||
setStep('select');
|
setStep('select');
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
setStartDate('');
|
setStartDate('');
|
||||||
setEndDate('');
|
setEndDate('');
|
||||||
|
setLastHeardAfter('');
|
||||||
|
setLastHeardBefore('');
|
||||||
setTypeFilter('all');
|
setTypeFilter('all');
|
||||||
|
setSortField('first_seen');
|
||||||
|
setSortDir('desc');
|
||||||
lastClickedKeyRef.current = null;
|
lastClickedKeyRef.current = null;
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
let list = [...contacts];
|
||||||
if (typeFilter !== 'all') {
|
if (typeFilter !== 'all') {
|
||||||
list = list.filter((c) => c.type === typeFilter);
|
list = list.filter((c) => c.type === typeFilter);
|
||||||
}
|
}
|
||||||
@@ -76,8 +125,44 @@ export function BulkDeleteContactsModal({
|
|||||||
const end = datetimeToUnix(endDate);
|
const end = datetimeToUnix(endDate);
|
||||||
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
||||||
}
|
}
|
||||||
|
if (lastHeardAfter) {
|
||||||
|
const after = datetimeToUnix(lastHeardAfter);
|
||||||
|
list = list.filter((c) => (c.last_seen ?? 0) >= after);
|
||||||
|
}
|
||||||
|
if (lastHeardBefore) {
|
||||||
|
const before = datetimeToUnix(lastHeardBefore);
|
||||||
|
list = list.filter((c) => (c.last_seen ?? 0) <= before);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
list.sort((a, b) => {
|
||||||
|
switch (sortField) {
|
||||||
|
case 'name': {
|
||||||
|
const an = getContactDisplayName(a.name, a.public_key, a.last_advert).toLowerCase();
|
||||||
|
const bn = getContactDisplayName(b.name, b.public_key, b.last_advert).toLowerCase();
|
||||||
|
return an < bn ? -dir : an > bn ? dir : 0;
|
||||||
|
}
|
||||||
|
case 'type':
|
||||||
|
return (a.type - b.type) * dir;
|
||||||
|
case 'key':
|
||||||
|
return a.public_key < b.public_key ? -dir : a.public_key > b.public_key ? dir : 0;
|
||||||
|
case 'first_seen':
|
||||||
|
return ((a.first_seen ?? 0) - (b.first_seen ?? 0)) * dir;
|
||||||
|
case 'last_seen':
|
||||||
|
return ((a.last_seen ?? 0) - (b.last_seen ?? 0)) * dir;
|
||||||
|
}
|
||||||
|
});
|
||||||
return list;
|
return list;
|
||||||
}, [contacts, typeFilter, startDate, endDate]);
|
}, [
|
||||||
|
contacts,
|
||||||
|
typeFilter,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
lastHeardAfter,
|
||||||
|
lastHeardBefore,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleToggle = (key: string, shiftKey: boolean) => {
|
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||||
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||||
@@ -148,6 +233,8 @@ export function BulkDeleteContactsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasFilters = startDate || endDate || lastHeardAfter || lastHeardBefore;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||||
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||||
@@ -164,40 +251,64 @@ export function BulkDeleteContactsModal({
|
|||||||
|
|
||||||
{step === 'select' && (
|
{step === 'select' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Show</label>
|
<div className="space-y-1">
|
||||||
<select
|
<label className="text-xs text-muted-foreground">Show</label>
|
||||||
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
<select
|
||||||
onChange={(e) =>
|
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||||
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
onChange={(e) =>
|
||||||
}
|
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||||
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
}
|
||||||
>
|
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||||
<option value="all">All</option>
|
>
|
||||||
<option value="1">Clients</option>
|
<option value="all">All</option>
|
||||||
<option value="2">Repeaters</option>
|
<option value="1">Clients</option>
|
||||||
<option value="3">Room Servers</option>
|
<option value="2">Repeaters</option>
|
||||||
<option value="4">Sensors</option>
|
<option value="3">Room Servers</option>
|
||||||
</select>
|
<option value="4">Sensors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Created after</label>
|
<div className="space-y-1">
|
||||||
<Input
|
<label className="text-xs text-muted-foreground">Created after</label>
|
||||||
type="datetime-local"
|
<Input
|
||||||
value={startDate}
|
type="datetime-local"
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
value={startDate}
|
||||||
className="w-48 h-8 text-sm"
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
/>
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Created before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Created before</label>
|
<div className="space-y-1">
|
||||||
<Input
|
<label className="text-xs text-muted-foreground">Last heard after</label>
|
||||||
type="datetime-local"
|
<Input
|
||||||
value={endDate}
|
type="datetime-local"
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
value={lastHeardAfter}
|
||||||
className="w-48 h-8 text-sm"
|
onChange={(e) => setLastHeardAfter(e.target.value)}
|
||||||
/>
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Last heard before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={lastHeardBefore}
|
||||||
|
onChange={(e) => setLastHeardBefore(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||||
@@ -211,7 +322,7 @@ export function BulkDeleteContactsModal({
|
|||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||||
{(startDate || endDate) && ' (filtered)'}
|
{hasFilters && ' (filtered)'}
|
||||||
{' · '}
|
{' · '}
|
||||||
{selectedKeys.size} selected
|
{selectedKeys.size} selected
|
||||||
</div>
|
</div>
|
||||||
@@ -219,17 +330,51 @@ export function BulkDeleteContactsModal({
|
|||||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||||
{filteredContacts.length === 0 ? (
|
{filteredContacts.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
No contacts match the selected date range.
|
No contacts match the selected filters.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||||
<tr className="text-left text-xs text-muted-foreground">
|
<tr className="text-left text-xs text-muted-foreground">
|
||||||
<th className="px-3 py-1.5 w-8" />
|
<th className="px-3 py-1.5 w-8" />
|
||||||
<th className="px-3 py-1.5">Name</th>
|
<SortableHeader
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
label="Name"
|
||||||
<th className="px-3 py-1.5">Key</th>
|
field="name"
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Type"
|
||||||
|
field="type"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Key"
|
||||||
|
field="key"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Created"
|
||||||
|
field="first_seen"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Last heard"
|
||||||
|
field="last_seen"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -265,6 +410,9 @@ export function BulkDeleteContactsModal({
|
|||||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.last_seen ? formatDate(c.last_seen) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -298,6 +446,7 @@ export function BulkDeleteContactsModal({
|
|||||||
<th className="px-3 py-1.5">Type</th>
|
<th className="px-3 py-1.5">Type</th>
|
||||||
<th className="px-3 py-1.5">Key</th>
|
<th className="px-3 py-1.5">Key</th>
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Last heard</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -315,6 +464,9 @@ export function BulkDeleteContactsModal({
|
|||||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.last_seen ? formatDate(c.last_seen) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
import { ChevronDown, Info } from 'lucide-react';
|
import { ChevronDown, Info } from 'lucide-react';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
@@ -278,7 +287,9 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
config: {
|
config: {
|
||||||
urls: '',
|
urls: '',
|
||||||
preserve_identity: true,
|
preserve_identity: true,
|
||||||
include_path: true,
|
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||||
|
body_format_channel:
|
||||||
|
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||||
},
|
},
|
||||||
scope: { messages: 'all', raw_packets: 'none' },
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
},
|
},
|
||||||
@@ -2376,6 +2387,91 @@ 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_SAMPLE_VARS: Record<string, string> = {
|
||||||
|
type: 'CHAN',
|
||||||
|
text: 'hello world',
|
||||||
|
sender_name: 'Alice',
|
||||||
|
sender_key: 'a1b2c3d4e5f6',
|
||||||
|
channel_name: '#general',
|
||||||
|
conversation_key: 'abcdef1234567890',
|
||||||
|
hops: '2a, 3b',
|
||||||
|
hops_backticked: '`2a`, `3b`',
|
||||||
|
hop_count: '2',
|
||||||
|
rssi: '-95',
|
||||||
|
snr: '6.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPRISE_SAMPLE_VARS_DM: Record<string, string> = {
|
||||||
|
...APPRISE_SAMPLE_VARS,
|
||||||
|
type: 'PRIV',
|
||||||
|
channel_name: '',
|
||||||
|
conversation_key: 'a1b2c3d4e5f6',
|
||||||
|
};
|
||||||
|
|
||||||
|
function appriseApplyFormat(fmt: string, vars: Record<string, string>): string {
|
||||||
|
let result = fmt;
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
result = result.split(`{${key}}`).join(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a markdown-ish string into inline React elements (bold + code spans). */
|
||||||
|
function appriseRenderMarkdown(s: string): ReactNode[] {
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
// Split on **bold** and `code` spans
|
||||||
|
const parts = s.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
|
nodes.push(
|
||||||
|
<strong key={key++} className="font-bold">
|
||||||
|
{part.slice(2, -2)}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
} 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">
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
} else if (part) {
|
||||||
|
nodes.push(<span key={key++}>{part}</span>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppriseFormatPreview({ format, vars }: { format: string; vars: Record<string, string> }) {
|
||||||
|
const raw = appriseApplyFormat(format, vars);
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
|
||||||
|
<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)
|
||||||
|
</span>
|
||||||
|
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appriseIsDefault(value: unknown, defaultStr: string): boolean {
|
||||||
|
if (value == null) return true;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return s === '' || s === defaultStr;
|
||||||
|
}
|
||||||
|
|
||||||
function AppriseConfigEditor({
|
function AppriseConfigEditor({
|
||||||
config,
|
config,
|
||||||
scope,
|
scope,
|
||||||
@@ -2387,6 +2483,10 @@ function AppriseConfigEditor({
|
|||||||
onChange: (config: Record<string, unknown>) => void;
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
onScopeChange: (scope: 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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[0.8125rem] text-muted-foreground">
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
@@ -2445,15 +2545,111 @@ function AppriseConfigEditor({
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<Separator />
|
||||||
<input
|
|
||||||
type="checkbox"
|
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
|
||||||
checked={config.include_path !== false}
|
|
||||||
onChange={(e) => onChange({ ...config, include_path: e.target.checked })}
|
<details className="group">
|
||||||
className="h-4 w-4 rounded border-border"
|
<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" />
|
||||||
|
Available variables
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 rounded-md border border-border bg-muted/30 p-2 text-xs space-y-0.5">
|
||||||
|
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{text}'}</code>
|
||||||
|
<span className="text-muted-foreground">Message body</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{sender_name}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Sender display name</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{sender_key}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Sender public key (hex)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{channel_name}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Channel name (channel messages only)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{conversation_key}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Contact pubkey (DM) or channel key (channel)
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{type}'}</code>
|
||||||
|
<span className="text-muted-foreground">PRIV or CHAN</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{hops}'}</code>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Comma-separated hop IDs, or "direct"
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{hops_backticked}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Hops wrapped in backticks for markdown</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{hop_count}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Number of hops (0 for direct)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{rssi}'}</code>
|
||||||
|
<span className="text-muted-foreground">Last-hop RSSI in dBm</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{snr}'}</code>
|
||||||
|
<span className="text-muted-foreground">Last-hop SNR in dB</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
Empty textareas use the default format. RSSI/SNR may be empty if unavailable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<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) && (
|
||||||
|
<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 })}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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}
|
||||||
|
value={(config.body_format_dm as string) ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
|
||||||
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Include routing path in notifications</span>
|
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} />
|
||||||
</label>
|
</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) && (
|
||||||
|
<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 })}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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}
|
||||||
|
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} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
|||||||
@@ -396,11 +396,6 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const update: AppSettingsUpdate = {};
|
const update: AppSettingsUpdate = {};
|
||||||
const hours = parseInt(advertIntervalHours, 10);
|
|
||||||
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
|
|
||||||
if (newAdvertInterval !== appSettings.advert_interval) {
|
|
||||||
update.advert_interval = newAdvertInterval;
|
|
||||||
}
|
|
||||||
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
|
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
|
||||||
update.flood_scope = floodScope;
|
update.flood_scope = floodScope;
|
||||||
}
|
}
|
||||||
@@ -419,6 +414,27 @@ export function SettingsRadioSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [advertIntervalBusy, setAdvertIntervalBusy] = useState(false);
|
||||||
|
const [advertIntervalError, setAdvertIntervalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSaveAdvertInterval = async () => {
|
||||||
|
setAdvertIntervalError(null);
|
||||||
|
setAdvertIntervalBusy(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hours = parseInt(advertIntervalHours, 10);
|
||||||
|
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
|
||||||
|
if (newAdvertInterval !== appSettings.advert_interval) {
|
||||||
|
await onSaveAppSettings({ advert_interval: newAdvertInterval });
|
||||||
|
}
|
||||||
|
toast.success('Advertising interval saved');
|
||||||
|
} catch (err) {
|
||||||
|
setAdvertIntervalError(err instanceof Error ? err.message : 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setAdvertIntervalBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdvertise = async (mode: RadioAdvertMode) => {
|
const handleAdvertise = async (mode: RadioAdvertMode) => {
|
||||||
setAdvertisingMode(mode);
|
setAdvertisingMode(mode);
|
||||||
try {
|
try {
|
||||||
@@ -1109,6 +1125,18 @@ export function SettingsRadioSection({
|
|||||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||||
Recommended: 24 hours or higher.
|
Recommended: 24 hours or higher.
|
||||||
</p>
|
</p>
|
||||||
|
{advertIntervalError && (
|
||||||
|
<div className="text-sm text-destructive" role="alert">
|
||||||
|
{advertIntervalError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveAdvertInterval}
|
||||||
|
disabled={advertIntervalBusy}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{advertIntervalBusy ? 'Saving...' : 'Save Advertising Interval'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function useContactsAndChannels({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateContact = useCallback(
|
const handleCreateContact = useCallback(
|
||||||
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
async (name: string, publicKey: string, tryHistorical: boolean, type?: number) => {
|
||||||
const created = await api.createContact(publicKey, name || undefined, tryHistorical);
|
const created = await api.createContact(publicKey, name || undefined, tryHistorical, type);
|
||||||
const data = await fetchAllContacts();
|
const data = await fetchAllContacts();
|
||||||
setContacts(data);
|
setContacts(data);
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false);
|
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false, 1);
|
||||||
});
|
});
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
recv_errors: 5,
|
||||||
telemetry_history: [],
|
telemetry_history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -707,6 +708,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
recv_errors: null,
|
||||||
telemetry_history: [liveEntry],
|
telemetry_history: [liveEntry],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -742,6 +744,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
recv_errors: null,
|
||||||
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
|
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ export interface RepeaterStatusResponse {
|
|||||||
flood_dups: number;
|
flood_dups: number;
|
||||||
direct_dups: number;
|
direct_dups: number;
|
||||||
full_events: number;
|
full_events: number;
|
||||||
|
recv_errors: number | null;
|
||||||
telemetry_history: TelemetryHistoryEntry[];
|
telemetry_history: TelemetryHistoryEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,7 @@ MESHCORE_DISABLE_BOTS=true
|
|||||||
# HTTP Basic Auth (recommended when bots are enabled)
|
# HTTP Basic Auth (recommended when bots are enabled)
|
||||||
#MESHCORE_BASIC_AUTH_USERNAME=
|
#MESHCORE_BASIC_AUTH_USERNAME=
|
||||||
#MESHCORE_BASIC_AUTH_PASSWORD=
|
#MESHCORE_BASIC_AUTH_PASSWORD=
|
||||||
|
|
||||||
|
# Enable GET /api/radio/private-key to return the in-memory private key as hex
|
||||||
|
# for backup or migration. Only enable on a trusted network.
|
||||||
|
#MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=false
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.12.2"
|
version = "3.12.3"
|
||||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -12,7 +12,7 @@ dependencies = [
|
|||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"pycryptodome>=3.20.0",
|
"pycryptodome>=3.20.0",
|
||||||
"pynacl>=1.5.0",
|
"pynacl>=1.5.0",
|
||||||
"meshcore==2.3.2",
|
"meshcore==2.3.7",
|
||||||
"aiomqtt>=2.0",
|
"aiomqtt>=2.0",
|
||||||
"apprise>=1.9.8",
|
"apprise>=1.9.8",
|
||||||
"boto3>=1.38.0",
|
"boto3>=1.38.0",
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ test.describe('Apprise integration settings', () => {
|
|||||||
const preserveIdentity = page.getByText('Preserve identity on Discord');
|
const preserveIdentity = page.getByText('Preserve identity on Discord');
|
||||||
await expect(preserveIdentity).toBeVisible();
|
await expect(preserveIdentity).toBeVisible();
|
||||||
|
|
||||||
// Verify include routing path checkbox is checked by default
|
// Verify format textareas are present under Message Format heading
|
||||||
const includePath = page.getByText('Include routing path in notifications');
|
await expect(page.getByText('Message Format')).toBeVisible();
|
||||||
await expect(includePath).toBeVisible();
|
await expect(page.locator('#fanout-apprise-fmt-dm')).toBeVisible();
|
||||||
|
await expect(page.locator('#fanout-apprise-fmt-chan')).toBeVisible();
|
||||||
|
|
||||||
// Rename it
|
// Rename it
|
||||||
const nameInput = page.locator('#fanout-edit-name');
|
const nameInput = page.locator('#fanout-edit-name');
|
||||||
@@ -94,7 +95,8 @@ test.describe('Apprise integration settings', () => {
|
|||||||
config: {
|
config: {
|
||||||
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
|
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
|
||||||
preserve_identity: false,
|
preserve_identity: false,
|
||||||
include_path: false,
|
body_format_dm: '{sender_name}: {text}',
|
||||||
|
body_format_channel: '{channel_name} | {sender_name}: {text}',
|
||||||
},
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
@@ -113,18 +115,18 @@ test.describe('Apprise integration settings', () => {
|
|||||||
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
|
await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
|
||||||
|
|
||||||
// Verify checkboxes reflect our config (both unchecked)
|
// Verify preserve identity checkbox reflects our config (unchecked)
|
||||||
const preserveCheckbox = page
|
const preserveCheckbox = page
|
||||||
.getByText('Preserve identity on Discord')
|
.getByText('Preserve identity on Discord')
|
||||||
.locator('xpath=ancestor::label[1]')
|
.locator('xpath=ancestor::label[1]')
|
||||||
.locator('input[type="checkbox"]');
|
.locator('input[type="checkbox"]');
|
||||||
await expect(preserveCheckbox).not.toBeChecked();
|
await expect(preserveCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
const pathCheckbox = page
|
// Verify format textareas reflect our custom formats
|
||||||
.getByText('Include routing path in notifications')
|
const dmFormat = page.locator('#fanout-apprise-fmt-dm');
|
||||||
.locator('xpath=ancestor::label[1]')
|
await expect(dmFormat).toHaveValue('{sender_name}: {text}');
|
||||||
.locator('input[type="checkbox"]');
|
const chanFormat = page.locator('#fanout-apprise-fmt-chan');
|
||||||
await expect(pathCheckbox).not.toBeChecked();
|
await expect(chanFormat).toHaveValue('{channel_name} | {sender_name}: {text}');
|
||||||
|
|
||||||
// Go back
|
// Go back
|
||||||
page.once('dialog', (dialog) => dialog.accept());
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
|||||||
+136
-9
@@ -1049,7 +1049,8 @@ class TestAppriseFormatBody:
|
|||||||
from app.fanout.apprise_mod import _format_body
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
body = _format_body(
|
body = _format_body(
|
||||||
{"type": "PRIV", "text": "hi", "sender_name": "Alice"}, include_path=False
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
|
body_format_dm="**DM:** {sender_name}: {text}",
|
||||||
)
|
)
|
||||||
assert body == "**DM:** Alice: hi"
|
assert body == "**DM:** Alice: hi"
|
||||||
|
|
||||||
@@ -1058,7 +1059,7 @@ class TestAppriseFormatBody:
|
|||||||
|
|
||||||
body = _format_body(
|
body = _format_body(
|
||||||
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#general"},
|
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#general"},
|
||||||
include_path=False,
|
body_format_channel="**{channel_name}:** {sender_name}: {text}",
|
||||||
)
|
)
|
||||||
assert body == "**#general:** Bob: hi"
|
assert body == "**#general:** Bob: hi"
|
||||||
|
|
||||||
@@ -1072,7 +1073,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Bob",
|
"sender_name": "Bob",
|
||||||
"channel_name": "#general",
|
"channel_name": "#general",
|
||||||
},
|
},
|
||||||
include_path=False,
|
body_format_channel="**{channel_name}:** {sender_name}: {text}",
|
||||||
)
|
)
|
||||||
assert body == "**#general:** Bob: hi"
|
assert body == "**#general:** Bob: hi"
|
||||||
|
|
||||||
@@ -1086,7 +1087,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "2027"}],
|
"paths": [{"path": "2027"}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`20`" in body
|
assert "`20`" in body
|
||||||
@@ -1097,7 +1098,7 @@ class TestAppriseFormatBody:
|
|||||||
|
|
||||||
body = _format_body(
|
body = _format_body(
|
||||||
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "`direct`" in body
|
assert "`direct`" in body
|
||||||
|
|
||||||
@@ -1112,7 +1113,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`aabb`" in body
|
assert "`aabb`" in body
|
||||||
@@ -1129,7 +1130,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "aabbccddeeff", "path_len": 2}],
|
"paths": [{"path": "aabbccddeeff", "path_len": 2}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`aabbcc`" in body
|
assert "`aabbcc`" in body
|
||||||
@@ -1147,7 +1148,7 @@ class TestAppriseFormatBody:
|
|||||||
"channel_name": "#general",
|
"channel_name": "#general",
|
||||||
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_channel="**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**#general:**" in body
|
assert "**#general:**" in body
|
||||||
assert "`aabb`" in body
|
assert "`aabb`" in body
|
||||||
@@ -1164,12 +1165,118 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "aabb"}],
|
"paths": [{"path": "aabb"}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`aa`" in body
|
assert "`aa`" in body
|
||||||
assert "`bb`" in body
|
assert "`bb`" in body
|
||||||
|
|
||||||
|
def test_default_format_strings(self):
|
||||||
|
"""Default format strings produce expected output."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{
|
||||||
|
"type": "PRIV",
|
||||||
|
"text": "hi",
|
||||||
|
"sender_name": "Alice",
|
||||||
|
"paths": [{"path": "2a3b"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert body == "**DM:** Alice: hi **via:** [`2a`, `3b`]"
|
||||||
|
|
||||||
|
def test_custom_format_with_rssi(self):
|
||||||
|
"""Custom format string can include rssi/snr."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{
|
||||||
|
"type": "PRIV",
|
||||||
|
"text": "hi",
|
||||||
|
"sender_name": "Alice",
|
||||||
|
"paths": [{"path": "2a", "rssi": -95, "snr": 6.5}],
|
||||||
|
},
|
||||||
|
body_format_dm="From {sender_name}: {text} (rssi: {rssi}, snr: {snr})",
|
||||||
|
)
|
||||||
|
assert body == "From Alice: hi (rssi: -95, snr: 6.5)"
|
||||||
|
|
||||||
|
def test_unknown_placeholder_left_as_is(self):
|
||||||
|
"""Unknown {placeholders} pass through unchanged."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
|
body_format_dm="{sender_name}: {text} {unknown_var}",
|
||||||
|
)
|
||||||
|
assert body == "Alice: hi {unknown_var}"
|
||||||
|
|
||||||
|
def test_none_fields_render_empty(self):
|
||||||
|
"""None optional fields render as empty string, not 'None'."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
|
body_format_dm="{sender_name}: {text} rssi={rssi}",
|
||||||
|
)
|
||||||
|
assert body == "Alice: hi rssi="
|
||||||
|
assert "None" not in body
|
||||||
|
|
||||||
|
def test_hops_direct_when_no_paths(self):
|
||||||
|
"""hops is 'direct' when no path data exists."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#gen"},
|
||||||
|
body_format_channel="{channel_name} {hops}",
|
||||||
|
)
|
||||||
|
assert body == "#gen direct"
|
||||||
|
|
||||||
|
def test_hops_direct_when_empty_path(self):
|
||||||
|
"""hops is 'direct' when path string is empty."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{
|
||||||
|
"type": "PRIV",
|
||||||
|
"text": "hi",
|
||||||
|
"sender_name": "Alice",
|
||||||
|
"paths": [{"path": ""}],
|
||||||
|
},
|
||||||
|
body_format_dm="{hops}",
|
||||||
|
)
|
||||||
|
assert body == "direct"
|
||||||
|
|
||||||
|
def test_no_re_expansion_of_substituted_values(self):
|
||||||
|
"""Placeholders in message text must not be expanded by later passes."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "PRIV", "text": "hello {sender_name}", "sender_name": "Alice"},
|
||||||
|
body_format_dm="{sender_name}: {text}",
|
||||||
|
)
|
||||||
|
assert body == "Alice: hello {sender_name}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_format_string_uses_default(self):
|
||||||
|
"""Empty format strings in config should produce default output, not blank."""
|
||||||
|
from unittest.mock import patch as _patch
|
||||||
|
|
||||||
|
from app.fanout.apprise_mod import AppriseModule
|
||||||
|
|
||||||
|
mod = AppriseModule(
|
||||||
|
"test",
|
||||||
|
{"urls": "json://localhost", "body_format_dm": "", "body_format_channel": " "},
|
||||||
|
)
|
||||||
|
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
|
||||||
|
await mod.on_message(
|
||||||
|
{"type": "PRIV", "text": "hi", "outgoing": False, "sender_name": "Alice"}
|
||||||
|
)
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
body = mock_send.call_args[0][1]
|
||||||
|
assert "Alice" in body
|
||||||
|
assert "hi" in body
|
||||||
|
assert body != ""
|
||||||
|
|
||||||
|
|
||||||
class TestAppriseNormalizeDiscordUrl:
|
class TestAppriseNormalizeDiscordUrl:
|
||||||
def test_discord_scheme(self):
|
def test_discord_scheme(self):
|
||||||
@@ -1233,6 +1340,26 @@ class TestAppriseValidation:
|
|||||||
|
|
||||||
_validate_apprise_config({"urls": "discord://123/abc"})
|
_validate_apprise_config({"urls": "discord://123/abc"})
|
||||||
|
|
||||||
|
def test_validate_apprise_config_accepts_format_strings(self):
|
||||||
|
from app.routers.fanout import _validate_apprise_config
|
||||||
|
|
||||||
|
_validate_apprise_config(
|
||||||
|
{
|
||||||
|
"urls": "discord://123/abc",
|
||||||
|
"body_format_dm": "DM from {sender_name}: {text}",
|
||||||
|
"body_format_channel": "{channel_name}: {text}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_apprise_config_rejects_non_string_format(self):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.fanout import _validate_apprise_config
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_validate_apprise_config({"urls": "discord://123/abc", "body_format_dm": 123})
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
def test_enforce_scope_apprise_strips_raw_packets(self):
|
def test_enforce_scope_apprise_strips_raw_packets(self):
|
||||||
from app.routers.fanout import _enforce_scope
|
from app.routers.fanout import _enforce_scope
|
||||||
|
|
||||||
|
|||||||
@@ -1171,7 +1171,8 @@ class TestFanoutAppriseIntegration:
|
|||||||
config={
|
config={
|
||||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||||
"preserve_identity": True,
|
"preserve_identity": True,
|
||||||
"include_path": False,
|
"body_format_dm": "**DM:** {sender_name}: {text}",
|
||||||
|
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
|
||||||
},
|
},
|
||||||
scope={"messages": "all", "raw_packets": "none"},
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
@@ -1212,7 +1213,8 @@ class TestFanoutAppriseIntegration:
|
|||||||
name="Channel Apprise",
|
name="Channel Apprise",
|
||||||
config={
|
config={
|
||||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||||
"include_path": False,
|
"body_format_dm": "**DM:** {sender_name}: {text}",
|
||||||
|
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
|
||||||
},
|
},
|
||||||
scope={"messages": "all", "raw_packets": "none"},
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
@@ -1541,13 +1543,14 @@ class TestFanoutAppriseIntegration:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
|
async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
|
||||||
"""Apprise with include_path=True shows routing hops in the body."""
|
"""Apprise with hops in format string shows routing hops in the body."""
|
||||||
cfg = await FanoutConfigRepository.create(
|
cfg = await FanoutConfigRepository.create(
|
||||||
config_type="apprise",
|
config_type="apprise",
|
||||||
name="Path Apprise",
|
name="Path Apprise",
|
||||||
config={
|
config={
|
||||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||||
"include_path": True,
|
"body_format_dm": "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
|
"body_format_channel": "**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
},
|
},
|
||||||
scope={"messages": "all", "raw_packets": "none"},
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
||||||
# ``applied == LATEST - starting_version`` so only this constant needs to
|
# ``applied == LATEST - starting_version`` so only this constant needs to
|
||||||
# change, not every individual assertion.
|
# change, not every individual assertion.
|
||||||
LATEST_SCHEMA_VERSION = 59
|
LATEST_SCHEMA_VERSION = 60
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class TestRadioDiscovery:
|
|||||||
class TestRepeaterDiscovery:
|
class TestRepeaterDiscovery:
|
||||||
def test_produces_sensor_per_field(self):
|
def test_produces_sensor_per_field(self):
|
||||||
configs = _repeater_discovery_configs("mc", "ccdd11223344", "Rep1", "aabb")
|
configs = _repeater_discovery_configs("mc", "ccdd11223344", "Rep1", "aabb")
|
||||||
assert len(configs) == 7 # matches _REPEATER_SENSORS length
|
assert len(configs) == 8 # matches _REPEATER_SENSORS length
|
||||||
|
|
||||||
topics = [t for t, _ in configs]
|
topics = [t for t, _ in configs]
|
||||||
assert "homeassistant/sensor/meshcore_ccdd11223344/battery_voltage/config" in topics
|
assert "homeassistant/sensor/meshcore_ccdd11223344/battery_voltage/config" in topics
|
||||||
|
|||||||
@@ -722,6 +722,7 @@ class TestRepeaterStatus:
|
|||||||
"flood_dups": 10,
|
"flood_dups": 10,
|
||||||
"direct_dups": 5,
|
"direct_dups": 5,
|
||||||
"full_evts": 0,
|
"full_evts": 0,
|
||||||
|
"recv_errors": 42,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -741,6 +742,7 @@ class TestRepeaterStatus:
|
|||||||
assert response.uptime_seconds == 86400
|
assert response.uptime_seconds == 86400
|
||||||
assert response.sent_flood == 100
|
assert response.sent_flood == 100
|
||||||
assert response.recv_direct == 700
|
assert response.recv_direct == 700
|
||||||
|
assert response.recv_errors == 42
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_504_on_timeout(self, test_db):
|
async def test_504_on_timeout(self, test_db):
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ SAMPLE_STATUS = {
|
|||||||
"flood_dups": 5,
|
"flood_dups": 5,
|
||||||
"direct_dups": 2,
|
"direct_dups": 2,
|
||||||
"full_events": 0,
|
"full_events": 0,
|
||||||
|
"recv_errors": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ class TestRoomStatus:
|
|||||||
"flood_dups": 2,
|
"flood_dups": 2,
|
||||||
"direct_dups": 1,
|
"direct_dups": 1,
|
||||||
"full_evts": 0,
|
"full_evts": 0,
|
||||||
|
"recv_errors": 7,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -147,6 +148,7 @@ class TestRoomStatus:
|
|||||||
assert response.battery_volts == 4.025
|
assert response.battery_volts == 4.025
|
||||||
assert response.packets_received == 80
|
assert response.packets_received == 80
|
||||||
assert response.recv_direct == 73
|
assert response.recv_direct == 73
|
||||||
|
assert response.recv_errors == 7
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_room_acl_maps_entries(self, test_db):
|
async def test_room_acl_maps_entries(self, test_db):
|
||||||
|
|||||||
@@ -768,7 +768,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meshcore"
|
name = "meshcore"
|
||||||
version = "2.3.2"
|
version = "2.3.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bleak" },
|
{ name = "bleak" },
|
||||||
@@ -776,9 +776,9 @@ dependencies = [
|
|||||||
{ name = "pycryptodome" },
|
{ name = "pycryptodome" },
|
||||||
{ name = "pyserial-asyncio-fast" },
|
{ name = "pyserial-asyncio-fast" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/32/6e7a3e7dcc379888bc2bfcbbdf518af89e47b3697977cbfefd0b87fdf333/meshcore-2.3.2.tar.gz", hash = "sha256:98ceb8c28a8abe5b5b77f0941b30f99ba3d4fc2350f76de99b6c8a4e778dad6f", size = 69871 }
|
sdist = { url = "https://files.pythonhosted.org/packages/50/d1/e45d8fa3cac24d58c3bc2523fe67b8cd00c05ea68e1704fbbaf56cb19753/meshcore-2.3.7.tar.gz", hash = "sha256:267107e09a96f7d0d63f4bdb1402d033a724baadd9c9becf9b71a458170f60bb", size = 90787 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/e4/9aafcd70315e48ca1bbae2f4ad1e00a13d5ef00019c486f964b31c34c488/meshcore-2.3.2-py3-none-any.whl", hash = "sha256:7b98e6d71f2c1e1ee146dd2fe96da40eb5bf33077e34ca840557ee53b192e322", size = 53325 },
|
{ url = "https://files.pythonhosted.org/packages/80/3d/ff4b5971a3210da07dc793b54af9b1231fea42dfb87e2818fdcc83e10d72/meshcore-2.3.7-py3-none-any.whl", hash = "sha256:952f028b25527155e78103d01598fa3897cccfa793ba2028a32bc36c86759f14", size = 60352 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1453,11 +1453,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.1"
|
version = "1.2.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1533,7 +1533,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.12.2"
|
version = "3.12.3"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiomqtt" },
|
{ name = "aiomqtt" },
|
||||||
@@ -1569,7 +1569,7 @@ requires-dist = [
|
|||||||
{ name = "boto3", specifier = ">=1.38.0" },
|
{ name = "boto3", specifier = ">=1.38.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "meshcore", specifier = "==2.3.2" },
|
{ name = "meshcore", specifier = "==2.3.7" },
|
||||||
{ name = "pycryptodome", specifier = ">=3.20.0" },
|
{ name = "pycryptodome", specifier = ">=3.20.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
||||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user