๐ WIA Delivery Robot Standard Ebook | Chapter 3 of 8
Chapter 2 explained how a delivery robot turns raw sensor signals into an estimate of where it is and what surrounds it. This chapter is about the next step: writing that estimate down in a form that another vendor's software can read without guesswork. Phase 1 of WIA ROB-010 is the data layer of the whole standard. The REST calls in Phase 2, the navigation behaviour in Phase 3 and the fleet telemetry in Phase 4 all move the same few documents around: a RobotState, a Package, a Route and the SensorReadings embedded in the robot state. If these documents are ambiguous, every layer above them inherits the ambiguity.
The chapter walks through each schema field by field, explains what the specification actually constrains and what it leaves open, and then lists the gaps an implementer must close before the schemas can be used in production. Where this book proposes a definition that the standard does not contain, the text says so explicitly; those proposals are labelled as an implementer profile and are not part of WIA ROB-010 v1.0.0.
Phase 1 states four design principles: interoperability (one format for any delivery robot system), extensibility (room for new robot classes and capabilities), precision (fine-grained state with timestamps and confidence values) and completeness (navigation, packages, telemetry and fleet operations covered by one family of documents). The principles are sensible, but they only become testable because the standard expresses its data model as JSON Schema documents rather than prose tables. A schema can be executed: a validator either accepts a payload or returns a list of violations, and two vendors running the same schema reach the same verdict.
Every Phase 1 schema declares "$schema": "https://json-schema.org/draft/2020-12/schema", which selects the 2020-12 edition of JSON Schema published by the json-schema.org project. Only a handful of keywords do the structural work in this standard:
| Keyword | Role in ROB-010 | Example from Phase 1 |
|---|---|---|
$schema | Selects the dialect (2020-12) so validators apply the right rules | Root of robot-state.json |
$id | Gives each schema a canonical URI; also the base for resolving relative references | https://wiastandards.com/schemas/delivery-robot/v1/package.json |
$defs | Holds reusable sub-schemas inside a document | Position, SensorReadings |
$ref | Points to a sub-schema, locally (#/$defs/...) or in another document | {"$ref": "#/$defs/Position"} |
required, enum, minimum/maximum, pattern, minItems | The actual constraints on values | battery_level 0–100, access_code pattern |
format | Names a well-known string format (uuid, date-time, uri) | robot_id, timestamp |
The $id values follow one pattern, https://wiastandards.com/schemas/delivery-robot/v1/<name>.json, with the major version in the path. Treat these URIs as identifiers, not as download locations: a validator should load the schemas from a local, pinned copy that you have reviewed, never fetch them from the network at runtime. A robot that cannot validate its own telemetry because a web server is unreachable has turned a data contract into an availability dependency.
First, in the 2020-12 edition format is an annotation by default. A validator that follows the default vocabulary reports that a field claims to be a UUID but does not check it. Most libraries offer an option to turn format checking into assertions (for example a format checker object in Python's jsonschema, or the ajv-formats plug-in for Ajv). Phase 1 leans heavily on format: robot_id, package_id and route_id are all "format": "uuid", and every timestamp is "format": "date-time". Without format assertion switched on, a payload with "robot_id": "robot-07" passes. Section 3.8 shows this with real validator output.
Second, none of the Phase 1 schemas set additionalProperties or unevaluatedProperties. Unknown fields are therefore allowed. That is deliberate for extensibility, but it also means a misspelt optional field such as "heaading" is silently accepted and the intended heading is simply absent. A practical rule used by many interoperable systems is: be strict when you produce (run your own output against a stricter copy of the schema that forbids unknown properties) and tolerant when you consume (accept unknown fields from other vendors, but log them).
RobotState ($id ending in robot-state.json) is the snapshot a robot reports about itself: where it is, how it is moving, how much energy it has, what it is doing and what it carries. It is the payload of GET /api/v1/robots/{robot_id} in Phase 2 and of the MQTT topic fleet/robots/{robot_id}/state in Phase 4, so one definition serves both the request/response and the streaming path.
| Field | Type and constraint | Required | Implementation notes |
|---|---|---|---|
robot_id | string, format: uuid | Yes | Description says UUID v4; the uuid format does not check the version nibble |
timestamp | string, format: date-time | Yes | Time the state was captured, not sent; use UTC with Z |
version | string, pattern ^\d+\.\d+\.\d+$ | Yes | Version of the schema, not of the firmware |
position | $ref Position | Yes | See 3.3 |
battery_level | number, 0–100 | Yes | Percentage state of charge |
heading | number, 0–360 | No | Degrees, 0 = North, 90 = East (clockwise) |
speed | number, ≥ 0 | No | Metres per second; magnitude only |
mode | enum autonomous, manual, idle, charging, error | No | Coarser than the Phase 3 behaviour states (Chapter 5) |
cargo | $ref CargoStatus | No | Referenced but not defined (3.7) |
route | $ref ActiveRoute | No | Referenced but not defined (3.7) |
sensors | $ref SensorReadings | No | See 3.6 |
errors | array of $ref Error | No | Error not defined (3.7) |
The robot_id is the join key for everything else in the ecosystem: package assignments, route calculations, fleet statistics and audit trails. It should be generated once, when the robot is commissioned, and stored in non-volatile memory; regenerating it after a firmware reflash breaks the history of that robot across every system that recorded it. If your deployment requires the identifier to be a version-4 UUID, as the description says, enforce that with an extra pattern in your strict producer schema, because the uuid format accepts any UUID version:
"robot_id": {
"type": "string",
"format": "uuid",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
}
The date-time format refers to the RFC 3339 profile of ISO 8601, which requires a full date, a full time and an explicit offset. 2025-12-26T10:40:12.250Z is valid; 2025-12-26 10:40:12 (no T, no offset) is not. Always emit UTC. Local offsets are legal, but they make log correlation across a multi-city fleet error-prone. The timestamp should be the moment the underlying measurements were taken. When a robot buffers state during a connectivity gap and uploads it later, receivers must be able to tell the difference between old data arriving late and new data; mixing capture time and send time destroys that distinction. Fractional seconds are allowed by RFC 3339 and are worth using: at walking speed a robot moves more than a metre per second, so whole-second timestamps are too coarse for reconstructing near-miss events.
heading uses the navigation convention: degrees clockwise from North. Robots built on ROS 2 usually hold yaw in the REP-103 convention (counter-clockwise from East, in radians), and Chapter 2 gives the conversion. The schema allows both 0 and 360, which describe the same direction; normalize to the half-open interval [0, 360) when producing, so that consumers comparing headings do not see a spurious 360-degree jump. speed has a minimum of 0 and no direction, so reversing is represented by the heading, not by a negative speed. If your platform genuinely drives backwards (some do when leaving a narrow docking bay), document how you report it rather than emitting negative values that will fail validation.
battery_level is a percentage from 0 to 100. The schema does not say how it is computed. A battery management system typically estimates state of charge from a combination of coulomb counting and voltage models; a raw voltage mapped linearly to 0–100 behaves very differently under load. Because Phase 3 triggers a failure-mode response when the battery falls below 15 % and Phase 4 schedules proactive charging at around 20–30 %, the meaning of this number has safety and dispatch consequences. State in your implementation documentation which estimate you report.
The five mode values describe who or what is in control, not the detailed task. A robot crossing a road, waiting at a door and handing over a parcel are all autonomous; the finer states belong to the Phase 3 behaviour state machine, and Chapter 5 gives a mapping table. Two practical rules help: emit error only when the robot cannot continue without intervention (a recoverable warning belongs in the errors array while the mode stays autonomous), and emit manual whenever a remote or local operator is commanding motion, because incident analysis depends on knowing whether software or a human was driving.
Position is defined once in $defs and reused by the robot state and by every route waypoint. Only latitude (−90 to 90) and longitude (−180 to 180) are required. The optional fields are altitude (metres above sea level), accuracy (metres, non-negative) and source, an enum of gps, rtk-gps, slam, visual-odometry and fused.
Three pieces of context are missing from the specification and must be fixed by each implementation:
One degree of latitude is roughly 111 km everywhere, so the number of decimal places sets the finest resolution you can express. Longitude degrees shrink with the cosine of latitude, so the east–west resolution is somewhat finer at mid-latitudes.
| Decimal places | Latitude step | Approximate resolution | Suitability |
|---|---|---|---|
| 4 | 0.0001° | about 11 m | Zone or block level only |
| 5 | 0.00001° | about 1.1 m | Consistent with metre-level GNSS |
| 6 | 0.000001° | about 0.11 m | Sidewalk-lane level; matches the Phase 3 0.5 m target |
| 7 | 0.0000001° | about 1.1 cm | Consistent with RTK-grade fixes |
Emitting more digits than your accuracy supports does not add information, but truncating below it throws information away. Six or seven decimal places are a reasonable default for a delivery robot. The source value should describe the estimate actually published: if an extended Kalman filter combines RTK corrections with wheel odometry and LiDAR scan matching, the published position is fused, and the individual inputs belong in diagnostics rather than in the position object.
The Package schema (package.json) describes the item being delivered and the rules for handing it over. Its required fields are package_id, recipient, pickup_location and dropoff_location; the rest is optional. It is created through POST /api/v1/packages and read back through GET /api/v1/packages/{package_id} (Chapter 4).
| Field | Type and constraint | Notes |
|---|---|---|
package_id | string, format: uuid (required) | Internal key; see the ID pitfall in 3.7 |
tracking_number | string | External, human-facing identifier from the merchant or carrier |
sender, recipient | $ref Contact (recipient required) | Contact not defined in the spec |
pickup_location, dropoff_location | $ref Location (required) | Location not defined in the spec |
package_details | object | weight_kg, dimensions_cm, category, fragile, temperature_controlled, temperature_range_celsius |
access_control | object | access_code, qr_code, biometric_required, signature_required |
status | enum of eight states | Lifecycle in 3.4.3 |
priority | enum normal, express, urgent; default normal | Input to task assignment (Chapter 6) |
estimated_delivery, delivered_at | string, format: date-time | Estimate versus fact; never overwrite one with the other |
weight_kg and the three dimensions_cm values are non-negative numbers. They exist so that dispatch can check whether a package fits a robot's compartment before assigning it, which is why it pays to fill them even though they are optional. category is one of food, retail, medical, document and other. The category is a routing and handling hint, not a legal classification; a platform that carries medicines must still apply whatever local rules govern that, outside this schema.
When temperature_controlled is true, temperature_range_celsius gives a min and max. JSON Schema cannot easily express "min must not exceed max", so that check belongs in application code. It is also worth deciding early what happens if a package is temperature-controlled but no range is supplied: rejecting such a package at creation time is safer than letting a robot without active cooling accept it.
The access_control object tells the robot how to release the package. access_code is a string of four to eight digits (^[0-9]{4,8}$). Keep it a string: a code such as 0427 loses its leading zero if any component converts it to an integer, and the pattern then correctly rejects it. qr_code carries a token for a scanned hand-over, and biometric_required and signature_required are flags for stronger recipient verification.
The access code is a secret. A four-digit code has only 10,000 possible values, so its protection comes from limiting guesses, not from its length. Recommended practice:
access_code into RobotState, telemetry, fleet dashboards or application logs. Send it to the compartment controller over the authenticated command channel only.delivered, failed or returned.The status enum lists eight states: pending, assigned, picked_up, in_transit, arrived, delivered, failed and returned. The specification lists the states but does not define which transitions are allowed. The diagram and table below are this book's recommended reading of the names; treat them as an implementer profile.
+---------+
| pending |
+----+----+
| robot selected
v
+----------+
| assigned |
+----+-----+
| package loaded, lid locked
v
+-----------+
| picked_up |
+----+------+
| leaves pickup location
v
+------------+
| in_transit |
+----+-------+
| reaches dropoff location
v
+---------+ recipient verified +-----------+
| arrived +----------------------->| delivered | (terminal)
+----+----+ +-----------+
|
any of assigned ... arrived may fail
v
+--------+ back at origin/hub +----------+
| failed +------------------------>| returned | (terminal)
+--------+ +----------+
| From | To | Trigger (recommended) |
|---|---|---|
pending | assigned | Dispatcher selects a robot |
assigned | picked_up | Package placed in compartment and lid locked |
picked_up | in_transit | Robot leaves the pickup location |
in_transit | arrived | Robot reaches the drop-off location |
arrived | delivered | Recipient authenticated and compartment emptied |
assigned … arrived | failed | Recipient absent, robot fault, access failure, route blocked |
failed | returned | Package back at origin or hub |
assigned | pending | Operator policy: robot withdrawn before loading |
Enforce transitions on the server and reject anything else with a validation error. In particular, delivered_at should be set exactly once, on the transition into delivered, and never edited afterwards. Whether a failed package may be re-attempted (failed back to assigned) is a business decision; if you allow it, record the attempt count, because otherwise the lifecycle history appears to loop.
A Route (route.json) is a planned path expressed as an ordered list of waypoints. Its required fields are route_id (UUID) and waypoints, which must contain at least two items. Each waypoint needs a position (the same Position definition as the robot) and a waypoint_type of start, intermediate, destination or charging_station; it may carry an arrival_time and a dwell_time_seconds.
The route-level fields are total_distance_meters and estimated_duration_seconds (both non-negative), route_preference (fastest, shortest, safest or energy_efficient) and navigation_constraints, an object with max_speed_ms, avoid_roads, sidewalk_only and elevator_allowed. The preference is an input to the cost function of the global planner described in Chapter 5; the constraints are hard limits that the planner must not trade off.
start or the last is destination. JSON Schema 2020-12 can express part of this, as shown below; "the last item must be a destination" cannot be expressed directly and needs an application check.max_speed_ms has no minimum or maximum. Add exclusiveMinimum: 0 in your profile, and cap it at the platform's certified speed.arrival_time values should be non-decreasing along the list, and total_distance_meters should be at least the sum of straight-line distances between consecutive waypoints. Both are cheap sanity checks that catch planner bugs.constraints object while Phase 1 names it navigation_constraints. Map one to the other explicitly at the API boundary (Chapter 4).A stricter producer profile can pin the first waypoint and require exactly one destination:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.example.com/delivery-robot/profile/v1/route-strict.json",
"allOf": [{ "$ref": "https://wiastandards.com/schemas/delivery-robot/v1/route.json" }],
"properties": {
"waypoints": {
"prefixItems": [
{ "properties": { "waypoint_type": { "const": "start" } } }
],
"contains": { "properties": { "waypoint_type": { "const": "destination" } } },
"minContains": 1,
"maxContains": 1
},
"navigation_constraints": {
"properties": { "max_speed_ms": { "exclusiveMinimum": 0 } }
}
}
}
Note that the profile references the official schema through allOf and only adds constraints. It never redefines a field, so any document valid against the profile is still valid against WIA ROB-010.
SensorReadings is the optional sensors object inside a robot state. It has four members:
| Member | Shape | Open questions to settle in your profile |
|---|---|---|
lidar | array of scans: sensor_id, timestamp, scan_rate_hz, points[] with x, y, z, intensity | Coordinate frame and unit of points (metres in the sensor frame is usual); intensity scale is vendor-specific |
cameras | array: sensor_id, timestamp, image_url, resolution, detections[] with class, confidence (0–1), bounding_box (integer x, y, width, height) | Pixel origin (top-left is the common convention); class vocabulary |
ultrasonic | array: sensor_id, distance_cm (≥ 0) | How "no echo" is reported (omit the entry rather than sending a sentinel value) |
imu | acceleration {x, y, z}; gyroscope {roll, pitch, yaw} | Units are not stated; gyroscopes measure angular rate, yet the axis names read like angles |
The IMU entry deserves attention. Acceleration is naturally in m/s² (a robot at rest reads roughly 9.8 on the vertical axis because of gravity). The gyroscope object uses the names roll, pitch and yaw, which are usually angles, while a gyroscope outputs angular velocity. Decide whether your implementation reports rates (for example in radians per second, as Chapter 2 recommends) or integrated orientation angles, and write that down; a consumer that guesses wrong will misread a gentle turn as a violent tilt. Similarly, the class labels in camera detections are free strings. If several vendors feed the same fleet, agree on a small shared vocabulary (pedestrian, cyclist, vehicle, wheelchair, animal, static_obstacle) and map vendor labels onto it at the edge.
The schema allows a full point cloud in JSON, but that is rarely a good idea: as the worked estimate in Chapter 2 (section 2.7.2) shows, a single LiDAR streamed as JSON points produces on the order of tens of megabytes per second, far beyond what a cellular uplink and a fleet database should carry for routine monitoring. The same logic applies to images, which is why the camera object carries an image_url rather than pixels.
The practical pattern is to publish detections and summaries in RobotState, keep raw data on the robot in a ring buffer, and upload a specific window of raw data only when an event (an emergency stop, a collision alarm, an operator request) calls for it. That also helps with privacy: camera frames of the street contain faces and licence plates, and Chapter 6 describes blurring them before they leave the robot.
Phase 1 section 6.3 recommends Protocol Buffers for high-frequency telemetry and delta updates for data that changes often. The standard does not define either the message definitions or the delta format, so any concrete choice is part of your profile. One reasonable approach is to keep the JSON schema as the canonical model, generate an equivalent Protocol Buffers message for the high-rate path, and test that both encodings round-trip to the same JSON. For deltas, an existing standard avoids inventing a format: JSON Merge Patch (RFC 7386) expresses "only these fields changed" as a partial document. The example below is illustrative:
{
"robot_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2025-12-26T10:40:13.250Z",
"position": { "latitude": 37.566818, "longitude": 126.980117 },
"speed": 1.3
}
Two cautions apply. In Merge Patch a null value means "remove this field", so you cannot use it to set a value to null; and arrays are replaced wholesale, not patched element by element. A delta is also not a valid RobotState on its own (it lacks version and battery_level), so validate the merged result, not the patch, and send a full state periodically so that a consumer that missed a delta can resynchronize.
WIA ROB-010 v1.0.0 has several places where the schemas cannot be used exactly as printed. Implementers who know about them in advance can close them in a documented profile instead of discovering them in integration testing.
| Issue | Where | Effect | Recommended handling |
|---|---|---|---|
CargoStatus, ActiveRoute, Error referenced but not defined | robot-state.json | The $ref cannot be resolved: Ajv refuses to compile the schema, and Python jsonschema raises an error when validation reaches the field | Define them in a profile document and state that you did |
Contact, Location referenced but not defined | package.json | Same compile-time failure | Same |
#/$defs/Position used in route.json, which has no $defs | route.json | Local reference points at nothing | Bundle Position into the route schema or reference it by absolute URI |
Phase 2 examples return package_id pkg-2025-12-26-0001 and route_id route-2025-12-26-001 | Phase 2 §4.1, §5.1 | Not UUIDs; fails format: uuid once format assertion is on | Issue UUIDs; put human-readable references in tracking_number |
format is an annotation by default | All schemas | Bad IDs and timestamps pass silently | Enable format assertion in every validator |
access_code is a secret stored in an ordinary field | package.json | Risk of leaking into logs and telemetry | Redact on output; restrict by scope |
The Phase 2 example for GET /api/v1/robots/{robot_id} returns "cargo": {"occupied_slots": 3, "total_slots": 5}, and the package creation example sends a recipient with name, phone and address and locations containing only latitude and longitude. A minimal profile consistent with those examples is shown below. It is this book's proposal, not WIA ROB-010 text; publish your own version with your conformance documentation so auditors can see exactly what you added.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.example.com/delivery-robot/profile/v1/common.json",
"$comment": "Implementer profile: fills definitions referenced but not defined by WIA ROB-010 v1.0.0",
"$defs": {
"Contact": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"phone": { "type": "string", "pattern": "^\\+?[0-9 ()-]{6,20}$" },
"email": { "type": "string", "format": "email" },
"address": { "type": "string" }
}
},
"Location": {
"type": "object",
"required": ["latitude", "longitude"],
"properties": {
"latitude": { "type": "number", "minimum": -90, "maximum": 90 },
"longitude": { "type": "number", "minimum": -180, "maximum": 180 },
"address": { "type": "string" },
"floor": { "type": "string" },
"handover_notes": { "type": "string", "maxLength": 500 }
}
},
"CargoStatus": {
"type": "object",
"required": ["occupied_slots", "total_slots"],
"properties": {
"occupied_slots": { "type": "integer", "minimum": 0 },
"total_slots": { "type": "integer", "minimum": 1 },
"lid_locked": { "type": "boolean" },
"compartment_temperature_celsius": { "type": "number" },
"package_ids": { "type": "array", "items": { "type": "string", "format": "uuid" } }
}
},
"ActiveRoute": {
"type": "object",
"required": ["route_id"],
"properties": {
"route_id": { "type": "string", "format": "uuid" },
"next_waypoint_index": { "type": "integer", "minimum": 0 },
"remaining_distance_meters": { "type": "number", "minimum": 0 },
"eta": { "type": "string", "format": "date-time" }
}
},
"Error": {
"type": "object",
"required": ["code", "severity", "timestamp"],
"properties": {
"code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" },
"severity": { "type": "string", "enum": ["info", "warning", "critical"] },
"component": { "type": "string" },
"message": { "type": "string" },
"timestamp": { "type": "string", "format": "date-time" }
}
}
}
}
Two design choices in this profile are worth explaining. Location keeps latitude and longitude at the top level, so the Phase 2 request bodies validate unchanged, and adds a floor string because indoor deliveries need it and altitude alone does not say which storey a door is on. CargoStatus lists package IDs but deliberately has no field for access codes, so the robot state can never carry one by accident. occupied_slots should not exceed total_slots; like the temperature range, that relation is enforced in code.
To connect the profile, either copy the definitions into the $defs of a bundled copy of each official schema (the $ref strings then resolve as printed) or keep the official files untouched and register a wrapper whose $defs entries point at the profile. Bundling is simpler for embedded validators; wrapping keeps a clean line between standard text and your additions. Either way, record the exact file hashes in your evidence package (Chapter 7).
The Phase 2 examples use date-based identifiers such as pkg-2025-12-26-0001. They are easy to read, but they violate the format: uuid constraint on package_id and route_id, and date-plus-counter schemes also leak business volume and collide when two systems generate IDs for the same day. Use UUIDs for package_id and route_id, and put anything a human needs to read out over the phone in tracking_number, which has no format restriction. Treat any response that returns a non-UUID package_id as a conformance defect, even though it appears in the specification's own example.
Each robot state carries a version that must match ^\d+\.\d+\.\d+$. Phase 1 section 6.2 applies Semantic Versioning: minor and patch releases are backward compatible, breaking changes go into a new major version, and the version is included in every message. Two details follow from the pattern. Pre-release labels such as 1.1.0-rc.1, which Semantic Versioning permits, are rejected, so test builds must still report a plain three-part number. And because the $id path contains only v1, the major version selects the schema file while minor and patch are informational to the consumer.
The non-normative Annex E adds a rule that contradicts this: it says renaming or removing fields, "even in error payloads, MUST trigger a minor version bump". Under Semantic Versioning, renaming or removing a field breaks existing consumers and requires a major version change. Because section 6.2 is the normative text and Annex E is explicitly non-normative, this book recommends treating any rename or removal as a major change. Additive changes, such as a new optional field or a new error code, stay minor, and consumers should ignore fields they do not recognize, which the absence of additionalProperties: false allows.
The example below uses the identifiers that recur throughout this book: robot 550e8400-e29b-41d4-a716-446655440000, package 7c9e6679-7425-40de-944b-e07fc1f90ae7 and route 3b241101-e2bb-4255-8caf-4136c566a962. The cargo, route and errors members follow the profile in 3.7.1; all other fields follow Phase 1 as published. The values are illustrative.
{
"robot_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2025-12-26T10:40:12.250Z",
"version": "1.0.0",
"position": {
"latitude": 37.566812,
"longitude": 126.980104,
"altitude": 38.2,
"accuracy": 0.35,
"source": "fused"
},
"heading": 62.5,
"speed": 1.4,
"battery_level": 71.5,
"mode": "autonomous",
"cargo": {
"occupied_slots": 1,
"total_slots": 2,
"lid_locked": true,
"package_ids": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"]
},
"route": {
"route_id": "3b241101-e2bb-4255-8caf-4136c566a962",
"next_waypoint_index": 2,
"remaining_distance_meters": 640
},
"sensors": {
"ultrasonic": [
{ "sensor_id": "us-front-left", "distance_cm": 182 },
{ "sensor_id": "us-front-right", "distance_cm": 240 }
],
"cameras": [
{
"sensor_id": "cam-front",
"timestamp": "2025-12-26T10:40:12.200Z",
"image_url": "https://media.example.com/frames/cam-front/000481.jpg",
"resolution": { "width": 1280, "height": 720 },
"detections": [
{
"class": "pedestrian",
"confidence": 0.91,
"bounding_box": { "x": 612, "y": 204, "width": 88, "height": 301 }
}
]
}
],
"imu": {
"acceleration": { "x": 0.12, "y": -0.03, "z": 9.79 },
"gyroscope": { "roll": 0.003, "pitch": -0.007, "yaw": 0.054 }
}
},
"errors": []
}
Notice that the camera timestamp is 50 ms earlier than the state timestamp. That is normal: the detection was computed from a frame captured slightly before the fused state was assembled. Keeping per-sensor timestamps lets a consumer align the detection with the robot pose at the time of capture, which matters when the robot is moving at 1.4 m/s. In this profile the gyroscope values are angular rates in radians per second (the REP-103 convention recommended in Chapter 2), as documented alongside the profile.
The next payload contains six deliberate errors of the kind that appear in real integrations: a vendor-style robot name instead of a UUID, a two-part version, a localization source outside the enum, a negative speed, a battery reading above 100 and a behaviour state used as a mode.
{
"robot_id": "robot-07",
"timestamp": "2025-12-26T10:30:15Z",
"version": "1.0",
"position": { "latitude": 37.5665, "longitude": 126.978, "source": "gnss" },
"heading": 45.0,
"speed": -0.2,
"battery_level": 104,
"mode": "navigating"
}
The following Python script validates a file against the robot-state schema with format assertion enabled. It uses the widely used jsonschema package; note that its date-time check needs an optional extra dependency (rfc3339-validator), and without it timestamps are not checked even when a format checker is supplied.
import json
import sys
from jsonschema import Draft202012Validator, FormatChecker
with open("robot-state.bundled.json", encoding="utf-8") as f:
schema = json.load(f)
with open(sys.argv[1], encoding="utf-8") as f:
document = json.load(f)
Draft202012Validator.check_schema(schema) # the schema itself must be valid 2020-12
validator = Draft202012Validator(schema, format_checker=FormatChecker())
errors = sorted(validator.iter_errors(document), key=lambda e: list(e.absolute_path))
for err in errors:
path = "/".join(str(p) for p in err.absolute_path) or "(root)"
print(f"{path} -> {err.message}")
print("valid" if not errors else f"{len(errors)} error(s)")
sys.exit(1 if errors else 0)
Run against the invalid payload with jsonschema 4.x, it prints:
battery_level -> 104 is greater than the maximum of 100
mode -> 'navigating' is not one of ['autonomous', 'manual', 'idle', 'charging', 'error']
position/source -> 'gnss' is not one of ['gps', 'rtk-gps', 'slam', 'visual-odometry', 'fused']
robot_id -> 'robot-07' is not a 'uuid'
speed -> -0.2 is less than the minimum of 0
version -> '1.0' does not match '^\\d+\\.\\d+\\.\\d+$'
6 error(s)
If you remove format_checker=FormatChecker() from the script, the robot_id line disappears and only five errors are reported: exactly the silent-annotation behaviour described in 3.1.2. That makes a good regression test for your pipeline: keep this payload as a negative test vector and assert that the uuid error is present.
TypeScript services can do the same with Ajv. The 2020-12 dialect is served by a separate entry point, and format checks come from the ajv-formats plug-in:
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
import robotStateSchema from "./schemas/robot-state.bundled.json";
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv); // turns "uuid", "date-time", "uri", "email" into assertions
const validateRobotState = ajv.compile(robotStateSchema);
export function assertRobotState(payload: unknown): void {
if (!validateRobotState(payload)) {
const details = (validateRobotState.errors ?? [])
.map((e) => `${e.instancePath || "(root)"} ${e.message}`)
.join("; ");
throw new Error(`RobotState rejected: ${details}`);
}
}
With allErrors: true Ajv reports every violation rather than stopping at the first, which is what you want in a conformance report. strict: true also makes Ajv refuse schemas with unknown keywords, which catches typing mistakes in your own profile files.
Validate at every trust boundary, and fail in a way that matches the boundary. On the robot, validate outgoing state in development and test builds, and in production at least sample it, because a firmware regression that corrupts one field is much cheaper to catch before it reaches the fleet. At the API gateway, validate every inbound request body and reject invalid ones with the Phase 2 error envelope, naming the offending path in details. On the telemetry ingest path, where dropping data is itself a risk, quarantine invalid messages and count them instead of discarding them. Log validation failures, as Phase 1 section 6.1 recommends, but apply the same redaction rules as for everything else, so that a rejected package body does not leak its access code into the log.
A short checklist closes the chapter:
CargoStatus, ActiveRoute, Error, Contact and Location, and resolves Position in the route schema.date-time support.package_id and route_id; keep human-readable references in tracking_number.access_code out of telemetry and logs.With conformant documents in hand, the next chapter moves them over the network: authentication, the robot, package, route and fleet endpoints of Phase 2, and the error envelope that reports what went wrong.
โ Chapter 2: Localization and Perception Fundamentals โ Turning Robot State into Numbers | Back to Index | Chapter 4: Phase 2 API Interface โ Auth, Robots, Packages, Routes and Fleet โ