Skip to content

Commit f83bfc9

Browse files
authored
Merge pull request #530 from SimmerV/bug-fix
fix: NaN telemetry writes, docker-compose port exposure, /v1/events docs
2 parents 8477c99 + a4a0946 commit f83bfc9

4 files changed

Lines changed: 70 additions & 5 deletions

File tree

API.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ directly for integrations. Responses are JSON unless noted.
3535
| GET | `/v1/traceroutes` | All recent traceroutes. |
3636
| GET | `/v1/stats` | Mesh totals (counts, top nodes, modem preset, etc.). |
3737

38+
### Live events (SSE)
39+
40+
| Method | Path | Notes |
41+
|---|---|---|
42+
| GET | `/v1/events` | Server-Sent Events stream of live updates, one multiplexed connection per client. Each frame's `event:` type is `node`, `chat`, `telemetry`, or `coverage`; `: ...` comment frames are keep-alive heartbeats. Served unbuffered through Caddy (`flush_interval -1`); the SPA reaches it as `/api/v1/events`. |
43+
3844
### Static map / Server
3945

4046
| Method | Path | Notes |

docker-compose.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ services:
1717
- "maintenance_work_mem=${PG_MAINTENANCE_WORK_MEM:-64MB}"
1818
- "-c"
1919
- "effective_cache_size=${PG_EFFECTIVE_CACHE_SIZE:-1GB}"
20-
ports:
21-
- "5432:5432"
20+
# No published port: the app reaches Postgres over the compose network,
21+
# and docker-published ports bypass host firewalls (ufw). For host access
22+
# use `docker compose exec postgres psql -U postgres meshinfo`, or map
23+
# "127.0.0.1:5432:5432" in a docker-compose.override.yml.
2224
volumes:
2325
# postgres:18+ stores data in a version-specific subdir; mount the
2426
# parent (/var/lib/postgresql), not /data. See POSTGRES.md.
@@ -32,6 +34,9 @@ services:
3234
mqtt:
3335
container_name: mqtt
3436
image: eclipse-mosquitto:latest
37+
# 1883 stays published on all interfaces by design — mesh gateways on the
38+
# WAN publish uplinks here. Protect it with broker auth: see the
39+
# password_file/acl_file notes in mosquitto/config/mosquitto.conf.sample.
3540
ports:
3641
- 1883:1883
3742
volumes:
@@ -54,8 +59,10 @@ services:
5459
# dns:
5560
# - 1.1.1.1
5661
# - 1.0.0.1
62+
# Localhost-only: caddy is the public entry for the API (/v1, /api, /tiles).
63+
# Docker-published ports bypass host firewalls, so don't expose 9000 directly.
5764
ports:
58-
- 9000:9000
65+
- 127.0.0.1:9000:9000
5966
restart: unless-stopped
6067
# Wait for Postgres readiness, not just container start.
6168
depends_on:
@@ -72,8 +79,9 @@ services:
7279

7380
frontend:
7481
image: ghcr.io/meshaddicts/meshinfo-spa:latest
82+
# Localhost-only: caddy is the public entry for the SPA.
7583
ports:
76-
- 8000:80
84+
- 127.0.0.1:8000:80
7785
env_file:
7886
- path: ./frontend/.env
7987
required: false

storage/db/postgres.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import datetime
1313
import json
1414
import logging
15+
import math
1516
from collections import OrderedDict
1617
from pathlib import Path
1718
from typing import Any, Dict, Optional, List, Tuple
@@ -41,6 +42,24 @@ def _json_default(obj: Any) -> Any:
4142
return str(obj)
4243

4344

45+
def _finite_or_none(value: Any) -> Any:
46+
"""Map non-finite telemetry metrics to None for typed numeric columns.
47+
48+
Protobuf's proto3 JSON mapping serializes non-finite floats as the
49+
strings "NaN"/"Infinity"/"-Infinity" (and some decode paths yield real
50+
non-finite floats); asyncpg can bind neither into a numeric column, so
51+
the whole node write fails. Everything else passes through unchanged."""
52+
if isinstance(value, float) and not math.isfinite(value):
53+
return None
54+
if isinstance(value, str):
55+
try:
56+
if not math.isfinite(float(value)):
57+
return None
58+
except (TypeError, ValueError):
59+
pass
60+
return value
61+
62+
4463
def _encode_cursor(created_at: datetime.datetime, row_id: int) -> str:
4564
"""Opaque keyset-pagination cursor for mqtt_messages ordered by (created_at, id) DESC."""
4665
raw = f"{created_at.isoformat()}|{row_id}".encode()
@@ -830,7 +849,7 @@ async def _write_node_telemetry_current(self, conn, node_id: str, telemetry: Dic
830849
present: Dict[str, Any] = {}
831850
for payload_key, col_name in self.TELEMETRY_COLUMNS.items():
832851
if payload_key in telemetry:
833-
present[col_name] = telemetry[payload_key]
852+
present[col_name] = _finite_or_none(telemetry[payload_key])
834853

835854
if not present:
836855
logger.debug("_write_node_telemetry_current: no recognized telemetry fields for node %s; skipping", node_norm)

tests/test_postgres_helpers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
PostgresStorage,
1313
_decode_cursor,
1414
_encode_cursor,
15+
_finite_or_none,
1516
_json_default,
1617
_month_partition_specs,
1718
)
@@ -52,6 +53,37 @@ def test_plain_dumps_still_raises_without_default(self):
5253
json.dumps({"ts": datetime.datetime.now()})
5354

5455

56+
class TestFiniteOrNone:
57+
"""Non-finite telemetry values must become NULL, not fail the whole
58+
node write. The exact failure shape from production: proto3 JSON
59+
renders a sensor's NaN float as the string "NaN", which asyncpg
60+
rejects for numeric columns ('must be real number, not str')."""
61+
62+
def test_nan_string_becomes_none(self):
63+
assert _finite_or_none("NaN") is None
64+
65+
def test_infinity_strings_become_none(self):
66+
assert _finite_or_none("Infinity") is None
67+
assert _finite_or_none("-Infinity") is None
68+
69+
def test_nan_float_becomes_none(self):
70+
assert _finite_or_none(float("nan")) is None
71+
72+
def test_infinite_floats_become_none(self):
73+
assert _finite_or_none(float("inf")) is None
74+
assert _finite_or_none(float("-inf")) is None
75+
76+
def test_finite_values_pass_through(self):
77+
assert _finite_or_none(3.7) == 3.7
78+
assert _finite_or_none(87) == 87
79+
assert _finite_or_none(0) == 0
80+
assert _finite_or_none(None) is None
81+
82+
def test_non_numeric_values_pass_through(self):
83+
# Not this helper's job to coerce or reject other bad input.
84+
assert _finite_or_none("not-a-number") == "not-a-number"
85+
86+
5587
class TestCursorCodec:
5688
"""Keyset-pagination cursor for mqtt_messages — see _encode_cursor/_decode_cursor."""
5789

0 commit comments

Comments
 (0)