Schedules
What schedules are
A schedule is a time-based automation stored against a single node: “turn the light on at 07:00 on weekdays”, “run the pump every day at sunset”. Each schedule carries one or more time triggers (clock time, day/date, or solar) and an action — a node parameter-update payload applied on the device when the schedule fires.
Schedules execute on the device itself, not in the cloud. This is the ESP RainMaker built-in Schedules feature: the firmware keeps the schedule set in NVS and runs it against its own real-time clock, so schedules keep firing even while the node is offline from the cloud. The cloud’s job is only to store the schedule set and push it down to the device whenever it changes.
Why it is needed
A durable, cloud-backed copy of each node’s schedules that survives app reinstalls and is shared across every user with access to the node.
A single API surface for clients to read/replace/clear schedules without talking MQTT directly.
A change-notification path: any edit is pushed to the device over MQTT so the on-device schedule set stays in sync with the cloud copy.
Architecture
Schedule service — the schedule-specific logic: key translation, storage, and the device push.
Generic node-service router — the Lambda that routes the REST request to the schedule service based on the URL.
Node-service contract — the shared interface and registry that schedule plugs into.
Node-details storage — the
rmng-nodestable.Device push — the MQTT push that forwards the schedule set to the device.
Schedule is one of several node services (alongside config, timeseries, trigger) registered into a shared registry at startup and dispatched by the same generic router. It is a node service, so it is scoped to a nodeId and exposes only read (GET), replace (PUT), and clear (DELETE) operations.
Key translation: schedules (API) ↔ Schedules (firmware)
The REST API is idiomatic snake_case; the device firmware wire shape is PascalCase and predates the cloud API. Rather than change the device contract, the cloud translates the top-level key at the service boundary:
schedules— the key used on the REST API.Schedules— the key the firmware reads on MQTT and in storage.On
PUT— the request’sschedulesarray is renamed toSchedulesbefore it is stored, so what lands in the DB is already in the shape the device expects.On storage / MQTT — data is kept under
Schedules, so the MQTT push forwards the device-native shape untouched.On
GET— the storedScheduleskey is renamed back toscheduleson the way out.
Only the top-level key is remapped; every other field (message, and each schedule’s id / name / triggers / action / validity, etc.) is copied through verbatim. If the stored data has no Schedules key, it is returned as-is.
Data model & storage
Schedules are stored on the node’s row in the node-details table.
Table:
rmng-nodes, partition keynode_id.Data column:
schedule— the service’s registry name. It holds the schedule payload map, e.g.{ "Schedules": [ ... ] }.Version column:
scheduleVer— the convention is<serviceName>Ver. A Unix-seconds timestamp updated on every write (see Versioning).
Writes go through the node-details storage layer:
On write, both the
schedulecolumn andscheduleVerare set in a singleUpdateItem.On delete, the
schedulecolumn isREMOVEd and a freshscheduleVerisSET, again in a single write.
There is no condition expression on these writes, so a PUT for a node_id with no existing row simply creates the row — schedule data can exist before the rest of the node record does.
Payload shape
The payload is the ESP RainMaker built-in Schedules structure — the exact shape the device parses; the cloud stores and forwards it unchanged. See the schedule payload schemas in docs/api/Api_Swagger.yaml.
{
"schedules": [
{
"id": "s1",
"name": "Morning Lights",
"enabled": true,
"triggers": [ { "m": 420, "d": 62 } ],
"action": { "Light": { "Power": true, "Brightness": 80 } },
"validity": { "start": 1704067200, "end": 1735689600 }
}
]
}
Notes:
idis the unique identifier and is mandatory for the device: it must be 16 characters or fewer, and the device rejects schedules with a missing, empty, non-string, or oversizedid. (The device stores each schedule under a fixed-length NVS key derived by hashing theid.)nameis display-only metadata; the device ignores it but the cloud stores it.A trigger’s kind is discriminated by which keys are present (
rsecone-shot;m+d/dddate-based;lat/lon+sr/sssolar) — there is no explicittypefield.actionis a node parameter-update payload. For a Matter node it mirrors the parameter-control payload ({ "0x<endpoint>": { "c": { "s": { "0x<cluster>": { "c": { "0x<command>": "<TLV hex>" } } } } } }).
The cloud does not validate the schedule contents — it stores and forwards whatever well-formed JSON is supplied. Semantic validation (id presence/length, trigger sanity) is the device’s responsibility.
Access control
Access is enforced in two layers; both must pass.
Group access (router) — before dispatching to the service, the router confirms
groupIdis in the caller’s accessible groups. If not, it returns 403. Loading the group together with its nodes walks the group’s nodes and grants the callernode:*on every node they can reach — this is subgroup-aware, so a subgroup-only (subentity) member is granted node permissions only for nodes in the subgroups they can access.Node permission (service) — each method re-checks the specific node action before touching the DB:
HTTP method
Required permission
GETnode:getPUTnode:putconfigDELETEnode:deleteconfigBecause layer 1 grants the
node:*wildcard for reachable nodes, these checks pass for any group member who can see the node.
Consequence: schedules do not distinguish primary / secondary / subentity — any user who can reach the node through the group (or an accessible subgroup) can read, replace, and delete its schedules. There is no read-only tier for schedules.
Versioning
The schedule service is versioned. Versioning uses the scheduleVer column, set to the current Unix time (seconds) on every PUT and DELETE. It is a monotonically-advancing change marker, not a sequential revision counter.
The version travels to the device with the schedule data (as a version field in the getSchedDetails payload), and the firmware can also fetch just the version via a getSchedVer request. This lets a device detect whether its locally-stored schedule set is stale relative to the cloud and re-sync if needed.
APIs
All three share one route, distinguished by HTTP method:
/v1/groups/{groupId}/nodes/{nodeId}/schedules
Routing
The route matches the generic node-service pattern /v1/groups/{groupId}/nodes/{nodeId}/{serviceName}. The URL segment is the plural schedules; the router remaps it to the singular registry name schedule (the same remap applies to triggers → trigger). Keeping the singular internal name preserves the DB column name and the shape forwarded over MQTT. The router then looks the service up in the registry and invokes the method for the HTTP verb.
Get schedules
External Flow
User opens a node’s schedule screen in the client.
Client calls the get-schedules API.
The configured schedules are displayed (or an empty state if none).
Internal Flow
API: GET /v1/groups/{groupId}/nodes/{nodeId}/schedules
Request: No request body.
Process:
Router verifies group access (403 if the caller cannot access
groupId), granting node permissions for reachable nodes.The schedule service checks
node:getonnodeId.Read the
schedulecolumn fromrmng-nodes.If absent, return an empty object
{}.Otherwise translate the stored
Scheduleskey toschedulesand return.
Response:
{
"schedules": [
{ "id": "s1", "name": "Morning Lights", "enabled": true,
"triggers": [ { "m": 420, "d": 62 } ],
"action": { "Light": { "Power": true, "Brightness": 80 } } }
]
}
When no schedules are configured: {}.
Create / update schedules
Replaces the node’s entire schedule set with the supplied payload (it is a whole-object write, not a merge) and notifies the device.
External Flow
User adds or edits schedules for a node.
Client sends the full schedule set to the create/update API.
The device is notified of the new schedule set over MQTT.
Internal Flow
API: PUT /v1/groups/{groupId}/nodes/{nodeId}/schedules
Request:
{
"schedules": [
{ "id": "s1", "name": "Morning Lights", "enabled": true,
"triggers": [ { "m": 420, "d": 62 } ],
"action": { "Light": { "Power": true, "Brightness": 80 } } }
]
}
Process:
Router verifies group access; the request body is parsed as generic JSON (invalid JSON → 400).
The schedule service checks
node:putconfigonnodeId.Translate the top-level
scheduleskey toSchedules.Write the
schedulecolumn and bumpscheduleVerin oneUpdateItem.Push the new schedule set to the device over MQTT (see MQTT push).
Response:
{ "message": "success" }
Delete schedules
Clears the node’s entire schedule set and notifies the device. There is no per-schedule delete — deletion removes the whole schedule column.
External Flow
User clears schedules for a node.
Client calls the delete-schedules API.
The device is notified that its schedule set is now empty.
Internal Flow
API: DELETE /v1/groups/{groupId}/nodes/{nodeId}/schedules
Request: No request body.
Process:
Router verifies group access.
The schedule service checks
node:deleteconfigonnodeId.REMOVEtheschedulecolumn andSETa freshscheduleVerin oneUpdateItem.Push the (now empty) schedule set to the device.
Response:
{ "message": "success" }
MQTT push to the device
Both PUT and DELETE push to the device after the DB write. The push does not send the request body — it re-reads the authoritative state from the DB and forwards that, so a DELETE naturally forwards an empty schedule set.
Push construction:
Read the
schedulecolumn andscheduleVerfor the node fromrmng-nodes.Build a
getSchedDetailspayload: the stored schedule map (using the firmwareScheduleskey) plus aversionfield carryingscheduleVer.Publish to the device’s from-cloud topic:
rainmaker/nodes/<node_id>/from_cloud.
Wire payload:
{
"event": ["getSchedDetails"],
"getSchedDetails": {
"Schedules": [ { "name": "Morning Lights", "triggers": [ { "m": 420, "d": 62 } ], "action": { "Light": { "Power": true } } } ],
"version": 1704067200
}
}
The same getSchedDetails (schedule data) and getSchedVer (version only) responses are also produced when a device proactively requests them; that inbound path is handled by the device-event input handler.
sequenceDiagram
title Update Schedules - Internal Flow
participant Client
participant Router as "Node-service router"
participant Svc as "Schedule service"
participant DB as "DynamoDB rmng-nodes"
participant Device as "Node (firmware)"
Client->>Router: PUT .../schedules {schedules:[...]}
Router->>Router: Verify group access (403 if none)<br/>grant node:* on reachable nodes
Router->>Svc: Dispatch PUT (nodeId, body)
Svc->>Svc: Check node:putconfig
Svc->>Svc: Rename "schedules" -> "Schedules"
Svc->>DB: Write schedule column + scheduleVer
DB->>Svc: OK
Svc->>DB: Read schedule + scheduleVer
Svc->>Device: Publish rainmaker/nodes/<id>/from_cloud<br/>{event:[getSchedDetails], getSchedDetails:{Schedules,version}}
Svc->>Router: OK
Router->>Client: 200 {status:"success"}
The push is best-effort in ordering only in the sense that it happens after a committed DB write; if the publish fails, the method returns an error (surfaced as 500) even though the DB already holds the new state. A device that misses a push can re-sync using the version (getSchedVer / getSchedDetails).
Code vs. swagger notes
The swagger at docs/api/Api_Swagger.yaml documents the intended contract; a few points differ from the current handler:
Error status for authorization / storage failures. Swagger lists
400,401,403,500. In practice the router returns403only for the group-access check. Any error raised inside the service — including the node-level authorization rejection (node:get/node:putconfig/node:deleteconfig) and any DB/MQTT error — is mapped to 500 by the node-service router, not to403. So a caller who can access the group but not a particular node (e.g. asubentitymember targeting a node outside their subgroups) receives500, not403.400on GET/DELETE. The handler only returns400forPUTwith an unparseable JSON body. GET and DELETE have no request body and never return400from the handler.401is enforced upstream by API Gateway (SigV4 auth), not by this Lambda.Success body. PUT and DELETE return
{ "message": "success" }— the standard API status object, whose only field ismessage.
FAQs
Do schedules run in the cloud? No. Schedules execute on the device against its own clock and persist in NVS, so they fire even when the node is offline. The cloud stores the set and pushes changes.
Is
PUTa merge or a replace? A replace. Theschedulecolumn is overwritten with the supplied payload; there is no per-schedule create/update/delete endpoint.Why
scheduleson the API butSchedulesin storage and on MQTT? The device wire shape (PascalCaseSchedules) predates the REST API. The cloud translates the top-level key at the service boundary so the API can be idiomatic snake_case without changing the device contract.Who can edit a node’s schedules? Any user with access to the group (or accessible subgroup) that contains the node. Schedules do not have a read-only access tier — the same reachability that allows GET also allows PUT and DELETE.
What is
scheduleVerfor? It is a change marker (Unix seconds) bumped on every write and sent to the device with the schedule data, so firmware can detect a stale local copy and re-sync.