Solution 2
Solution 2
config
be delivered — will it be preconfigured via the desktop app the same way tags are configured?
(imp)
1. Architecture Overview
The Standard Ship Data Framework uses a .NET 8 Windows Service as the onboard data
acquisition agent. It reads directly from ship hardware using industrial protocols and publishes
normalised data to a cloud MQTT broker. There is no local MQTT broker on the ship.
Sensors / Equipment
↓ (Modbus / NMEA / CIP / Serial / HTTP)
.NET 8 Windows Service (Ship Data Agent)
↓ online: publish directly to cloud broker
↓ offline: buffer to local PostgreSQL + TimescaleDB, drain on
reconnect
Cloud MQTT Broker
↓
[Link] Core API + PostgreSQL + TimescaleDB
↓
Analytics / Dashboard / Reporting
Layer Responsibility
Driver Layer Reads raw data from hardware — Modbus, NMEA, CIP, Serial, HTTP.
Each protocol has its own driver.
Normalization Maps raw device values to standard fleet parameters. Adds deviceId,
Layer shipId, unit, componentId.
PostgreSQL Stores readings locally when satellite link is down. Drained in order on
Buffer reconnect.
MQTT Publisher Publishes normalised payload to cloud broker on the correct fleet topic.
1. The ship agent reads from hardware using industrial protocols — Modbus registers, NMEA
sentences, CIP tags — not MQTT subscriptions.
2. There is no intermediate publisher between devices and the agent. The agent IS the
publisher.
3. Offline resilience is handled by PostgreSQL + TimescaleDB, not by a local broker with
persistent sessions.
4. This eliminates an entire infrastructure component, reduces latency, and simplifies ops.
Key distinction: The ship agent does not subscribe to MQTT anywhere. It only publishes
MQTT — to the cloud broker. All data acquisition happens via driver protocols, not via
MQTT.
fleet/{ship_id}/{component_type}
// Example:
fleet/IMO-1234567/flowmeter
fleet/IMO-1234567/engine
fleet/IMO-1234567/nav
Topic count = ships × component types — 20 ships × 8 types = 160 topics permanently
Backend wildcards are simple and stable
Adding a new ship requires zero backend subscription changes
QoS is assigned per topic — RPM and TEMP share the same topic and therefore the same
QoS. You cannot protect critical parameters differently from non-critical ones.
ACL operates at topic level — you cannot restrict one service to RPM only if RPM and
TEMP live in the same topic.
Backend must filter by parameter field in app code — the broker cannot do it for you.
Excessive wildcard usage on broad topics increases resource consumption on cloud broker
and backend services.
Verdict: Operationally simple but sacrifices QoS per parameter and ACL per parameter — two
of MQTT's core strengths.
fleet/{ship_id}/{component_type}/{criticality}
// Example:
fleet/IMO-1234567/engine/critical → RPM, load, pressure
fleet/IMO-1234567/engine/stream → temperature, vibration
QoS and ACL split by criticality — critical topics get QoS 1, stream topics get QoS 0
Topic count doubles to 320 — still manageable
Broker does coarse filtering by criticality
Verdict: A patch for the wrong hierarchy. Discarded once ISA-95 alignment was identified as
the correct path.
fleet/{ship_id}/{component_type}/{device_id}/{parameter}
// Example:
fleet/IMO-1234567/engine/moxa-002/RPM
fleet/IMO-1234567/engine/moxa-002/TEMP
fleet/IMO-1234567/flowmeter/krohne-001/MEMINFLOW
fleet/IMO-1234567/nav/gps-001/HEADING
Each parameter is its own topic — QoS and ACL are native, no workarounds needed
RPM gets QoS 1, TEMP gets QoS 0 — set individually at the broker, not approximated
Broker handles all filtering — backend services receive exactly the data they need, no app-
code filtering
Aligns with ISA-95 international standard used across industrial IoT
Full subscription precision — services subscribe at exactly the level they own
Verdict: Chosen approach. The correct hierarchy for this domain. Topic count is managed
through subscription discipline, not by flattening the hierarchy.
4. ISA-95 / UNS — Why This Is the Standard
ISA-95 is the international standard for industrial automation hierarchies. The Unified
Namespace (UNS) is the modern MQTT-based implementation of it. The full hierarchy it
prescribes maps directly to the ship domain:
AutoIndustries/Munich/PressShopArea/DoorPressProductionLine/CuttingWorkCell
Companies like Tesla use this for millions of vehicles over MQTT. They do not simplify the
hierarchy to make consumption easier — they keep the full depth because the hierarchy is the
data model. What they engineer carefully is the subscription layer — each service subscribes
at exactly the level it owns, never broader.
The 5-level hierarchy you originally proposed was not wrong. It is literally the industry standard.
The evaluations in Section 3 confirm it is the right choice once subscription discipline is in
place.
fleet/{ship_id}/{component_type}/{device_id}/{parameter}
5.2 Examples
fleet/IMO-1234567/engine/moxa-002/RPM
fleet/IMO-1234567/engine/moxa-002/TEMP
fleet/IMO-1234567/engine/moxa-002/LOAD
fleet/IMO-1234567/flowmeter/krohne-001/MEMINFLOW
fleet/IMO-1234567/nav/gps-001/HEADING
fleet/IMO-1234567/nav/gps-001/SPEED
fleet/IMO-9999999/engine/moxa-005/RPM ← different ship, identical
shape
Rule: QoS 1 for parameters where losing a reading has operational or safety consequence.
QoS 0 for parameters used only in trend analysis or reporting. This decision is made at
commissioning in the device registry — not in code.
{
"value": 20,
"unit": "m3/h",
"timestamp": "2026-05-18T12:22:00Z"
}
fleet/IMO-1234567/flowmeter/krohne-001/MEMINFLOW
→ shipId: IMO-1234567
→ component: flowmeter
→ deviceId: krohne-001
→ parameter: MEMINFLOW
This keeps payload size minimal — important over satellite links with constrained bandwidth.
6. Device Registry
Each ship has a configuration file loaded by the ship agent at startup. This is the single source
of truth for what devices exist, how to read them, what parameters they expose, and what QoS
each parameter requires.
{
"shipId": "IMO-1234567",
"devices": [
{
"deviceId": "krohne-001",
"type": "flowmeter",
"componentId": "mainengine/me1",
"protocol": "Modbus",
"register": "40001",
"pollInterval": 5,
"parameters": [
{ "name": "MEMINFLOW", "unit": "m3/h", "qos": 1 }
]
},
{
"deviceId": "moxa-002",
"type": "engine",
"componentId": "mainengine",
"protocol": "Modbus",
"register": "40010",
"pollInterval": 5,
"parameters": [
{ "name": "RPM", "unit": "rpm", "qos": 1 },
{ "name": "TEMP", "unit": "C", "qos": 0 },
{ "name": "LOAD", "unit": "%", "qos": 1 }
]
},
{
"deviceId": "gps-001",
"type": "nav",
"componentId": "bridge",
"protocol": "NMEA",
"sentence": "GGA",
"pollInterval": 1,
"parameters": [
{ "name": "HEADING", "unit": "deg", "qos": 1 },
{ "name": "SPEED", "unit": "kn", "qos": 0 }
]
}
]
}
Key points:
protocol + register/sentence tells the driver how to acquire the reading — there is no
localTopic
qos is defined per parameter — the MQTT Publisher uses this when calling publish
pollInterval drives the Polling Engine per device
forwardTopic is constructed at runtime:
fleet/{shipId}/{type}/{deviceId}/{parameter}
Registry grows or shrinks per ship — topic structure never changes
Registry update process: When a device is replaced at port, the ops team pushes an
updated registry over the satellite/4G uplink. Agent picks it up on next restart. Ship crew are
not involved.
The broker does this natively. No application code needed for routing. Each backend service
receives only what it subscribed to — pre-filtered at the broker, not in app code.
Subscription discipline — team rule: Every service subscribes at exactly the level it
owns. No service uses fleet/# except raw ingestion. Wildcard breadth maps directly to
broker delivery load and backend resource consumption. Every new subscription must be
reviewed for breadth before going to production. This is not optional at scale.
9. Payload Validation
Problem 1 — Empty or malformed payload
The broker routes on topic string only. It delivers empty or malformed JSON without complaint.
The backend receives it and either crashes or silently drops the reading.
Field Rule
value Numeric, not null, within configured sensor range
unit Non-null, matches parameter definition in registry
timestamp Valid UTC ISO 8601, not future-dated
if ([Link](payload))
return; // log and discard
if (reading?.Value is null)
// write to dead letter table — do not process
Rule: Validate at the agent — it is the only place that knows what a valid reading looks like
per parameter. Backend parsing is a safety net, not the primary defence.
Reconnect Drain buffer ordered by captured_at ASC → publish with original QoS →
mark published = true
Important: QoS level from the registry must be preserved when draining the buffer on
reconnect — not defaulted to QoS 0.
This is manageable for any modern MQTT broker. HiveMQ, EMQX, and Mosquitto handle
millions of topics. The concern is not topic count — it is subscriber load per wildcard.
MQTT broker handles IoT fan-out → bridge to Kafka or similar stream processor → aggregation
and filtering happens in the stream layer, not on the broker.
For this MVP, the broker handles everything. When fleet size justifies it, add a broker-to-stream
bridge without changing the topic structure — the ISA-95 hierarchy is compatible with this
pattern by design.
13. Summary
Final rule: The sender's topic design and the receiver's subscription design are two
separate problems. The hierarchy must be designed with both in mind simultaneously. ISA-
95 solves the sender side completely. Subscription discipline solves the receiver side. Both
are required.
Payload validation
Problem 1 — Empty or malformed payload
The broker routes on topic string only. It delivers empty, null, or malformed JSON payloads
without complaint. The backend receives it and either crashes or silently drops the reading —
no visibility into what was lost.
Problem 2 — Missing fields inside valid JSON
Even if JSON parses correctly, individual fields like deviceId , parameter , or value could be
null or missing. The backend then stores an incomplete row — or worse, passes null into the
formula engine and produces garbage output downstream.
Field Rule
shipId Non-null, matches registry
deviceId Non-null, exists in registry
componentId Non-null, exists in registry
parameter Non-null, known parameter name
value Numeric, not null, within sensor range
timestamp Valid UTC ISO 8601
The dead letter table holds every message that failed backend validation. No reading is silently
lost — it is quarantined for ops investigation.
Rule
Validate at the ship agent — it is the only place that knows what a valid payload looks like
per device.
Backend parsing is a safety net, not the primary defence.
┌────────────────────────────┐
│ Device Registry JSON │
│-----------------------------│
│ shipId │
│ protocol │
│ register / sentence │
│ component type │
│ deviceId │
│ parameter mappings │
│ qos │
│ poll interval │
└─────────────┬──────────────┘
│
▼
┌────────────────────────────────┐
│ .NET Ship Agent Service │
└────────────────────────────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ Internet Available │ │ Internet Unavailable │
└──────────┬─────────┘ └──────────┬───────────┘
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ Cloud MQTT Broker │ │ PostgreSQL Buffer │
│--------------------│ │----------------------│
│ Topic Routing │ │ Store unpublished │
│ Wildcard Matching │ │ telemetry locally │
│ ACL Enforcement │ │ Preserve QoS/order │
└──────────┬─────────┘ └──────────┬───────────┘
│ │
│ ▼
│ ┌────────────────────────┐
│ │ Reconnect Drain Engine │
│ │------------------------│
│ │ Publish oldest first │
│ │ Mark as published │
│ └──────────┬─────────────┘
│ │
└──────────────┬─────────────┘
▼
┌────────────────────────┐
│ [Link] Core Backend │
│------------------------│
│ MQTT Subscribers │
│ Topic Parsing │
│ Parameter Mapping │
│ Validation │
│ Store into TimescaleDB │
└──────────┬─────────────┘
│
▼
┌────────────────────────┐
│ Analytics / Dashboard │
│ Alerts / Reporting │
│ Fleet Monitoring │
└────────────────────────┘
fleet/{ship_id}/{component_type}/{device_id}/{parameter}
Examples:
fleet/IMO-1234567/engine/moxa-002/RPM
fleet/IMO-1234567/engine/moxa-002/TEMP
fleet/IMO-1234567/flowmeter/krohne-001/MEMINFLOW
fleet/IMO-1234567/nav/gps-001/HEADING
This hierarchy follows the ISA-95 / Unified Namespace (UNS) industrial telemetry standard
where the topic itself becomes part of the data model and routing identity.
fleet/+/engine/+/RPM
Example routing:
Published topic:
fleet/IMO-1234567/engine/moxa-002/RPM
Possible subscribers:
fleet/IMO-1234567/engine/moxa-002/RPM
→ exact parameter only
fleet/IMO-1234567/engine/moxa-002/+
→ all parameters from one device
fleet/IMO-1234567/engine/+/RPM
→ RPM from all engine devices on one ship
fleet/+/engine/+/RPM
→ RPM from all ships
fleet/IMO-1234567/#
→ all telemetry from one ship
fleet/#
→ all telemetry in the system
The MQTT broker performs this routing internally using topic matching trees. Backend services
only receive messages matching their subscriptions.
The primary scalability concern is NOT total topic count. Modern brokers can handle millions of
topics. The actual concern is excessive wildcard breadth and subscriber fanout.
For example:
fleet/#
causes the subscriber to receive every telemetry message in the system and increases:
network traffic
backend CPU usage
payload parsing cost
memory usage
processing latency
This hierarchy also keeps the payload lightweight because most telemetry identity already
exists inside the topic itself.
Here it is:
Question
What QoS level should each parameter type use — which parameters are strict enough to need
QoS 2, which can tolerate QoS 1 or QoS 0, and how do we decide that mapping?
Answer in Brief
The QoS mapping is not hardcoded in the application. It is defined per parameter in the device
registry JSON — making it easy to change without touching code. The actual classification of
which parameter gets which QoS level is decided by the domain team using the sensor
parameter excel sheet, not by the development team unilaterally.
Critical point: QoS 2 is a 4-step handshake between publisher and broker. It doubles
network round trips per message. Over a satellite link with high latency — common on ships
— QoS 2 can significantly reduce throughput and increase buffering time. Use it only where
exactly-once delivery is genuinely required, not as a default safe choice.
High frequency (every 1–5 seconds) — QoS 2 handshake overhead becomes significant →
prefer QoS 1 maximum
Low frequency (every 30s+) — QoS 2 overhead is tolerable if exactly-once is genuinely
needed
Changing a parameter's QoS requires only a registry update and agent restart — no code
change
Different ships can have different QoS for the same parameter if their operational profile
differs
The ops team owns QoS classification, not the development team
{
"shipId": "IMO-1234567",
"devices": [
{
"deviceId": "krohne-001",
"type": "flowmeter",
"componentId": "mainengine/me1",
"protocol": "Modbus",
"register": "40001",
"pollInterval": 5,
"parameters": [
{ "name": "MEMINFLOW", "unit": "m3/h", "qos": 1 },
{ "name": "MEMOUTFLOW", "unit": "m3/h", "qos": 1 },
{ "name": "BUNKER", "unit": "m3", "qos": 2 }
]
},
{
"deviceId": "moxa-002",
"type": "engine",
"componentId": "mainengine",
"protocol": "Modbus",
"register": "40010",
"pollInterval": 5,
"parameters": [
{ "name": "RPM", "unit": "rpm", "qos": 1 },
{ "name": "LOAD", "unit": "%", "qos": 1 },
{ "name": "TEMP", "unit": "C", "qos": 0 }
]
},
{
"deviceId": "gps-001",
"type": "nav",
"componentId": "bridge",
"protocol": "NMEA",
"sentence": "GGA",
"pollInterval": 1,
"parameters": [
{ "name": "HEADING", "unit": "deg", "qos": 1 },
{ "name": "POSITION", "unit": "deg", "qos": 1 },
{ "name": "SPEED", "unit": "kn", "qos": 0 }
]
}
]
}
How the Ship Agent Uses QoS at Runtime
The MQTT Publisher reads the qos field per parameter from the registry and passes it directly
to the publish call:
No hardcoded QoS anywhere in application code. The registry drives everything.
On drain:
Why this matters: A QoS 2 bunker metering reading buffered during an outage must still
arrive exactly once when the link restores. Draining it at QoS 0 defeats the original
guarantee entirely.
This table becomes the registry. Any future QoS change is a registry update — reviewed,
versioned, and deployed by ops without a code release.
Summary
Rule: QoS is a cost on the satellite link and broker. Do not default to QoS 2 for safety.
Default to QoS 1 for operational parameters and reserve QoS 2 only for parameters where
a duplicate causes a real downstream consequence — billing, regulatory records, or alarm
deduplication. The mapping lives in the registry. The domain team owns the classification.
What should the payload structure look like — what fields must every message carry regardless
of parameter type? (imp)
Payload Structure
The telemetry payload structure defines the actual sensor reading that is transported through
MQTT. Since the topic hierarchy already carries most of the telemetry identity ( ship_id ,
component , device_id , parameter ), the payload is intentionally kept minimal to reduce
bandwidth usage over satellite links.
{
"value": 20,
"unit": "m3/h",
"timestamp": "2026-05-18T12:22:00Z"
}
Example:
Topic:
fleet/IMO-1234567/flowmeter/krohne-001/MEMINFLOW
Payload:
{
"value": 20,
"unit": "m3/h",
"timestamp": "2026-05-18T12:22:00Z"
}
shipId → IMO-1234567
component → flowmeter
deviceId → krohne-001
parameter → MEMINFLOW
Required Fields
Every telemetry payload must contain these fields regardless of parameter type.
1. value
Examples:
RPM value
temperature
flow rate
GPS speed
pressure
Examples:
{
"value": 80
}
{
"value": 32.5
}
Rules:
2. unit
Examples:
rpm
m3/h
deg
C
%
kn
Example:
{
"unit": "rpm"
}
Reason:
Without units the backend or analytics engine may interpret values incorrectly.
Example:
20 could mean:
20 RPM
20 °C
20 m3/h
3. timestamp
Represents the UTC time at which the reading was captured on the ship.
Example:
{
"timestamp": "2026-05-18T12:22:00Z"
}
Rules:
Reason:
Telemetry systems require proper ordering and replay capability.
Without timestamps:
Backend Reconstruction
The backend reconstructs full telemetry context by combining:
Example:
Topic:
fleet/IMO-1234567/engine/moxa-002/RPM
Payload:
{
"value": 80,
"unit": "rpm",
"timestamp": "2026-05-18T12:22:00Z"
}
Backend reconstructs:
{
"shipId": "IMO-1234567",
"component": "engine",
"deviceId": "moxa-002",
"parameter": "RPM",
"value": 80,
"unit": "rpm",
"timestamp": "2026-05-18T12:22:00Z"
}
Payload Validation Rules
Validation must happen at the ship agent before publishing.
Checks:
Invalid payloads:
Backend validation still exists as a defensive safety layer but should not be the primary
validation mechanism.
Examples:
quality
alarm state
sequence number
source protocol
confidence score
checksum
Example:
{
"value": 80,
"unit": "rpm",
"timestamp": "2026-05-18T12:22:00Z",
"quality": "GOOD"
}
However, for the MVP the minimal payload structure is preferred because it keeps the telemetry
lightweight, simple, and operationally efficient.