From 70cf6c8b7d891c9218dba751071a30da68a8c3b2 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 13:36:00 +0100 Subject: [PATCH 1/3] docs(text-messages): document WS unread contract for UI #279 Add text-messages feature hub and unread-count deep dive; note TextMessageWSSerializer missing protocol field. --- docs/features/README.md | 3 + docs/features/text-messages/README.md | 80 +++++++++++++ docs/features/text-messages/unread-count.md | 120 ++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 docs/features/text-messages/README.md create mode 100644 docs/features/text-messages/unread-count.md diff --git a/docs/features/README.md b/docs/features/README.md index 0f96b9b..89d3b2a 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -88,6 +88,9 @@ the mesh. Because feeders are geographically distributed, Meshflow can show both sides of conversations even when no single radio could hear them all. Realtime updates are pushed over WebSocket. +Reference: [text-messages/](text-messages/) (REST, WS fan-out, unread/badge +contract with meshflow-ui); MeshCore ingest [meshcore/text-message-channels.md](meshcore/text-message-channels.md). + ### Mesh infrastructure monitoring A dedicated page surfacing the health of every node with an infrastructure diff --git a/docs/features/text-messages/README.md b/docs/features/text-messages/README.md new file mode 100644 index 0000000..93e342a --- /dev/null +++ b/docs/features/text-messages/README.md @@ -0,0 +1,80 @@ +# Text messages + +Meshflow stores normalised **text messages** from Meshtastic and MeshCore feeders: public channel chat, emoji reactions, and (Meshtastic) threaded replies. Rows live in the `text_messages` Django app; ingest paths differ by protocol but both emit the same `text_message_received` signal for WebSocket fan-out. + +This folder documents cross-cutting text-message behaviour. MeshCore-specific ingest and channel modelling remain under [meshcore/text-message-channels.md](../meshcore/text-message-channels.md) and [meshcore/mc-channel-sync/](../meshcore/mc-channel-sync/). + +## Implementation status + +| Area | Status | Notes | +| --- | --- | --- | +| REST list/detail `GET /api/messages/text/` | Shipped | `protocol`, `channel_id`, `constellation_id`, pagination | +| `TextMessage.protocol` on DB row | Shipped | `Protocol.MESHTASTIC` / `Protocol.MESHCORE` | +| WebSocket `/ws/messages/` | Shipped | JWT; Redis group `text_messages` | +| WS payload parity with REST | **Gap** | `TextMessageWSSerializer` omits `protocol` (see [unread-count.md](unread-count.md)) | +| Server-side unread / read receipts | Not implemented | Unread is entirely client-side in meshflow-ui | + +## Documentation map + +| Doc | Purpose | +| --- | --- | +| [unread-count.md](unread-count.md) | Realtime push, WS serializer fields, UI nav badges ([#279](https://github.com/pskillen/meshflow-ui/issues/279)) | +| [../meshcore/text-message-channels.md](../meshcore/text-message-channels.md) | MC ingest, channels, sender inference | +| [../packet-ingestion/meshtastic.md](../packet-ingestion/meshtastic.md) | MT `TEXT_MESSAGE_APP` → `TextMessage` | + +## Concepts + +- **`TextMessage`** — business row with `protocol`, optional `sender`, `channel` FK, provenance via `original_packet` (MT) or `original_mc_packet` (MC). +- **`text_message_received`** — Django signal (`packets.signals`) fired after a row is created; `ws.receivers` pushes to connected UI clients. +- **Unread** — not stored in Postgres; the SPA keeps an in-memory list keyed by protocol (see UI doc). + +## Flow (high level) + +```mermaid +sequenceDiagram + participant Ingest as packets_or_meshcore_packets + participant TM as TextMessage + participant Sig as text_message_received + participant WS as TextMessageWebSocketNotifier + participant Redis as channel_layer_text_messages + participant UI as meshflow_ui + + Ingest->>TM: create row (protocol set on model) + Ingest->>Sig: send(message, observer) + Sig->>WS: notify(message) + WS->>Redis: group_send text_messages + Redis->>UI: JSON frame on /ws/messages/ +``` + +## HTTP API + +- **List:** `GET /api/messages/text/` — filter `protocol=meshtastic|meshcore`, `channel_id`, `constellation_id`, `sender_node_id`; paginated. +- **OpenAPI:** `TextMessage` schema includes `protocol` (`MeshProtocol` string). +- **Auth:** JWT (same as rest of API). + +## WebSocket + +- **Path:** `/ws/messages/?token={jwt}` — see [openapi.yaml](../../../openapi.yaml) and [REDIS.md](../../REDIS.md) (Channels group `text_messages`). +- **Consumer:** `ws.consumers.TextMessageConsumer` — adds socket to group `text_messages`; handler `text_message` forwards `event["message"]` as JSON. +- **Notifier:** `ws.services.text_message.TextMessageWebSocketNotifier` serializes with `TextMessageWSSerializer`. + +## Consumers + +| Consumer | Use | +| --- | --- | +| meshflow-ui `WebSocketProvider` | Nav unread badges, toasts when off messages page | +| meshflow-ui `useMessagesWithWebSocket` | Prepend to active channel list on messages page | +| (none server-side) | No unread persistence | + +## Related issues + +| Issue | Repo | Topic | +| --- | --- | --- | +| [#279](https://github.com/pskillen/meshflow-ui/issues/279) | meshflow-ui | Protocol-scoped unread nav badges | +| [#341](https://github.com/pskillen/meshflow-api/issues/341) | meshflow-api | Messages UI epic (parent) | +| [#277](https://github.com/pskillen/meshflow-ui/issues/277)–[#281](https://github.com/pskillen/meshflow-ui/issues/281) | meshflow-ui | Picker / layout rework | + +## Cross-repo docs + +- UI messages feature hub: [meshflow-ui `docs/features/messages/README.md`](https://github.com/pskillen/meshflow-ui/blob/main/docs/features/messages/README.md) +- UI legacy overview: [meshflow-ui `docs/messages/`](https://github.com/pskillen/meshflow-ui/blob/main/docs/messages/README.md) diff --git a/docs/features/text-messages/unread-count.md b/docs/features/text-messages/unread-count.md new file mode 100644 index 0000000..21cc535 --- /dev/null +++ b/docs/features/text-messages/unread-count.md @@ -0,0 +1,120 @@ +# Text messages — unread count (WebSocket → UI) + +**Purpose:** Document how new text messages reach the browser and why sidebar unread badges can mix Meshtastic (MT) and MeshCore (MC) counts. Unread is **not** computed or stored in meshflow-api; the API only **pushes** message JSON. Scoping logic lives in meshflow-ui. + +**UI counterpart:** [meshflow-ui `docs/features/messages/unread-count.md`](https://github.com/pskillen/meshflow-ui/blob/main/docs/features/messages/unread-count.md) + +**Tracking:** [meshflow-ui#279](https://github.com/pskillen/meshflow-ui/issues/279) (bug). Parent epic: [meshflow-api#341](https://github.com/pskillen/meshflow-api/issues/341). + +--- + +## What the API does (and does not do) + +| Responsibility | Owner | +| --- | --- | +| Persist `TextMessage` with correct `protocol` | `text_messages` + ingest services | +| Broadcast new rows to logged-in UI clients | `ws` app via Channels | +| Per-user unread state, read receipts, badge counts | **Not implemented** — SPA only | + +There is no `GET /api/messages/unread/` and no DB column for “read”. Clearing unread in the UI does not call the API. + +--- + +## Code anchors + +| Piece | Path | +| --- | --- | +| Model `protocol` field | `Meshflow/text_messages/models.py` | +| REST serializer (includes `protocol`) | `Meshflow/text_messages/serializers.py` — `TextMessageSerializer.get_protocol` → `"meshtastic"` / `"meshcore"` | +| WS serializer (**no `protocol`**) | `Meshflow/ws/serializers.py` — `TextMessageWSSerializer` | +| WS notify | `Meshflow/ws/services/text_message.py` — `TextMessageWebSocketNotifier.notify` | +| Signal → WS | `Meshflow/ws/receivers.py` — `@receiver(text_message_received)` | +| Consumer | `Meshflow/ws/consumers.py` — `TextMessageConsumer` | +| Routing | `Meshflow/Meshflow/routing.py` — `ws/messages/` | +| Signal definition | `Meshflow/packets/signals.py` — `text_message_received` | +| MT row creation | `Meshflow/packets/services/text_message.py` | +| MC row creation | `Meshflow/meshcore_packets/services/text_message.py` | + +--- + +## Emit path + +Both protocols call the same signal after insert: + +1. Ingest creates `TextMessage` with `protocol=Protocol.MESHTASTIC` or `Protocol.MESHCORE`. +2. `text_message_received.send(sender=..., message=message, observer=...)`. +3. `TextMessageWebSocketNotifier` serializes and `group_send`s to Redis group **`text_messages`**. +4. Every connected `TextMessageConsumer` receives `text_message` and `send(text_data=json.dumps(event["message"]))`. + +All authenticated UI sessions share one broadcast group (no per-user or per-protocol channel split). + +--- + +## WebSocket payload shape + +`TextMessageWSSerializer` fields today: + +| Field | In WS payload | In REST `TextMessageSerializer` | +| --- | --- | --- | +| `id` | Yes | Yes | +| `protocol` | **No** | Yes (`meshtastic` / `meshcore`) | +| `original_packet_id` | Yes (MT) | Yes (+ `original_mc_packet_id` on REST) | +| `sender` | Yes (brief) | Yes | +| `channel` | Yes (PK) | Yes | +| `sent_at`, `message_text`, `is_emoji`, `reply_to_meshtastic_packet_id` | Yes | Yes | +| `heard` | Yes (prefetched MT observations) | Yes (MT + MC paths) | +| `mc_sender_label`, `mc_sender_candidates` | **No** | Yes (MC) | + +OpenAPI states WS frames use the same schema as `TextMessage`; **implementation is narrower than REST** for realtime pushes. + +--- + +## Why this breaks protocol-scoped unread ([#279](https://github.com/pskillen/meshflow-ui/issues/279)) + +meshflow-ui classifies each WS frame with `messageProtocol(msg)` (`src/lib/message-protocol.ts`): + +- Explicit `msg.protocol === 'meshcore'` (or legacy `2` / `'mc'`) → MeshCore. +- **Otherwise → Meshtastic.** + +When `protocol` is absent from the JSON (all WS pushes today), **every** message is treated as Meshtastic for: + +- `unreadCountForProtocol('meshtastic')` / `hasUnreadForProtocol('meshtastic')` +- `markAsReadForProtocol('meshcore')` (MC rows never removed from unread list) +- `useMessagesWithWebSocket` protocol filter (MC realtime on messages page may not prepend) + +Nav code in `nav-main.tsx` already calls `unreadCountForProtocol(messagesProtocol)` per link; the bug is **misclassification of payload**, not use of global `unreadMessages.length` on badges. + +### Intended fix (API side) + +Add `protocol` to `TextMessageWSSerializer` (same string labels as REST), and align OpenAPI / any WS tests. Optional: include `original_mc_packet_id` or `packet_id` for parity with list responses. + +### UI-side hardening (optional) + +Infer MC only when REST fields exist, or fetch protocol after WS receive — fragile compared to fixing the serializer. + +--- + +## Infrastructure + +| Concern | Detail | +| --- | --- | +| Redis | Channels layer; group name `text_messages` — [REDIS.md](../../REDIS.md) | +| Auth | JWT query param `token` on connect (`TextMessageConsumer`) | +| Separate from feeder WS | `/ws/nodes/` is bot commands only — [meshcore/phase-2-outstanding.md](../meshcore/phase-2-outstanding.md) | + +--- + +## Known gaps + +- WS payload missing `protocol` (primary cause of [#279](https://github.com/pskillen/meshflow-ui/issues/279)). +- WS payload missing MC sender helper fields (affects display if UI ever rendered unread toasts from WS-only data without REST merge). +- `TextMessageConsumer` still uses `print` when forwarding (noise in logs). +- No deduplication if the same message were pushed twice (UI appends blindly). + +--- + +## Related + +- [README.md](README.md) — text messages hub +- [../meshcore/text-message-channels.md](../meshcore/text-message-channels.md) — MC ingest +- [meshflow-ui unread-count.md](https://github.com/pskillen/meshflow-ui/blob/main/docs/features/messages/unread-count.md) — client state, nav, mark-as-read From 157f314c3db20b8c89c54e25e14d2c27233c8d19 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 14:34:15 +0100 Subject: [PATCH 2/3] fix(ws): include protocol on text message WebSocket payload (#279) Add protocol to TextMessageWSSerializer matching REST labels. Tests in ws/tests/test_text_message_ws_serializer.py. --- Meshflow/ws/serializers.py | 6 + .../tests/test_text_message_ws_serializer.py | 51 +++++++++ docs/features/text-messages/README.md | 2 +- docs/features/text-messages/unread-count.md | 103 ++++-------------- 4 files changed, 77 insertions(+), 85 deletions(-) create mode 100644 Meshflow/ws/tests/test_text_message_ws_serializer.py diff --git a/Meshflow/ws/serializers.py b/Meshflow/ws/serializers.py index 4a95867..79d1cbc 100644 --- a/Meshflow/ws/serializers.py +++ b/Meshflow/ws/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers +from common.protocol import Protocol from nodes.models import ObservedNode from text_messages.models import TextMessage @@ -12,16 +13,21 @@ class Meta: class TextMessageWSSerializer(serializers.ModelSerializer): id = serializers.UUIDField(format="hex", read_only=True) + protocol = serializers.SerializerMethodField() sender = ObservedNodeWSBriefSerializer(read_only=True) channel = serializers.PrimaryKeyRelatedField(read_only=True) sent_at = serializers.DateTimeField(format="%Y-%m-%dT%H:%M:%S.%fZ", read_only=True) original_packet_id = serializers.SerializerMethodField() heard = serializers.SerializerMethodField() + def get_protocol(self, obj): + return Protocol(obj.protocol).label.lower() + class Meta: model = TextMessage fields = [ "id", + "protocol", "original_packet_id", "sender", "recipient_meshtastic_node_id", diff --git a/Meshflow/ws/tests/test_text_message_ws_serializer.py b/Meshflow/ws/tests/test_text_message_ws_serializer.py new file mode 100644 index 0000000..7f12e9a --- /dev/null +++ b/Meshflow/ws/tests/test_text_message_ws_serializer.py @@ -0,0 +1,51 @@ +"""TextMessage WebSocket payload serialization.""" + +from django.utils import timezone + +import pytest + +from common.protocol import Protocol +from constellations.models import MessageChannel +from text_messages.models import TextMessage +from ws.serializers import TextMessageWSSerializer + + +@pytest.mark.django_db +def test_ws_serializer_includes_meshtastic_protocol(create_constellation, create_observed_node): + constellation = create_constellation() + channel = MessageChannel.objects.create(name="LongFast", constellation=constellation) + sender = create_observed_node() + message = TextMessage.objects.create( + protocol=Protocol.MESHTASTIC, + sender=sender, + channel=channel, + sent_at=timezone.now(), + message_text="hello mt", + ) + + data = TextMessageWSSerializer(message).data + + assert data["protocol"] == "meshtastic" + assert data["channel"] == channel.id + + +@pytest.mark.django_db +def test_ws_serializer_includes_meshcore_protocol(create_constellation): + constellation = create_constellation() + channel = MessageChannel.objects.create( + name="Public", + constellation=constellation, + protocol=Protocol.MESHCORE, + ) + message = TextMessage.objects.create( + protocol=Protocol.MESHCORE, + sender=None, + channel=channel, + sent_at=timezone.now(), + message_text="hello mc", + ) + + data = TextMessageWSSerializer(message).data + + assert data["protocol"] == "meshcore" + assert data["channel"] == channel.id diff --git a/docs/features/text-messages/README.md b/docs/features/text-messages/README.md index 93e342a..cf1bac3 100644 --- a/docs/features/text-messages/README.md +++ b/docs/features/text-messages/README.md @@ -11,7 +11,7 @@ This folder documents cross-cutting text-message behaviour. MeshCore-specific in | REST list/detail `GET /api/messages/text/` | Shipped | `protocol`, `channel_id`, `constellation_id`, pagination | | `TextMessage.protocol` on DB row | Shipped | `Protocol.MESHTASTIC` / `Protocol.MESHCORE` | | WebSocket `/ws/messages/` | Shipped | JWT; Redis group `text_messages` | -| WS payload parity with REST | **Gap** | `TextMessageWSSerializer` omits `protocol` (see [unread-count.md](unread-count.md)) | +| WS payload `protocol` | Shipped | `TextMessageWSSerializer` (see [unread-count.md](unread-count.md)) | | Server-side unread / read receipts | Not implemented | Unread is entirely client-side in meshflow-ui | ## Documentation map diff --git a/docs/features/text-messages/unread-count.md b/docs/features/text-messages/unread-count.md index 21cc535..3514950 100644 --- a/docs/features/text-messages/unread-count.md +++ b/docs/features/text-messages/unread-count.md @@ -1,10 +1,10 @@ # Text messages — unread count (WebSocket → UI) -**Purpose:** Document how new text messages reach the browser and why sidebar unread badges can mix Meshtastic (MT) and MeshCore (MC) counts. Unread is **not** computed or stored in meshflow-api; the API only **pushes** message JSON. Scoping logic lives in meshflow-ui. +**Purpose:** How new text messages reach the browser for realtime UI and unread badges. Unread state is **client-only** in meshflow-ui. **UI counterpart:** [meshflow-ui `docs/features/messages/unread-count.md`](https://github.com/pskillen/meshflow-ui/blob/main/docs/features/messages/unread-count.md) -**Tracking:** [meshflow-ui#279](https://github.com/pskillen/meshflow-ui/issues/279) (bug). Parent epic: [meshflow-api#341](https://github.com/pskillen/meshflow-api/issues/341). +**Tracking:** [meshflow-ui#279](https://github.com/pskillen/meshflow-ui/issues/279). Deferred UI rollup: [meshflow-api#396](https://github.com/pskillen/meshflow-api/issues/396). Epic: [#341](https://github.com/pskillen/meshflow-api/issues/341). --- @@ -12,11 +12,9 @@ | Responsibility | Owner | | --- | --- | -| Persist `TextMessage` with correct `protocol` | `text_messages` + ingest services | -| Broadcast new rows to logged-in UI clients | `ws` app via Channels | -| Per-user unread state, read receipts, badge counts | **Not implemented** — SPA only | - -There is no `GET /api/messages/unread/` and no DB column for “read”. Clearing unread in the UI does not call the API. +| Persist `TextMessage` with correct `protocol` | `text_messages` + ingest | +| Broadcast new rows to UI clients | `ws` via Channels group `text_messages` | +| Unread / read receipts | **Not implemented** — SPA only | --- @@ -24,97 +22,34 @@ There is no `GET /api/messages/unread/` and no DB column for “read”. Clearin | Piece | Path | | --- | --- | -| Model `protocol` field | `Meshflow/text_messages/models.py` | -| REST serializer (includes `protocol`) | `Meshflow/text_messages/serializers.py` — `TextMessageSerializer.get_protocol` → `"meshtastic"` / `"meshcore"` | -| WS serializer (**no `protocol`**) | `Meshflow/ws/serializers.py` — `TextMessageWSSerializer` | -| WS notify | `Meshflow/ws/services/text_message.py` — `TextMessageWebSocketNotifier.notify` | -| Signal → WS | `Meshflow/ws/receivers.py` — `@receiver(text_message_received)` | -| Consumer | `Meshflow/ws/consumers.py` — `TextMessageConsumer` | -| Routing | `Meshflow/Meshflow/routing.py` — `ws/messages/` | -| Signal definition | `Meshflow/packets/signals.py` — `text_message_received` | -| MT row creation | `Meshflow/packets/services/text_message.py` | -| MC row creation | `Meshflow/meshcore_packets/services/text_message.py` | - ---- - -## Emit path - -Both protocols call the same signal after insert: - -1. Ingest creates `TextMessage` with `protocol=Protocol.MESHTASTIC` or `Protocol.MESHCORE`. -2. `text_message_received.send(sender=..., message=message, observer=...)`. -3. `TextMessageWebSocketNotifier` serializes and `group_send`s to Redis group **`text_messages`**. -4. Every connected `TextMessageConsumer` receives `text_message` and `send(text_data=json.dumps(event["message"]))`. - -All authenticated UI sessions share one broadcast group (no per-user or per-protocol channel split). +| Model `protocol` | `Meshflow/text_messages/models.py` | +| REST serializer | `Meshflow/text_messages/serializers.py` | +| WS serializer | `Meshflow/ws/serializers.py` — `TextMessageWSSerializer` (includes `protocol`) | +| WS notify | `Meshflow/ws/services/text_message.py` | +| Tests | `Meshflow/ws/tests/test_text_message_ws_serializer.py` | --- ## WebSocket payload shape -`TextMessageWSSerializer` fields today: - -| Field | In WS payload | In REST `TextMessageSerializer` | -| --- | --- | --- | -| `id` | Yes | Yes | -| `protocol` | **No** | Yes (`meshtastic` / `meshcore`) | -| `original_packet_id` | Yes (MT) | Yes (+ `original_mc_packet_id` on REST) | -| `sender` | Yes (brief) | Yes | -| `channel` | Yes (PK) | Yes | -| `sent_at`, `message_text`, `is_emoji`, `reply_to_meshtastic_packet_id` | Yes | Yes | -| `heard` | Yes (prefetched MT observations) | Yes (MT + MC paths) | -| `mc_sender_label`, `mc_sender_candidates` | **No** | Yes (MC) | - -OpenAPI states WS frames use the same schema as `TextMessage`; **implementation is narrower than REST** for realtime pushes. - ---- - -## Why this breaks protocol-scoped unread ([#279](https://github.com/pskillen/meshflow-ui/issues/279)) +`TextMessageWSSerializer` includes `protocol` (`meshtastic` / `meshcore`, same labels as REST). Also: `id`, `channel`, `sender`, `sent_at`, `message_text`, `is_emoji`, `reply_to_meshtastic_packet_id`, `original_packet_id`, `heard` (MT prefetch when available). -meshflow-ui classifies each WS frame with `messageProtocol(msg)` (`src/lib/message-protocol.ts`): +Still narrower than REST for MC helper fields (`mc_sender_label`, `mc_sender_candidates`). -- Explicit `msg.protocol === 'meshcore'` (or legacy `2` / `'mc'`) → MeshCore. -- **Otherwise → Meshtastic.** - -When `protocol` is absent from the JSON (all WS pushes today), **every** message is treated as Meshtastic for: - -- `unreadCountForProtocol('meshtastic')` / `hasUnreadForProtocol('meshtastic')` -- `markAsReadForProtocol('meshcore')` (MC rows never removed from unread list) -- `useMessagesWithWebSocket` protocol filter (MC realtime on messages page may not prepend) - -Nav code in `nav-main.tsx` already calls `unreadCountForProtocol(messagesProtocol)` per link; the bug is **misclassification of payload**, not use of global `unreadMessages.length` on badges. - -### Intended fix (API side) - -Add `protocol` to `TextMessageWSSerializer` (same string labels as REST), and align OpenAPI / any WS tests. Optional: include `original_mc_packet_id` or `packet_id` for parity with list responses. - -### UI-side hardening (optional) - -Infer MC only when REST fields exist, or fetch protocol after WS receive — fragile compared to fixing the serializer. - ---- - -## Infrastructure - -| Concern | Detail | -| --- | --- | -| Redis | Channels layer; group name `text_messages` — [REDIS.md](../../REDIS.md) | -| Auth | JWT query param `token` on connect (`TextMessageConsumer`) | -| Separate from feeder WS | `/ws/nodes/` is bot commands only — [meshcore/phase-2-outstanding.md](../meshcore/phase-2-outstanding.md) | +OpenAPI documents WS frames as the `TextMessage` schema; `protocol` is required for correct UI classification ([#279](https://github.com/pskillen/meshflow-ui/issues/279)). --- ## Known gaps -- WS payload missing `protocol` (primary cause of [#279](https://github.com/pskillen/meshflow-ui/issues/279)). -- WS payload missing MC sender helper fields (affects display if UI ever rendered unread toasts from WS-only data without REST merge). -- `TextMessageConsumer` still uses `print` when forwarding (noise in logs). -- No deduplication if the same message were pushed twice (UI appends blindly). +- MC sender helper fields not on WS payload. +- `TextMessageConsumer` uses `print` when forwarding (log noise). +- UI dedupes by message `id`; API does not suppress duplicate pushes. --- ## Related -- [README.md](README.md) — text messages hub -- [../meshcore/text-message-channels.md](../meshcore/text-message-channels.md) — MC ingest -- [meshflow-ui unread-count.md](https://github.com/pskillen/meshflow-ui/blob/main/docs/features/messages/unread-count.md) — client state, nav, mark-as-read +- [README.md](README.md) +- [meshflow-ui unread-count.md](https://github.com/pskillen/meshflow-ui/blob/main/docs/features/messages/unread-count.md) +- [#396](https://github.com/pskillen/meshflow-api/issues/396) — multi-constellation unread rollup (UI) From 8f84d397873acdbc08e1c0113674687f913d7681 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 14:49:23 +0100 Subject: [PATCH 3/3] fix(ws): add original_mc_packet_id to text message WebSocket payload Helps meshflow-ui infer MeshCore when protocol is missing on older payloads. --- Meshflow/ws/serializers.py | 5 +++++ Meshflow/ws/tests/test_text_message_ws_serializer.py | 1 + 2 files changed, 6 insertions(+) diff --git a/Meshflow/ws/serializers.py b/Meshflow/ws/serializers.py index 79d1cbc..41232af 100644 --- a/Meshflow/ws/serializers.py +++ b/Meshflow/ws/serializers.py @@ -18,17 +18,22 @@ class TextMessageWSSerializer(serializers.ModelSerializer): channel = serializers.PrimaryKeyRelatedField(read_only=True) sent_at = serializers.DateTimeField(format="%Y-%m-%dT%H:%M:%S.%fZ", read_only=True) original_packet_id = serializers.SerializerMethodField() + original_mc_packet_id = serializers.SerializerMethodField() heard = serializers.SerializerMethodField() def get_protocol(self, obj): return Protocol(obj.protocol).label.lower() + def get_original_mc_packet_id(self, obj): + return str(obj.original_mc_packet_id) if obj.original_mc_packet_id else None + class Meta: model = TextMessage fields = [ "id", "protocol", "original_packet_id", + "original_mc_packet_id", "sender", "recipient_meshtastic_node_id", "channel", diff --git a/Meshflow/ws/tests/test_text_message_ws_serializer.py b/Meshflow/ws/tests/test_text_message_ws_serializer.py index 7f12e9a..f4af7c3 100644 --- a/Meshflow/ws/tests/test_text_message_ws_serializer.py +++ b/Meshflow/ws/tests/test_text_message_ws_serializer.py @@ -49,3 +49,4 @@ def test_ws_serializer_includes_meshcore_protocol(create_constellation): assert data["protocol"] == "meshcore" assert data["channel"] == channel.id + assert data["original_mc_packet_id"] is None