diff --git a/docs/mqtt-vectors/channels-broadcast.json b/docs/mqtt-vectors/channels-broadcast.json new file mode 100644 index 00000000..c4316d54 --- /dev/null +++ b/docs/mqtt-vectors/channels-broadcast.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "**Broadcast has no snapshot subscription.**", + "features": ["mqtt"], + "steps": [ + { "send": { "mqtt": { "topic": "patternflow/snapshot", "payload": "{\"param\":[100,200,300,400]}", "retain": true } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow&role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true, "paramActive": [false, false, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/param/2", "payload": "800" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/snapshot", "payload": "{\"param\":[100,200,300,400]}", "retain": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } } + ] +} diff --git a/docs/mqtt-vectors/channels-forced.json b/docs/mqtt-vectors/channels-forced.json new file mode 100644 index 00000000..d4f28435 --- /dev/null +++ b/docs/mqtt-vectors/channels-forced.json @@ -0,0 +1,20 @@ +{ + "contract": "docs/mqtt-spec.md#transport", + "says": "Channels 1–4 force the Subscriber role.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow5&role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow1" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow2" } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow3" } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow4" } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "subscriber", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow4/pattern", "payload": "Wave Saw" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Wave Saw" } } } + ] +} diff --git a/docs/mqtt-vectors/channels-heartbeat.json b/docs/mqtt-vectors/channels-heartbeat.json new file mode 100644 index 00000000..2ed1ecbd --- /dev/null +++ b/docs/mqtt-vectors/channels-heartbeat.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "A Publisher on the channel re-sends one every 8 seconds, and the retained copy is redelivered on every reconnect.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow5&role=publisher" } }, + { "send": { "wait": 20 }, "expect": { + "status": { "mqttRole": "publisher", "mqttConnected": true, "paramActive": [false, false, false, false] }, + "out": [{ "mqtt": { "topic": "patternflow5/snapshot", "payload": "", "retain": true } }] } }, + { "send": { "wait": 7900 }, "expect": { "out": [{ "mqtt": "" }] } }, + { "send": { "wait": 200 }, "expect": { "out": [{ "mqtt": { "topic": "patternflow5/snapshot", "payload": "", "retain": true } }] } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "subscriber", "mqttConnected": true, "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/mqtt-vectors/channels-overwrite.json b/docs/mqtt-vectors/channels-overwrite.json new file mode 100644 index 00000000..4ba87021 --- /dev/null +++ b/docs/mqtt-vectors/channels-overwrite.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "So on those five channels an external controller is not the only writer: its value is overwritten a moment later by whatever the snapshot last said, with every write succeeding and nothing reporting an error.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow2&role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow2/param/1", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [900, "", "", ""], "paramActive": [true, false, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow2/snapshot", "payload": "{\"param\":[250,500,500,500]}", "retain": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [250, 500, 500, 500], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow5" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "subscriber", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow5/param/3", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [250, 500, 900, 500], "paramActive": [true, true, true, true] } } }, + { "send": { "mqtt": { "topic": "patternflow5/snapshot", "payload": "{\"param\":[250,500,640,500]}", "retain": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [250, 500, 640, 500], "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/mqtt-vectors/channels-prefix.json b/docs/mqtt-vectors/channels-prefix.json new file mode 100644 index 00000000..31a0bfac --- /dev/null +++ b/docs/mqtt-vectors/channels-prefix.json @@ -0,0 +1,20 @@ +{ + "contract": "docs/mqtt-spec.md#transport", + "says": "Prefix `patternflow` is **Broadcast**; `patternflow1` … `patternflow4` are **channels 1–4**; `patternflow5` is **Live**; anything else is **Custom**.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow3&role=subscriber" }, "expect": { "reply": { "body": { "channel": "ch3", "prefix": "patternflow3" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/param/1", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow3/param/1", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [900, "", "", ""], "paramActive": [true, false, false, false] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow" }, "expect": { "reply": { "body": { "channel": "broadcast" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow1" }, "expect": { "reply": { "body": { "channel": "ch1" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow2" }, "expect": { "reply": { "body": { "channel": "ch2" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow4" }, "expect": { "reply": { "body": { "channel": "ch4" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow5" }, "expect": { "reply": { "body": { "channel": "live" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow6" }, "expect": { "reply": { "body": { "channel": "custom" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=studio" }, "expect": { "reply": { "body": { "channel": "custom" } } } } + ] +} diff --git a/docs/mqtt-vectors/channels-snapshot.json b/docs/mqtt-vectors/channels-snapshot.json new file mode 100644 index 00000000..3171c2dd --- /dev/null +++ b/docs/mqtt-vectors/channels-snapshot.json @@ -0,0 +1,22 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "Channels 1–4 and Live subscribe to the retained `/snapshot`, whose `param:[…]` the firmware applies straight onto the knobs, exactly as if it had arrived on `param/`.", + "features": ["mqtt"], + "steps": [ + { "send": { "mqtt": { "topic": "patternflow1/snapshot", "payload": "{\"param\":[110,120,130,140]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow2/snapshot", "payload": "{\"param\":[210,220,230,240]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow3/snapshot", "payload": "{\"param\":[310,320,330,340]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow4/snapshot", "payload": "{\"param\":[410,420,430,440]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow5/snapshot", "payload": "{\"param\":[510,520,530,540]}", "retain": true } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow1&role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [110, 120, 130, 140], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow2" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [210, 220, 230, 240], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow3" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [310, 320, 330, 340], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow4" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [410, 420, 430, 440], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow5" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [510, 520, 530, 540], "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-caps.json b/docs/rest-api-vectors/mqtt-caps.json new file mode 100644 index 00000000..1fd06945 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-caps.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#conventions-for-future-additions", + "says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "caps": ["patterns", "params", "sleep", "mqtt"] } } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "status": 200, "headers": { "content-type": "application/json" }, "body": { "ok": true } } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-fields.json b/docs/rest-api-vectors/mqtt-fields.json new file mode 100644 index 00000000..5fa3568c --- /dev/null +++ b/docs/rest-api-vectors/mqtt-fields.json @@ -0,0 +1,26 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "Also reported: `normalHost`, `normalPort`, `normalUser`, `normalPrefix`, `normalHasPassword` (the saved Normal-mode broker, kept while Director mode overlays it) and `directorHost`.", + "features": ["mqtt"], + "steps": [ + { + "send": { "http": "GET /api/mqtt" }, + "expect": { + "reply": { + "status": 200, + "body": { + "ok": true, "role": "", "channel": "", "state": "", + "host": "", "port": "", "user": "", "prefix": "", + "pattern": "", "error": "", "mode": "", + "connected": "", "configured": "", "hasPassword": "", "forcesSub": "", + "knobs": ["", "", "", ""], + "params": ["", "", "", ""], + "paramActive": ["", "", "", ""], + "normalHost": "", "normalPort": "", "normalUser": "", "normalPrefix": "", + "normalHasPassword": "", "directorHost": "" + } + } + } + } + ] +} diff --git a/docs/rest-api-vectors/mqtt-forces-sub.json b/docs/rest-api-vectors/mqtt-forces-sub.json new file mode 100644 index 00000000..0820ee27 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-forces-sub.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "The selected channel (1–4) pins the role to Subscriber", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow5&role=publisher" }, "expect": { "reply": { "body": { "channel": "live", "role": "publisher", "forcesSub": false } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow3" }, "expect": { "reply": { "body": { "channel": "ch3", "role": "subscriber", "forcesSub": true } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "reply": { "body": { "channel": "ch3", "role": "subscriber", "forcesSub": true } } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "channel": "ch3", "role": "subscriber", "forcesSub": true } } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-knobs.json b/docs/rest-api-vectors/mqtt-knobs.json new file mode 100644 index 00000000..072f5e18 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-knobs.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "Absolute accumulated click counts, signed, unbounded. Live in every role.", + "features": ["mqtt"], + "steps": [ + { "send": { "hand": { "knob": 1, "turn": 3 } } }, + { "send": { "hand": { "knob": 1, "turn": 2 } } }, + { "send": { "hand": { "knob": 2, "turn": -4 } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "role": "off", "knobs": [5, -4, 0, 0] } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "hand": { "knob": 4, "turn": 30 } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "role": "publisher", "knobs": [5, -4, 0, 30] } } } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "hand": { "knob": 2, "turn": -1 } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "role": "subscriber", "knobs": [5, -5, 0, 30] } } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-password.json b/docs/rest-api-vectors/mqtt-password.json new file mode 100644 index 00000000..e0f15339 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-password.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "Passwords are never returned — only whether one is set.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "hasPassword": false, "normalHasPassword": false } } } }, + { + "send": { "http": { "request": "POST /api/mqtt", "form": "host=broker.lan&user=ha&password=hunter2" } }, + "expect": { "reply": { "status": 200, "body": { "host": "broker.lan", "user": "ha", "hasPassword": true, "normalHasPassword": true, "password": "", "pass": "" } } } + }, + { + "send": { "http": "GET /api/mqtt" }, + "expect": { "reply": { "body": { "host": "broker.lan", "user": "ha", "hasPassword": true, "normalHasPassword": true, "password": "", "pass": "" } } } + } + ] +} diff --git a/docs/rest-api-vectors/mqtt-role.json b/docs/rest-api-vectors/mqtt-role.json new file mode 100644 index 00000000..1de3b29c --- /dev/null +++ b/docs/rest-api-vectors/mqtt-role.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "`\"off\"`, `\"publisher\"` or `\"subscriber\"`.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "mqttRole": "off", "mqttState": "", "mqttConnected": false } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "role": "publisher" } }, "status": { "mqttRole": "publisher" } } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "role": "subscriber" } }, "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?role=off" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "role": "off" } }, "status": { "mqttRole": "off" } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-same-fields.json b/docs/rest-api-vectors/mqtt-same-fields.json new file mode 100644 index 00000000..9ed8dcb4 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-same-fields.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/rest-api.md#knobs-and-parameters", + "says": "`GET /api/mqtt` reports the same three fields and has since before `/api/status` did — but it lives in the MQTT addon, so a build without MQTT does not serve it.", + "features": ["mqtt"], + "steps": [ + { "send": { "hand": { "knob": 3, "turn": 2 } } }, + { "send": { "http": "POST /api/params?p2=800" } }, + { + "send": { "http": "GET /api/mqtt" }, + "expect": { "reply": { "body": { "knobs": [0, 0, 2, 0], "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } } + }, + { + "send": { "http": "GET /api/status" }, + "expect": { "reply": { "body": { "knobs": [0, 0, 2, 0], "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } } + } + ] +}