diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index d9fd03d..e939526 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -36,7 +36,7 @@ The new `ControllerChunker` reads state as the response drains. On a slow link o - `/linkToGroup` and `/unlinkFromGroup` change a group's linked shades. - `/setPaired` sets `paired`. - Shade commands, set-My, unpair, `/setRollingCode`, a shades restore and `/updateShadeConfig` already go through the web command queue to `loop()`. + Shade requests (shade and group commands, set-My, unpair, `/setPositions`, `/setRollingCode`, and every MQTT command), a shades restore and `/updateShadeConfig` already go through the LoopQueue to `loop()`. - `/controller` leaves out a room, shade, group or linked shade that doesn't fit in its chunk buffer, see [Oversized `/controller` items are left out](#oversized-controller-items-are-left-out). ## Oversized `/controller` items are left out diff --git a/docs/LOOP_QUEUE_PLAN.md b/docs/LOOP_QUEUE_PLAN.md index 253b11f..f9fcba3 100644 --- a/docs/LOOP_QUEUE_PLAN.md +++ b/docs/LOOP_QUEUE_PLAN.md @@ -1,6 +1,6 @@ # Plan: domain modules behind the LoopQueue -**Status:** planned, nothing built yet. +**Status:** steps 0 and 1 built (PRs #39 and #40); steps 2–5 planned. **Written:** 2026-10-15, against commit `e624a41` on `refactor/entry-points-outputs`. Terms are defined in [GLOSSARY.md](../GLOSSARY.md); the threading rule is [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). ## Why @@ -118,6 +118,18 @@ Each step is a PR of its own that can be tested on the device. Each one runs `pi | 4 | Change fan-out with dirty bits, replacing publishing from the `p_*` setters. | writes that skip MQTT, `position` stuck at 50 | | 5 | Split `Web.cpp` into WebUiRoutes and ApiRoutes, and `MQTT.cpp`/`SomfyMqtt.cpp` into MqttCommands, an MQTT connection and MqttPublisher. Edit parsing stays in `SomfyJson.cpp`. | the glossary names land in the code | +### Step 1: what changed in behaviour +- **Flip handling is the same for every entry point.** MQTT `tiltTarget`, `mypos`, `myTiltPos` and `tiltPosition`, and `/setPositions` (which Home Assistant calls), now turn the position round for a shade with `flipPosition`, as the other routes always did. +- **Setting the favorite over MQTT keeps the other half.** `mypos` on a shade with tilt keeps the favorite tilt; before, it set it to 0. `myTiltPos` keeps the favorite lift, or uses where the shade is when it has none. +- **`repeat` means the same everywhere.** Left out or negative is the shade's or group's own repeats; 0 is no repeats. `/shadeCommand` used to read 0 as "the shade's own". A JSON `repeat` over 127 is capped; it used to wrap round to "the shade's own". +- **Position corrections reach MQTT.** `/setPositions` and MQTT `position`/`tiltPosition` go through the setters, so they are published. +- **MQTT commands go through the LoopQueue.** While the radio is full they wait instead of being refused, and they run in order with web requests. An MQTT command topic that isn't understood is logged. +- **The LoopQueue is drained on every `loop()` pass**, with or without a network. It is no longer drained from inside a GitHub firmware download, so a restore can't reboot the device in the middle of one. + +`WebCommand.h` became `LoopQueue.h` (the item and the hold rule) and `ShadeRequests.h` (the request, the `ShadeOps` seam and the module). `SomfyShadeOps` is the device side of the seam. `entryPoints/MqttCommands` holds the topic parsing. There are 45 new native tests (271 in all). + +Flash: `esp32devdbg` 1,546,405 → 1,547,381 bytes, `esp32dev` 1,396,957 → 1,398,337 bytes. + ### Step 0 on a device NVS stays at `0x9000`, so the rolling codes survive. The file system partition shrinks from `0xE0000` to `0xD0000`, and the existing LittleFS won't mount, because `LittleFS.begin()` doesn't format. So: 1. Take a backup through `/backup`. diff --git a/platformio.ini b/platformio.ini index 20b0701..0ecef26 100644 --- a/platformio.ini +++ b/platformio.ini @@ -109,7 +109,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + + + + + + +build_src_filter = -<*> + + + + + + + + + + build_flags = -std=gnu++17 -Isrc diff --git a/src/LoopQueue.cpp b/src/LoopQueue.cpp new file mode 100644 index 0000000..36e350a --- /dev/null +++ b/src/LoopQueue.cpp @@ -0,0 +1,66 @@ +#include +#include +#include "esp_log.h" +#include "LoopQueue.h" +#include "ConfigFile.h" +#include "Somfy.h" +#include "Utils.h" + +static const char *TAG = "LoopQueue"; + +extern SomfyShadeController somfy; +extern ShadeRequests shadeRequests; +extern rebootDelay_t rebootDelay; + +static restore_options_t restoreOptions(uint8_t parts) { + restore_options_t opts; + opts.settings = parts & RESTORE_SETTINGS; + opts.shades = parts & RESTORE_SHADES; + opts.network = parts & RESTORE_NETWORK; + opts.transceiver = parts & RESTORE_TRANSCEIVER; + opts.repeaters = parts & RESTORE_REPEATERS; + opts.mqtt = parts & RESTORE_MQTT; + return opts; +} + +void LoopQueue::begin() { + if(!this->queue) this->queue = xQueueCreate(LOOP_QUEUE_SIZE, sizeof(loop_item_t)); +} +bool LoopQueue::submit(const loop_item_t &item) { + if(!this->queue) return false; + // async_tcp also services the sockets, so it must never wait on loop() or + // the radio. + if(xQueueSend(static_cast(this->queue), &item, 0) != pdTRUE) { + ESP_LOGE(TAG, "Loop queue full, dropping item"); + return false; + } + return true; +} +void LoopQueue::drain() { + if(!this->queue) return; + QueueHandle_t q = static_cast(this->queue); + loop_item_t item; + // Items run in order, so only the one at the head can be held back. + while(xQueuePeek(q, &item, 0) == pdTRUE) { + if(holdLoopItem(item, somfy.transceiver.txSuspended(), somfy.transceiver.txHasRoom())) break; + // loop() is the only consumer so this is the item we peeked. + xQueueReceive(q, &item, 0); + switch(item.type) { + case loop_item_type_t::shade_request: + if(!shadeRequests.run(item.request)) + ESP_LOGW(TAG, "Radio didn't take shade request %u", static_cast(item.request.type)); + break; + case loop_item_type_t::restore_shades: { + restore_options_t opts = restoreOptions(item.restoreParts); + ShadeConfigFile::restore(&somfy, "/shades.tmp", opts); + ESP_LOGI(TAG, "Rebooting ESP for restored settings..."); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 1000; + break; + } + case loop_item_type_t::load_shades: + somfy.loadShadesFile("/shades.tmp"); + break; + } + } +} diff --git a/src/LoopQueue.h b/src/LoopQueue.h new file mode 100644 index 0000000..d4551af --- /dev/null +++ b/src/LoopQueue.h @@ -0,0 +1,58 @@ +#ifndef LOOP_QUEUE_H +#define LOOP_QUEUE_H +#include +#include "ShadeRequests.h" + +// The one queue through which other tasks hand work to loop(), which alone +// changes the shade model (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). +// The items and the hold rule are kept free of FreeRTOS so they can be unit +// tested on the PC (pio test -e native). + +// Big enough for Home Assistant firing a command at every shade at once. +#define LOOP_QUEUE_SIZE 32 + +// What a restore_shades item restores, as bits of restoreParts. +#define RESTORE_SETTINGS 0x01 +#define RESTORE_SHADES 0x02 +#define RESTORE_NETWORK 0x04 +#define RESTORE_TRANSCEIVER 0x08 +#define RESTORE_REPEATERS 0x10 +#define RESTORE_MQTT 0x20 + +enum class loop_item_type_t : uint8_t { + shade_request, // run by ShadeRequests + restore_shades, // restore from /shades.tmp, then reboot + load_shades // replace the shades with /shades.tmp +}; +struct loop_item_t { + loop_item_type_t type = loop_item_type_t::shade_request; + shade_request_t request; // shade_request + uint8_t restoreParts = 0; // restore_shades: RESTORE_* bits +}; + +// Whether the item sends anything on air. +inline bool loopItemUsesRadio(const loop_item_t &item) { + return item.type == loop_item_type_t::shade_request && shadeRequestUsesRadio(item.request.type); +} +// Whether the item at the head of the queue waits for the radio. The client +// was already told it was accepted so it stays queued until the radio has room +// rather than have the radio drop it. While TX is suspended nothing gets +// through anyway so nothing is held back then, except an unpair: the client was +// told the shade is unpaired, so it waits until TX resumes. +inline bool holdLoopItem(const loop_item_t &item, bool txSuspended, bool txHasRoom) { + if(txSuspended) return item.type == loop_item_type_t::shade_request && item.request.type == shade_request_type_t::unpair; + return loopItemUsesRadio(item) && !txHasRoom; +} + +class LoopQueue { + public: + void begin(); + // Hands the item to loop() without waiting, so it is safe from any task, + // including async_tcp. False when the queue is full. + bool submit(const loop_item_t &item); + // Runs the waiting items in order. Only from loop(). + void drain(); + private: + void *queue = nullptr; +}; +#endif diff --git a/src/ShadeRequests.cpp b/src/ShadeRequests.cpp new file mode 100644 index 0000000..afdbc99 --- /dev/null +++ b/src/ShadeRequests.cpp @@ -0,0 +1,89 @@ +#include "ShadeRequests.h" +#include "ShadeMotion.h" + +bool shadeRequestUsesRadio(shade_request_type_t type) { + switch(type) { + case shade_request_type_t::set_positions: + case shade_request_type_t::set_rolling_code: + return false; + default: + return true; + } +} +int8_t requestRepeat(int32_t repeat) { + if(repeat < 0) return -1; + return repeat > INT8_MAX ? INT8_MAX : static_cast(repeat); +} +int8_t requestTarget(int32_t target) { return target >= 0 && target <= 100 ? static_cast(target) : -1; } + +static bool inRange(int8_t pos) { return pos >= 0 && pos <= 100; } +// Turning a client position round is the same sum as turning a stored one. +static int8_t storedPosition(int8_t pos, bool flip) { return displayPosition(static_cast(pos), flip); } +static uint8_t repeatsOr(int8_t repeat, uint8_t own) { return repeat >= 0 ? static_cast(repeat) : own; } + +bool ShadeRequests::run(const shade_request_t &req) { + shade_info_t shade; + uint8_t groupRepeats = 0; + switch(req.type) { + case shade_request_type_t::group_command: + case shade_request_type_t::group_repeat: + case shade_request_type_t::group_sensor: + if(!this->ops.findGroup(req.id, groupRepeats)) return true; + break; + default: + if(!this->ops.findShade(req.id, shade)) return true; + break; + } + const bool flip = shade.flipPosition; + switch(req.type) { + case shade_request_type_t::shade_command: + if(inRange(req.target)) return this->ops.moveToTarget(req.id, storedPosition(req.target, flip)); + return this->ops.sendCommand(req.id, req.command, repeatsOr(req.repeat, shade.repeats), req.stepSize); + case shade_request_type_t::group_command: + return this->ops.sendGroupCommand(req.id, req.command, repeatsOr(req.repeat, groupRepeats), req.stepSize); + case shade_request_type_t::tilt_command: + if(inRange(req.target)) return this->ops.moveToTiltTarget(req.id, storedPosition(req.target, flip)); + return this->ops.sendTiltCommand(req.id, req.command); + case shade_request_type_t::shade_repeat: { + // A single button garage door has no Prog, its button is Toggle. + somfy_commands cmd = req.command; + if(shade.shadeType == shade_types::garage1 && cmd == somfy_commands::Prog) cmd = somfy_commands::Toggle; + const uint8_t repeat = repeatsOr(req.repeat, shade.repeats); + if(this->ops.isLastCommand(req.id, cmd)) return this->ops.repeatFrame(req.id, repeat); + return this->ops.sendCommand(req.id, cmd, repeat, req.stepSize); + } + case shade_request_type_t::group_repeat: { + const uint8_t repeat = repeatsOr(req.repeat, groupRepeats); + if(this->ops.isLastGroupCommand(req.id, req.command)) return this->ops.repeatGroupFrame(req.id, repeat); + return this->ops.sendGroupCommand(req.id, req.command, repeat, req.stepSize); + } + case shade_request_type_t::set_positions: { + const float pos = inRange(req.position) ? storedPosition(req.position, flip) : -1.0f; + const float tilt = inRange(req.tiltPosition) && shade.tiltType != tilt_types::none ? storedPosition(req.tiltPosition, flip) : -1.0f; + if(pos >= 0.0f || tilt >= 0.0f) this->ops.setPositions(req.id, pos, tilt); + return true; + } + case shade_request_type_t::shade_sensor: + return this->ops.sendSensorCommand(req.id, req.windy, req.sunny, repeatsOr(req.repeat, shade.repeats)); + case shade_request_type_t::group_sensor: + return this->ops.sendGroupSensorCommand(req.id, req.windy, req.sunny, repeatsOr(req.repeat, groupRepeats)); + case shade_request_type_t::set_favorite: { + const int8_t tilt = requestedFavoriteTilt(req.tiltPosition, shade.tiltType, shade.myTiltPos, flip); + // Without a lift position only the tilt changes: the lift favorite stays, + // or is set where the shade is when it has none. + int8_t pos = req.position; + if(!inRange(pos)) pos = displayPosition(shade.myPos >= 0.0f ? shade.myPos : shade.currentPos, flip); + return this->ops.setMyPosition(req.id, storedPosition(pos, flip), storedPosition(tilt, flip)); + } + case shade_request_type_t::unpair: { + // A 56-bit motor needs a longer Prog press than an 80-bit one. + const bool sent = this->ops.sendCommand(req.id, somfy_commands::Prog, shade.bitLength == 56 ? 7 : 1, 0); + if(sent) this->ops.setUnpaired(req.id); + return sent; + } + case shade_request_type_t::set_rolling_code: + this->ops.setRollingCode(req.id, req.rollingCode); + return true; + } + return true; +} diff --git a/src/ShadeRequests.h b/src/ShadeRequests.h new file mode 100644 index 0000000..c1cce6b --- /dev/null +++ b/src/ShadeRequests.h @@ -0,0 +1,101 @@ +#ifndef SHADE_REQUESTS_H +#define SHADE_REQUESTS_H +#include +#include "SomfyFrame.h" +#include "ShadeTypes.h" + +// Shade requests from the entry points (HTTP and MQTT) and the domain module +// that carries them out on loop(). Kept free of the shade model so it can be +// unit tested on the PC (pio test -e native). + +enum class shade_request_type_t : uint8_t { + shade_command, // move the shade to target, or send command + group_command, // send command to the group + tilt_command, // move the tilt to target, or send command to the tilt + shade_repeat, // send command, or repeat it when it was the last one sent + group_repeat, // the same for a group + set_positions, // correct where the shade is thought to be; sends nothing + shade_sensor, // send a sensor frame as the shade + group_sensor, // send a sensor frame as the group + set_favorite, // set or clear the shade's favorite + unpair, // send Prog and clear paired + set_rolling_code // set the shade's rolling code; sends nothing +}; + +// Positions are as the client sees them: 0-100, counted from the other end on +// a shade with flipPosition, and -1 when not given. +struct shade_request_t { + shade_request_type_t type = shade_request_type_t::shade_command; + uint8_t id = 255; // shade id, or group id for group_* + int8_t target = -1; // shade/tilt_command: -1 sends command instead + somfy_commands command = somfy_commands::My; + int8_t repeat = -1; // -1 for the shade's or group's own repeats + uint8_t stepSize = 0; + int8_t position = -1; // set_positions, set_favorite + int8_t tiltPosition = -1; // set_positions, set_favorite + int8_t sunny = -1; // *_sensor: -1 leaves it as it is + int8_t windy = -1; + uint16_t rollingCode = 0; // set_rolling_code +}; + +// Whether the request sends anything on air. +bool shadeRequestUsesRadio(shade_request_type_t type); +// A repeat count from a client: negative for the default, capped at what fits. +int8_t requestRepeat(int32_t repeat); +// A target from a client: -1 unless it is 0-100. +int8_t requestTarget(int32_t target); + +// What ShadeRequests needs to know about a shade. Positions are stored ones. +struct shade_info_t { + bool flipPosition = false; + shade_types shadeType = shade_types::roller; + tilt_types tiltType = tilt_types::none; + uint8_t repeats = 1; + uint8_t bitLength = 56; + float currentPos = 0.0f; + float myPos = -1.0f; + float myTiltPos = -1.0f; +}; + +// The seam between ShadeRequests and the shades and groups it acts on. On the +// device it is the shade model (SomfyShadeOps); the tests record the calls. +// Positions are stored ones, so already turned round for a flipped shade. +class ShadeOps { + public: + virtual ~ShadeOps() {} + virtual bool findShade(uint8_t shadeId, shade_info_t &info) = 0; + // repeats is the group's own repeat count. + virtual bool findGroup(uint8_t groupId, uint8_t &repeats) = 0; + // The bool ones return false when the radio refused a command they sent. + virtual bool moveToTarget(uint8_t shadeId, float pos) = 0; + virtual bool moveToTiltTarget(uint8_t shadeId, float tilt) = 0; + virtual bool sendCommand(uint8_t shadeId, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) = 0; + virtual bool sendTiltCommand(uint8_t shadeId, somfy_commands cmd) = 0; + virtual bool isLastCommand(uint8_t shadeId, somfy_commands cmd) = 0; + virtual bool repeatFrame(uint8_t shadeId, uint8_t repeat) = 0; + virtual bool sendSensorCommand(uint8_t shadeId, int8_t windy, int8_t sunny, uint8_t repeat) = 0; + virtual bool setMyPosition(uint8_t shadeId, int8_t pos, int8_t tilt) = 0; + // A negative position leaves that one as it is. + virtual void setPositions(uint8_t shadeId, float pos, float tilt) = 0; + virtual void setUnpaired(uint8_t shadeId) = 0; + virtual void setRollingCode(uint8_t shadeId, uint16_t code) = 0; + virtual bool sendGroupCommand(uint8_t groupId, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) = 0; + virtual bool isLastGroupCommand(uint8_t groupId, somfy_commands cmd) = 0; + virtual bool repeatGroupFrame(uint8_t groupId, uint8_t repeat) = 0; + virtual bool sendGroupSensorCommand(uint8_t groupId, int8_t windy, int8_t sunny, uint8_t repeat) = 0; +}; + +// Carries out shade requests, whichever entry point they came from: finds +// the shade or group, turns client positions into stored ones, checks +// ranges and fills in default repeats. Runs on loop() only. +class ShadeRequests { + public: + explicit ShadeRequests(ShadeOps &ops) : ops(ops) {} + // False when the radio refused a command it sent. A request for a shade + // or group that doesn't exist, or with nothing in range to do, is dropped + // and returns true. + bool run(const shade_request_t &req); + private: + ShadeOps &ops; +}; +#endif diff --git a/src/SomfyController.ino b/src/SomfyController.ino index b0e2941..c1a5653 100644 --- a/src/SomfyController.ino +++ b/src/SomfyController.ino @@ -9,6 +9,9 @@ #include "Utils.h" #include "Somfy.h" #include "entryPoints/MQTT.h" +#include "LoopQueue.h" +#include "ShadeRequests.h" +#include "SomfyShadeOps.h" #include "GitOTA.h" #include "esp_core_dump.h" #include "esp_system.h" @@ -33,6 +36,9 @@ rebootDelay_t rebootDelay; SomfyShadeController somfy; MQTTClass mqtt; GitUpdater git; +SomfyShadeOps shadeOps; +ShadeRequests shadeRequests(shadeOps); +LoopQueue loopQueue; uint32_t oldheap = 0; @@ -112,6 +118,8 @@ void setup() { settings.begin(); if(WiFi.status() == WL_CONNECTED) WiFi.disconnect(true); delay(10); + // Before anything that can submit to it: the web servers and MQTT. + loopQueue.begin(); webServer.startup(); webServer.begin(); delay(1000); @@ -145,6 +153,9 @@ void loop() { somfy.loop(); logIfSlow(TAG, "Loop somfy", timing.lap(), LOOP_SLOW_MS); esp_task_wdt_reset(); + loopQueue.drain(); + logIfSlow(TAG, "Loop queue", timing.lap(), LOOP_SLOW_MS); + esp_task_wdt_reset(); if(net.connected() || net.softAPOpened) { if(!rebootDelay.reboot && net.connected() && !net.softAPOpened) { git.loop(); diff --git a/src/SomfyShadeOps.cpp b/src/SomfyShadeOps.cpp new file mode 100644 index 0000000..89eae0c --- /dev/null +++ b/src/SomfyShadeOps.cpp @@ -0,0 +1,70 @@ +#include "SomfyShadeOps.h" +#include "Somfy.h" + +extern SomfyShadeController somfy; + +// ShadeRequests only calls these for a shade or group it has just found. +bool SomfyShadeOps::findShade(uint8_t shadeId, shade_info_t &info) { + SomfyShade *shade = somfy.getShadeById(shadeId); + if(!shade) return false; + info.flipPosition = shade->flipPosition; + info.shadeType = shade->shadeType; + info.tiltType = shade->tiltType; + info.repeats = shade->repeats; + info.bitLength = shade->bitLength; + info.currentPos = shade->currentPos; + info.myPos = shade->myPos; + info.myTiltPos = shade->myTiltPos; + return true; +} +bool SomfyShadeOps::findGroup(uint8_t groupId, uint8_t &repeats) { + SomfyGroup *group = somfy.getGroupById(groupId); + if(!group) return false; + repeats = group->repeats; + return true; +} +bool SomfyShadeOps::moveToTarget(uint8_t shadeId, float pos) { return somfy.getShadeById(shadeId)->moveToTarget(pos); } +bool SomfyShadeOps::moveToTiltTarget(uint8_t shadeId, float tilt) { return somfy.getShadeById(shadeId)->moveToTiltTarget(tilt); } +bool SomfyShadeOps::sendCommand(uint8_t shadeId, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { + return somfy.getShadeById(shadeId)->sendCommand(cmd, repeat, stepSize); +} +bool SomfyShadeOps::sendTiltCommand(uint8_t shadeId, somfy_commands cmd) { return somfy.getShadeById(shadeId)->sendTiltCommand(cmd); } +bool SomfyShadeOps::isLastCommand(uint8_t shadeId, somfy_commands cmd) { return somfy.getShadeById(shadeId)->isLastCommand(cmd); } +bool SomfyShadeOps::repeatFrame(uint8_t shadeId, uint8_t repeat) { return somfy.getShadeById(shadeId)->repeatFrame(repeat); } +bool SomfyShadeOps::sendSensorCommand(uint8_t shadeId, int8_t windy, int8_t sunny, uint8_t repeat) { + SomfyShade *shade = somfy.getShadeById(shadeId); + const bool sent = shade->sendSensorCommand(windy, sunny, repeat); + shade->emitState(); + return sent; +} +bool SomfyShadeOps::setMyPosition(uint8_t shadeId, int8_t pos, int8_t tilt) { return somfy.getShadeById(shadeId)->setMyPosition(pos, tilt); } +void SomfyShadeOps::setPositions(uint8_t shadeId, float pos, float tilt) { + SomfyShade *shade = somfy.getShadeById(shadeId); + // Through the setters, so MQTT hears about it too. + if(pos >= 0.0f) { + shade->p_target(pos); + shade->p_currentPos(pos); + } + if(tilt >= 0.0f) { + shade->p_tiltTarget(tilt); + shade->p_currentTiltPos(tilt); + } + shade->emitState(); +} +void SomfyShadeOps::setUnpaired(uint8_t shadeId) { + SomfyShade *shade = somfy.getShadeById(shadeId); + shade->paired = false; + shade->save(); +} +void SomfyShadeOps::setRollingCode(uint8_t shadeId, uint16_t code) { somfy.getShadeById(shadeId)->setRollingCode(code); } +bool SomfyShadeOps::sendGroupCommand(uint8_t groupId, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { + return somfy.getGroupById(groupId)->sendCommand(cmd, repeat, stepSize); +} +bool SomfyShadeOps::isLastGroupCommand(uint8_t groupId, somfy_commands cmd) { return somfy.getGroupById(groupId)->isLastCommand(cmd); } +bool SomfyShadeOps::repeatGroupFrame(uint8_t groupId, uint8_t repeat) { return somfy.getGroupById(groupId)->repeatFrame(repeat); } +bool SomfyShadeOps::sendGroupSensorCommand(uint8_t groupId, int8_t windy, int8_t sunny, uint8_t repeat) { + SomfyGroup *group = somfy.getGroupById(groupId); + const bool sent = group->sendSensorCommand(windy, sunny, repeat); + group->emitState(); + return sent; +} diff --git a/src/SomfyShadeOps.h b/src/SomfyShadeOps.h new file mode 100644 index 0000000..3eb03cc --- /dev/null +++ b/src/SomfyShadeOps.h @@ -0,0 +1,27 @@ +#ifndef SOMFY_SHADE_OPS_H +#define SOMFY_SHADE_OPS_H +#include "ShadeRequests.h" + +// ShadeOps on the device: forwards to the shades and groups of the shade +// model. Runs on loop() only. +class SomfyShadeOps : public ShadeOps { + public: + bool findShade(uint8_t shadeId, shade_info_t &info) override; + bool findGroup(uint8_t groupId, uint8_t &repeats) override; + bool moveToTarget(uint8_t shadeId, float pos) override; + bool moveToTiltTarget(uint8_t shadeId, float tilt) override; + bool sendCommand(uint8_t shadeId, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) override; + bool sendTiltCommand(uint8_t shadeId, somfy_commands cmd) override; + bool isLastCommand(uint8_t shadeId, somfy_commands cmd) override; + bool repeatFrame(uint8_t shadeId, uint8_t repeat) override; + bool sendSensorCommand(uint8_t shadeId, int8_t windy, int8_t sunny, uint8_t repeat) override; + bool setMyPosition(uint8_t shadeId, int8_t pos, int8_t tilt) override; + void setPositions(uint8_t shadeId, float pos, float tilt) override; + void setUnpaired(uint8_t shadeId) override; + void setRollingCode(uint8_t shadeId, uint16_t code) override; + bool sendGroupCommand(uint8_t groupId, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) override; + bool isLastGroupCommand(uint8_t groupId, somfy_commands cmd) override; + bool repeatGroupFrame(uint8_t groupId, uint8_t repeat) override; + bool sendGroupSensorCommand(uint8_t groupId, int8_t windy, int8_t sunny, uint8_t repeat) override; +}; +#endif diff --git a/src/WebCommand.h b/src/WebCommand.h deleted file mode 100644 index 43187aa..0000000 --- a/src/WebCommand.h +++ /dev/null @@ -1,75 +0,0 @@ -#ifndef WEB_COMMAND_H -#define WEB_COMMAND_H -#include -#include "SomfyFrame.h" - -// Commands the web servers hand to the main loop (src/entryPoints/Web.cpp). Kept free of -// the web server and FreeRTOS so it can be unit tested on the PC -// (pio test -e native). - -// Commands are queued by the async web server and drained by the main loop. -// Big enough for Home Assistant firing a command at every shade at once. -#define WEB_CMD_QUEUE_SIZE 32 - -enum class web_cmd_t : uint8_t { - shade_command, // moveToTarget or sendCommand - group_command, // group sendCommand - tilt_command, // moveToTiltTarget or sendTiltCommand - shade_repeat, // shade sendCommand/repeatFrame - group_repeat, // group sendCommand/repeatFrame - set_positions, // set shade position directly - shade_sensor, // shade sensor command - group_sensor, // group sensor command - set_my_position, // shade setMyPosition (position/tiltPosition) - unpair_shade, // shade unpair (Prog) and clear paired flag - set_rolling_code, // set a shade's rolling code - restore_shades, // restore from /shades.tmp, then reboot - load_shades, // replace the shades with /shades.tmp -}; - -// What a restore_shades command restores, as bits of restoreParts. -#define RESTORE_SETTINGS 0x01 -#define RESTORE_SHADES 0x02 -#define RESTORE_NETWORK 0x04 -#define RESTORE_TRANSCEIVER 0x08 -#define RESTORE_REPEATERS 0x10 -#define RESTORE_MQTT 0x20 - -struct web_command_t { - web_cmd_t type; - uint8_t shadeId; - uint8_t groupId; - uint8_t target; // 0-100 or 255 (none) - somfy_commands command; - int8_t repeat; - uint8_t stepSize; - int8_t position; // for setPositions - int8_t tiltPosition; // for setPositions/tilt - int8_t sunny; // for sensor - int8_t windy; // for sensor - uint16_t rollingCode; // for set_rolling_code - uint8_t restoreParts; // for restore_shades: RESTORE_* bits -}; - -// Whether the command sends anything on air. -inline bool webCommandUsesRadio(web_cmd_t type) { - switch(type) { - case web_cmd_t::set_positions: - case web_cmd_t::set_rolling_code: - case web_cmd_t::restore_shades: - case web_cmd_t::load_shades: - return false; - default: - return true; - } -} -// Whether the command at the head of the queue waits for the radio. The -// client was already told it was accepted so it stays queued until the radio -// has room rather than have the radio drop it. While TX is suspended nothing -// gets through anyway so nothing is held back then, except an unpair: the -// client was told the shade is unpaired, so it waits until TX resumes. -inline bool holdWebCommand(web_cmd_t type, bool txSuspended, bool txHasRoom) { - if(txSuspended) return type == web_cmd_t::unpair_shade; - return webCommandUsesRadio(type) && !txHasRoom; -} -#endif diff --git a/src/entryPoints/MQTT.cpp b/src/entryPoints/MQTT.cpp index dba7aaf..6cbaf79 100644 --- a/src/entryPoints/MQTT.cpp +++ b/src/entryPoints/MQTT.cpp @@ -5,7 +5,9 @@ #include "esp_log.h" #include "ConfigSettings.h" #include "MQTT.h" +#include "MqttCommands.h" #include "Somfy.h" +#include "LoopQueue.h" #include "ESPNetwork.h" #include "Utils.h" @@ -21,6 +23,7 @@ extern ConfigSettings settings; extern SomfyShadeController somfy; extern ESPNetwork net; extern rebootDelay_t rebootDelay; +extern LoopQueue loopQueue; bool MQTTClass::begin() { @@ -49,131 +52,14 @@ bool MQTTClass::loop() { void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) { esp_task_wdt_reset(); // Make sure we do not reboot here. ESP_LOGD(TAG, "MQTT Topic:%s payload:%.*s", topic, (int)length, (const char *)payload); - - // We need to start at the last slash in the data - uint8_t len = strlen(topic); - - uint8_t slashes = 0; - uint16_t ndx = strlen(topic) - 1; - while(ndx > 0) { - if(topic[ndx] == '/') slashes++; - if(slashes == 4) break; - ndx--; + // This already runs on loop(), but going through the loop queue keeps MQTT + // and web requests in order and holds them while the radio is full. + loop_item_t item; + if(!parseMqttCommand(topic, payload, length, item.request)) { + ESP_LOGW(TAG, "MQTT command not understood: %s", topic); + return; } - char entityId[4]; - char command[32]; - char entityType[7]; - char value[10]; - memset(command, 0x00, sizeof(command)); - memset(entityId, 0x00, sizeof(entityId)); - memset(entityType, 0x00, sizeof(entityType)); - memset(value, 0x00, sizeof(value)); - uint8_t i = 0; - while(topic[ndx] == '/' && ndx < len) ndx++; - while(ndx < len) { - if(topic[ndx] != '/' && i < sizeof(entityType)) - entityType[i++] = topic[ndx]; - ndx++; - if(topic[ndx] == '/') break; - } - i = 0; - while(topic[ndx] == '/' && ndx < len) ndx++; - while(ndx < len) { - if(topic[ndx] != '/' && i < sizeof(entityId)) - entityId[i++] = topic[ndx]; - ndx++; - if(topic[ndx] == '/') break; - } - i = 0; - while(topic[ndx] == '/' && ndx < len) ndx++; - while(ndx < len) { - if(topic[ndx] != '/' && i < sizeof(command)) - command[i++] = topic[ndx]; - ndx++; - if(topic[ndx] == '/') break; - } - for(uint8_t j = 0; j < length && j < sizeof(value); j++) - value[j] = payload[j]; - - ESP_LOGD(TAG, "MQTT type:[%s] command:[%s] entityId:%s value:%s", entityType, command, entityId, value); - if(strncmp(entityType, "shades", sizeof(entityType)) == 0) { - SomfyShade* shade = somfy.getShadeById(atoi(entityId)); - if (shade) { - int val = atoi(value); - if(strncmp(command, "target", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) { - ESP_LOGI(TAG, "MQTT shade %s target=%d", entityId, val); - shade->moveToTarget(shade->transformPosition(atoi(value))); - } - } - if(strncmp(command, "tiltTarget", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) - shade->moveToTiltTarget(atoi(value)); - } - else if(strncmp(command, "direction", sizeof(command)) == 0) { - if(val < 0) - shade->sendCommand(somfy_commands::Up); - else if(val > 0) - shade->sendCommand(somfy_commands::Down); - else - shade->sendCommand(somfy_commands::My); - } - else if(strncmp(command, "mypos", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) - shade->setMyPosition(val); - } - else if(strncmp(command, "myTiltPos", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) - shade->setMyPosition(shade->myPos, val); - } - else if(strncmp(command, "sunFlag", sizeof(command)) == 0) { - shade->sendCommand(sunFlagCommand(val > 0)); - } - else if(strncmp(command, "position", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) { - shade->target = shade->currentPos = shade->transformPosition((float)val); - shade->emitState(); - } - } - else if(strncmp(command, "tiltPosition", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) { - shade->tiltTarget = shade->currentTiltPos = (float)val; - shade->emitState(); - } - } - else if(strncmp(command, "sunny", sizeof(command)) == 0) { - if(val >= 0) shade->sendSensorCommand(-1, val, shade->repeats); - } - else if(strncmp(command, "windy", sizeof(command)) == 0) { - if(val >= 0) shade->sendSensorCommand(val, -1, shade->repeats); - } - } - } - else if(strncmp(entityType, "groups", sizeof(entityType)) == 0) { - SomfyGroup* group = somfy.getGroupById(atoi(entityId)); - if (group) { - int val = atoi(value); - ESP_LOGI(TAG, "MQTT group %s command=%s value=%d", entityId, command, val); - if(strncmp(command, "direction", sizeof(command)) == 0) { - if(val < 0) - group->sendCommand(somfy_commands::Up); - else if(val > 0) - group->sendCommand(somfy_commands::Down); - else - group->sendCommand(somfy_commands::My); - } - else if(strncmp(command, "sunFlag", sizeof(command)) == 0) { - group->sendCommand(sunFlagCommand(val > 0)); - } - else if(strncmp(command, "sunny", sizeof(command)) == 0) { - if(val >= 0) group->sendSensorCommand(-1, val, group->repeats); - } - else if(strncmp(command, "windy", sizeof(command)) == 0) { - if(val >= 0) group->sendSensorCommand(val, -1, group->repeats); - } - } - } - esp_task_wdt_reset(); // Make sure we do not reboot here. + loopQueue.submit(item); } bool MQTTClass::connect() { esp_task_wdt_reset(); // Make sure we do not reboot here. diff --git a/src/entryPoints/MqttCommands.cpp b/src/entryPoints/MqttCommands.cpp new file mode 100644 index 0000000..89d0a1b --- /dev/null +++ b/src/entryPoints/MqttCommands.cpp @@ -0,0 +1,126 @@ +#include +#include +#include "MqttCommands.h" + +namespace { +struct segment_t { + const char *s = ""; + size_t len = 0; + bool is(const char *word) const { return this->len == strlen(word) && strncmp(this->s, word, this->len) == 0; } +}; +bool inRange(int val) { return val >= 0 && val <= 100; } +somfy_commands directionCommand(int val) { + if(val < 0) return somfy_commands::Up; + if(val > 0) return somfy_commands::Down; + return somfy_commands::My; +} +} + +bool parseMqttCommand(const char *topic, const uint8_t *payload, size_t length, shade_request_t &req) { + // Only the last four segments count, so the root topic may hold slashes or + // be empty: type, id, command and "set". + segment_t seg[4]; + size_t end = strlen(topic); + for(int i = 3; i >= 0; i--) { + size_t start = end; + while(start > 0 && topic[start - 1] != '/') start--; + seg[i].s = topic + start; + seg[i].len = end - start; + if(i > 0) { + if(start == 0) return false; + end = start - 1; + } + } + if(!seg[3].is("set")) return false; + if(seg[1].len == 0 || seg[1].len > 3) return false; + int id = 0; + for(size_t i = 0; i < seg[1].len; i++) { + if(seg[1].s[i] < '0' || seg[1].s[i] > '9') return false; + id = id * 10 + (seg[1].s[i] - '0'); + } + if(id > 255) return false; + char value[16]; + const size_t n = length < sizeof(value) - 1 ? length : sizeof(value) - 1; + memcpy(value, payload, n); + value[n] = '\0'; + const int val = atoi(value); + const segment_t &cmd = seg[2]; + + shade_request_t r; + r.id = static_cast(id); + if(seg[0].is("shades")) { + if(cmd.is("target")) { + if(!inRange(val)) return false; + r.type = shade_request_type_t::shade_command; + r.target = static_cast(val); + } + else if(cmd.is("tiltTarget")) { + if(!inRange(val)) return false; + r.type = shade_request_type_t::tilt_command; + r.target = static_cast(val); + } + else if(cmd.is("direction")) { + r.type = shade_request_type_t::shade_command; + r.command = directionCommand(val); + } + else if(cmd.is("mypos")) { + if(!inRange(val)) return false; + r.type = shade_request_type_t::set_favorite; + r.position = static_cast(val); + } + else if(cmd.is("myTiltPos")) { + if(!inRange(val)) return false; + r.type = shade_request_type_t::set_favorite; + r.tiltPosition = static_cast(val); + } + else if(cmd.is("sunFlag")) { + r.type = shade_request_type_t::shade_command; + r.command = sunFlagCommand(val > 0); + } + else if(cmd.is("sunny")) { + if(val < 0) return false; + r.type = shade_request_type_t::shade_sensor; + r.sunny = val > 0 ? 1 : 0; + } + else if(cmd.is("windy")) { + if(val < 0) return false; + r.type = shade_request_type_t::shade_sensor; + r.windy = val > 0 ? 1 : 0; + } + else if(cmd.is("position")) { + if(!inRange(val)) return false; + r.type = shade_request_type_t::set_positions; + r.position = static_cast(val); + } + else if(cmd.is("tiltPosition")) { + if(!inRange(val)) return false; + r.type = shade_request_type_t::set_positions; + r.tiltPosition = static_cast(val); + } + else return false; + } + else if(seg[0].is("groups")) { + if(cmd.is("direction")) { + r.type = shade_request_type_t::group_command; + r.command = directionCommand(val); + } + else if(cmd.is("sunFlag")) { + r.type = shade_request_type_t::group_command; + r.command = sunFlagCommand(val > 0); + } + else if(cmd.is("sunny")) { + if(val < 0) return false; + r.type = shade_request_type_t::group_sensor; + r.sunny = val > 0 ? 1 : 0; + } + else if(cmd.is("windy")) { + if(val < 0) return false; + r.type = shade_request_type_t::group_sensor; + r.windy = val > 0 ? 1 : 0; + } + else return false; + } + else return false; + req = r; + return true; +} diff --git a/src/entryPoints/MqttCommands.h b/src/entryPoints/MqttCommands.h new file mode 100644 index 0000000..1d1df1b --- /dev/null +++ b/src/entryPoints/MqttCommands.h @@ -0,0 +1,14 @@ +#ifndef MQTT_COMMANDS_H +#define MQTT_COMMANDS_H +#include +#include +#include "ShadeRequests.h" + +// The MQTT command topics, /shades///set and +// /groups///set. Kept free of the MQTT client so it can be +// unit tested on the PC (pio test -e native). + +// Turns a message on a command topic into a shade request. False for a topic +// or value it doesn't take, which is dropped. +bool parseMqttCommand(const char *topic, const uint8_t *payload, size_t length, shade_request_t &req); +#endif diff --git a/src/entryPoints/Web.cpp b/src/entryPoints/Web.cpp index f4d4025..d5c60b8 100644 --- a/src/entryPoints/Web.cpp +++ b/src/entryPoints/Web.cpp @@ -10,6 +10,7 @@ #include "SSDP.h" #include "Somfy.h" #include "WResp.h" +#include "LoopQueue.h" #include "Web.h" #include "MQTT.h" #include "GitOTA.h" @@ -28,6 +29,7 @@ extern Web webServer; extern MQTTClass mqtt; extern GitUpdater git; extern ESPNetwork net; +extern LoopQueue loopQueue; //#define WEB_MAX_RESPONSE 34768 #define WEB_MAX_RESPONSE 4096 @@ -47,61 +49,53 @@ static const char _encoding_json[] = "application/json"; static const char *TAG = "Web"; -static QueueHandle_t webCmdQueue = nullptr; - static void sendQueueFull(AsyncWebServerRequest *request); static void sendRadioUnavailable(AsyncWebServerRequest *request); -// Hands a command to the main loop, which owns the shades. When it can't be -// the request is answered with the reason and false is returned. -static bool queueLoopCommand(AsyncWebServerRequest *request, const web_command_t &cmd) { +// Hands an item to loop(), which owns the shades. When it can't be the +// request is answered with the reason and false is returned. +static bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) { // Nothing can go on air during a frequency scan so tell the client now rather - // than dropping the command once it reaches the radio. - if(webCommandUsesRadio(cmd.type) && somfy.transceiver.txSuspended()) { sendRadioUnavailable(request); return false; } - if(!webServer.queueCommand(cmd)) { sendQueueFull(request); return false; } + // than dropping the request once it reaches the radio. + if(loopItemUsesRadio(item) && somfy.transceiver.txSuspended()) { sendRadioUnavailable(request); return false; } + if(!loopQueue.submit(item)) { sendQueueFull(request); return false; } return true; } -// Radio work for these handlers runs on the main loop via the command queue. +static bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req) { + loop_item_t item; + item.request = req; + return queueLoopItem(request, item); +} static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) { - web_command_t cmd = {}; - cmd.type = web_cmd_t::set_my_position; - cmd.shadeId = shade->getShadeId(); - cmd.position = pos; - cmd.tiltPosition = tilt; - return queueLoopCommand(request, cmd); + shade_request_t req; + req.type = shade_request_type_t::set_favorite; + req.id = shade->getShadeId(); + req.position = pos; + req.tiltPosition = tilt; + return queueShadeRequest(request, req); } -// The main loop clears paired and saves it once the Prog is queued. The -// shade belongs to the main loop so it isn't changed here. +// loop() clears paired and saves it once the Prog is queued. The shade +// belongs to loop() so it isn't changed here. static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) { - web_command_t cmd = {}; - cmd.type = web_cmd_t::unpair_shade; - cmd.shadeId = shade->getShadeId(); - return queueLoopCommand(request, cmd); + shade_request_t req; + req.type = shade_request_type_t::unpair; + req.id = shade->getShadeId(); + return queueShadeRequest(request, req); } -// Sending a command reads and commits the rolling code on the main loop, so -// a new code is set there too. +// Sending a command reads and commits the rolling code on loop(), so a new +// code is set there too. static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) { - web_command_t cmd = {}; - cmd.type = web_cmd_t::set_rolling_code; - cmd.shadeId = shade->getShadeId(); - cmd.rollingCode = rollingCode; - return queueLoopCommand(request, cmd); + shade_request_t req; + req.type = shade_request_type_t::set_rolling_code; + req.id = shade->getShadeId(); + req.rollingCode = rollingCode; + return queueShadeRequest(request, req); } -// A restore's options travel through the command queue as RESTORE_* bits. +// A restore's options travel through the loop queue as RESTORE_* bits. static uint8_t restoreParts(const restore_options_t &opts) { return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) | (opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) | (opts.repeaters ? RESTORE_REPEATERS : 0) | (opts.mqtt ? RESTORE_MQTT : 0); } -static restore_options_t restoreOptions(uint8_t parts) { - restore_options_t opts; - opts.settings = parts & RESTORE_SETTINGS; - opts.shades = parts & RESTORE_SHADES; - opts.network = parts & RESTORE_NETWORK; - opts.transceiver = parts & RESTORE_TRANSCEIVER; - opts.repeaters = parts & RESTORE_REPEATERS; - opts.mqtt = parts & RESTORE_MQTT; - return opts; -} // The radio and MQTT are shut down once an upload starts and only a reboot // brings them back. The completion handler schedules it but never runs if the // client goes away mid-upload, so make sure the device reboots then too. @@ -125,7 +119,6 @@ AsyncWebServer asyncServer(80); AsyncWebServer asyncApiServer(8081); void Web::startup() { ESP_LOGI(TAG, "Launching web server..."); - if(!webCmdQueue) webCmdQueue = xQueueCreate(WEB_CMD_QUEUE_SIZE, sizeof(web_command_t)); asyncServer.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) { AsyncJsonResponse *response = new AsyncJsonResponse(); @@ -143,128 +136,8 @@ void Web::startup() { ESP_LOGI(TAG, "Async API server started on port 8081"); } void Web::loop() { - this->processQueue(); delay(1); } -bool Web::queueCommand(const web_command_t &cmd) { - if(!webCmdQueue) return false; - // This runs on the async_tcp task which also services the sockets so it must - // never wait on the main loop or the radio. - if(xQueueSend(webCmdQueue, &cmd, 0) != pdTRUE) { - ESP_LOGE(TAG, "Command queue full, dropping command"); - return false; - } - return true; -} -void Web::processQueue() { - if(!webCmdQueue) return; - web_command_t cmd; - // Commands run in order, so only the one at the head can be held back. - while(xQueuePeek(webCmdQueue, &cmd, 0) == pdTRUE) { - if(holdWebCommand(cmd.type, somfy.transceiver.txSuspended(), somfy.transceiver.txHasRoom())) break; - // loop() is the only consumer so this is the command we peeked. - xQueueReceive(webCmdQueue, &cmd, 0); - bool sent = true; - switch(cmd.type) { - case web_cmd_t::shade_command: { - SomfyShade *shade = somfy.getShadeById(cmd.shadeId); - if(shade) { - if(cmd.target <= 100) sent = shade->moveToTarget(shade->transformPosition(cmd.target)); - else sent = shade->sendCommand(cmd.command, cmd.repeat > 0 ? cmd.repeat : shade->repeats, cmd.stepSize); - } - break; - } - case web_cmd_t::group_command: { - SomfyGroup *group = somfy.getGroupById(cmd.groupId); - if(group) sent = group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize); - break; - } - case web_cmd_t::tilt_command: { - SomfyShade *shade = somfy.getShadeById(cmd.shadeId); - if(shade) { - if(cmd.target <= 100) sent = shade->moveToTiltTarget(shade->transformPosition(cmd.target)); - else sent = shade->sendTiltCommand(cmd.command); - } - break; - } - case web_cmd_t::shade_repeat: { - SomfyShade *shade = somfy.getShadeById(cmd.shadeId); - if(shade) { - if(shade->shadeType == shade_types::garage1 && cmd.command == somfy_commands::Prog) cmd.command = somfy_commands::Toggle; - if(!shade->isLastCommand(cmd.command)) sent = shade->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : shade->repeats, cmd.stepSize); - else sent = shade->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : shade->repeats); - } - break; - } - case web_cmd_t::group_repeat: { - SomfyGroup *group = somfy.getGroupById(cmd.groupId); - if(group) { - if(!group->isLastCommand(cmd.command)) sent = group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize); - else sent = group->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : group->repeats); - } - break; - } - case web_cmd_t::set_positions: { - SomfyShade *shade = somfy.getShadeById(cmd.shadeId); - if(shade) { - if(cmd.position >= 0) shade->target = shade->currentPos = cmd.position; - if(cmd.tiltPosition >= 0 && shade->tiltType != tilt_types::none) shade->tiltTarget = shade->currentTiltPos = cmd.tiltPosition; - shade->emitState(); - } - break; - } - case web_cmd_t::shade_sensor: { - SomfyShade *shade = somfy.getShadeById(cmd.shadeId); - if(shade) { - sent = shade->sendSensorCommand(cmd.windy, cmd.sunny, cmd.repeat >= 0 ? (uint8_t)cmd.repeat : shade->repeats); - shade->emitState(); - } - break; - } - case web_cmd_t::group_sensor: { - SomfyGroup *group = somfy.getGroupById(cmd.groupId); - if(group) { - sent = group->sendSensorCommand(cmd.windy, cmd.sunny, cmd.repeat >= 0 ? (uint8_t)cmd.repeat : group->repeats); - group->emitState(); - } - break; - } - case web_cmd_t::set_my_position: { - SomfyShade *shade = somfy.getShadeById(cmd.shadeId); - if(shade) sent = shade->setMyPosition(cmd.position, cmd.tiltPosition); - break; - } - case web_cmd_t::unpair_shade: { - SomfyShade *shade = somfy.getShadeById(cmd.shadeId); - if(shade) { - sent = shade->sendCommand(somfy_commands::Prog, shade->bitLength == 56 ? 7 : 1); - if(sent) { - shade->paired = false; - shade->save(); - } - } - break; - } - case web_cmd_t::set_rolling_code: { - SomfyShade *shade = somfy.getShadeById(cmd.shadeId); - if(shade) shade->setRollingCode(cmd.rollingCode); - break; - } - case web_cmd_t::restore_shades: { - restore_options_t opts = restoreOptions(cmd.restoreParts); - ShadeConfigFile::restore(&somfy, "/shades.tmp", opts); - ESP_LOGI(TAG, "Rebooting ESP for restored settings..."); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 1000; - break; - } - case web_cmd_t::load_shades: - somfy.loadShadesFile("/shades.tmp"); - break; - } - if(!sent) ESP_LOGW(TAG, "Radio didn't take web command %u", static_cast(cmd.type)); - } -} bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) { ESP_LOGD(TAG, "Checking async authentication"); if(settings.Security.type == security_types::None) return true; @@ -353,7 +226,7 @@ static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { } // paired and lastRollingCode are passed in for a response that reports the // change the main loop is about to make. An unpair is held until it goes on -// air (holdWebCommand), so the answer comes true. +// air (holdLoopItem), so the answer comes true. static void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) { json.addElem("shadeId", shade->getShadeId()); json.addElem("roomId", shade->roomId); @@ -898,40 +771,30 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) { void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json) { if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(!this->isAuthenticated(request)) return; - uint8_t shadeId = 255; - uint8_t target = 255; - uint8_t stepSize = 0; - int8_t repeat = -1; - somfy_commands command = somfy_commands::My; + shade_request_t req; + req.type = shade_request_type_t::shade_command; // Try query params if(asyncHasParam(request, "shadeId")) { - shadeId = asyncParam(request, "shadeId").toInt(); - if(asyncHasParam(request, "command")) command = translateSomfyCommand(asyncParam(request, "command")); - else if(asyncHasParam(request, "target")) target = asyncParam(request, "target").toInt(); - if(asyncHasParam(request, "repeat")) repeat = asyncParam(request, "repeat").toInt(); - if(asyncHasParam(request, "stepSize")) stepSize = asyncParam(request, "stepSize").toInt(); + req.id = asyncParam(request, "shadeId").toInt(); + if(asyncHasParam(request, "command")) req.command = translateSomfyCommand(asyncParam(request, "command")); + else if(asyncHasParam(request, "target")) req.target = requestTarget(asyncParam(request, "target").toInt()); + if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt()); + if(asyncHasParam(request, "stepSize")) req.stepSize = asyncParam(request, "stepSize").toInt(); } else if(!json.isNull()) { JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + if(!obj["shadeId"].isNull()) req.id = obj["shadeId"]; else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; } - if(!obj["command"].isNull()) { String scmd = obj["command"]; command = translateSomfyCommand(scmd); } - else if(!obj["target"].isNull()) target = obj["target"].as(); - if(!obj["repeat"].isNull()) repeat = obj["repeat"].as(); - if(!obj["stepSize"].isNull()) stepSize = obj["stepSize"].as(); + if(!obj["command"].isNull()) { String scmd = obj["command"]; req.command = translateSomfyCommand(scmd); } + else if(!obj["target"].isNull()) req.target = requestTarget(obj["target"].as()); + if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as()); + if(!obj["stepSize"].isNull()) req.stepSize = obj["stepSize"].as(); } else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } - SomfyShade *shade = somfy.getShadeById(shadeId); + SomfyShade *shade = somfy.getShadeById(req.id); if(shade) { - ESP_LOGI(TAG, "handleShadeCommand shade=%u target=%u command=%s", shadeId, target, translateSomfyCommand(command).c_str()); - web_command_t cmd = {}; - cmd.type = web_cmd_t::shade_command; - cmd.shadeId = shadeId; - cmd.target = target; - cmd.command = command; - cmd.repeat = repeat; - cmd.stepSize = stepSize; - if(!queueLoopCommand(request, cmd)) return; + ESP_LOGI(TAG, "handleShadeCommand shade=%u target=%d command=%s", req.id, req.target, translateSomfyCommand(req.command).c_str()); + if(!queueShadeRequest(request, req)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -944,35 +807,27 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json) void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) { if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(!this->isAuthenticated(request)) return; - uint8_t groupId = 255; - uint8_t stepSize = 0; - int8_t repeat = -1; - somfy_commands command = somfy_commands::My; + shade_request_t req; + req.type = shade_request_type_t::group_command; if(asyncHasParam(request, "groupId")) { - groupId = asyncParam(request, "groupId").toInt(); - if(asyncHasParam(request, "command")) command = translateSomfyCommand(asyncParam(request, "command")); - if(asyncHasParam(request, "repeat")) repeat = asyncParam(request, "repeat").toInt(); - if(asyncHasParam(request, "stepSize")) stepSize = asyncParam(request, "stepSize").toInt(); + req.id = asyncParam(request, "groupId").toInt(); + if(asyncHasParam(request, "command")) req.command = translateSomfyCommand(asyncParam(request, "command")); + if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt()); + if(asyncHasParam(request, "stepSize")) req.stepSize = asyncParam(request, "stepSize").toInt(); } else if(!json.isNull()) { JsonObject obj = json.as(); - if(!obj["groupId"].isNull()) groupId = obj["groupId"]; + if(!obj["groupId"].isNull()) req.id = obj["groupId"]; else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group id was supplied.\"}")); return; } - if(!obj["command"].isNull()) { String scmd = obj["command"]; command = translateSomfyCommand(scmd); } - if(!obj["repeat"].isNull()) repeat = obj["repeat"].as(); - if(!obj["stepSize"].isNull()) stepSize = obj["stepSize"].as(); + if(!obj["command"].isNull()) { String scmd = obj["command"]; req.command = translateSomfyCommand(scmd); } + if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as()); + if(!obj["stepSize"].isNull()) req.stepSize = obj["stepSize"].as(); } else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); return; } - SomfyGroup *group = somfy.getGroupById(groupId); + SomfyGroup *group = somfy.getGroupById(req.id); if(group) { - ESP_LOGI(TAG, "handleGroupCommand group=%u command=%s", groupId, translateSomfyCommand(command).c_str()); - web_command_t cmd = {}; - cmd.type = web_cmd_t::group_command; - cmd.groupId = groupId; - cmd.command = command; - cmd.repeat = repeat; - cmd.stepSize = stepSize; - if(!queueLoopCommand(request, cmd)) return; + ESP_LOGI(TAG, "handleGroupCommand group=%u command=%s", req.id, translateSomfyCommand(req.command).c_str()); + if(!queueShadeRequest(request, req)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -985,31 +840,25 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) { if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(!this->isAuthenticated(request)) return; - uint8_t shadeId = 255; - uint8_t target = 255; - somfy_commands command = somfy_commands::My; + shade_request_t req; + req.type = shade_request_type_t::tilt_command; if(asyncHasParam(request, "shadeId")) { - shadeId = asyncParam(request, "shadeId").toInt(); - if(asyncHasParam(request, "command")) command = translateSomfyCommand(asyncParam(request, "command")); - else if(asyncHasParam(request, "target")) target = asyncParam(request, "target").toInt(); + req.id = asyncParam(request, "shadeId").toInt(); + if(asyncHasParam(request, "command")) req.command = translateSomfyCommand(asyncParam(request, "command")); + else if(asyncHasParam(request, "target")) req.target = requestTarget(asyncParam(request, "target").toInt()); } else if(!json.isNull()) { JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + if(!obj["shadeId"].isNull()) req.id = obj["shadeId"]; else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; } - if(!obj["command"].isNull()) { String scmd = obj["command"]; command = translateSomfyCommand(scmd); } - else if(!obj["target"].isNull()) target = obj["target"].as(); + if(!obj["command"].isNull()) { String scmd = obj["command"]; req.command = translateSomfyCommand(scmd); } + else if(!obj["target"].isNull()) req.target = requestTarget(obj["target"].as()); } else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } - SomfyShade *shade = somfy.getShadeById(shadeId); + SomfyShade *shade = somfy.getShadeById(req.id); if(shade) { - ESP_LOGI(TAG, "handleTiltCommand shade=%u target=%u command=%s", shadeId, target, translateSomfyCommand(command).c_str()); - web_command_t cmd = {}; - cmd.type = web_cmd_t::tilt_command; - cmd.shadeId = shadeId; - cmd.target = target; - cmd.command = command; - if(!queueLoopCommand(request, cmd)) return; + ESP_LOGI(TAG, "handleTiltCommand shade=%u target=%d command=%s", req.id, req.target, translateSomfyCommand(req.command).c_str()); + if(!queueShadeRequest(request, req)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1024,33 +873,27 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json) if(!this->isAuthenticated(request)) return; uint8_t shadeId = 255; uint8_t groupId = 255; - uint8_t stepSize = 0; - int8_t repeat = -1; - somfy_commands command = somfy_commands::My; + shade_request_t req; if(asyncHasParam(request, "shadeId")) shadeId = asyncParam(request, "shadeId").toInt(); else if(asyncHasParam(request, "groupId")) groupId = asyncParam(request, "groupId").toInt(); - if(asyncHasParam(request, "command")) command = translateSomfyCommand(asyncParam(request, "command")); - if(asyncHasParam(request, "repeat")) repeat = asyncParam(request, "repeat").toInt(); - if(asyncHasParam(request, "stepSize")) stepSize = asyncParam(request, "stepSize").toInt(); + if(asyncHasParam(request, "command")) req.command = translateSomfyCommand(asyncParam(request, "command")); + if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt()); + if(asyncHasParam(request, "stepSize")) req.stepSize = asyncParam(request, "stepSize").toInt(); if(shadeId == 255 && groupId == 255 && !json.isNull()) { JsonObject obj = json.as(); if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; if(!obj["groupId"].isNull()) groupId = obj["groupId"]; - if(!obj["stepSize"].isNull()) stepSize = obj["stepSize"]; - if(!obj["command"].isNull()) { String scmd = obj["command"]; command = translateSomfyCommand(scmd); } - if(!obj["repeat"].isNull()) repeat = obj["repeat"].as(); + if(!obj["stepSize"].isNull()) req.stepSize = obj["stepSize"]; + if(!obj["command"].isNull()) { String scmd = obj["command"]; req.command = translateSomfyCommand(scmd); } + if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as()); } if(shadeId != 255) { - ESP_LOGI(TAG, "handleRepeatCommand shade=%u command=%s", shadeId, translateSomfyCommand(command).c_str()); + ESP_LOGI(TAG, "handleRepeatCommand shade=%u command=%s", shadeId, translateSomfyCommand(req.command).c_str()); SomfyShade *shade = somfy.getShadeById(shadeId); if(!shade) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade reference could not be found.\"}")); return; } - web_command_t cmd = {}; - cmd.type = web_cmd_t::shade_repeat; - cmd.shadeId = shadeId; - cmd.command = command; - cmd.repeat = repeat; - cmd.stepSize = stepSize; - if(!queueLoopCommand(request, cmd)) return; + req.type = shade_request_type_t::shade_repeat; + req.id = shadeId; + if(!queueShadeRequest(request, req)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1059,16 +902,12 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json) resp.endResponse(); } else if(groupId != 255) { - ESP_LOGI(TAG, "handleRepeatCommand group=%u command=%s", groupId, translateSomfyCommand(command).c_str()); + ESP_LOGI(TAG, "handleRepeatCommand group=%u command=%s", groupId, translateSomfyCommand(req.command).c_str()); SomfyGroup *group = somfy.getGroupById(groupId); if(!group) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group reference could not be found.\"}")); return; } - web_command_t cmd = {}; - cmd.type = web_cmd_t::group_repeat; - cmd.groupId = groupId; - cmd.command = command; - cmd.repeat = repeat; - cmd.stepSize = stepSize; - if(!queueLoopCommand(request, cmd)) return; + req.type = shade_request_type_t::group_repeat; + req.id = groupId; + if(!queueShadeRequest(request, req)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1144,27 +983,24 @@ void Web::handleGroup(AsyncWebServerRequest *request) { void Web::handleSetPositions(AsyncWebServerRequest *request, JsonVariant &json) { if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(!this->isAuthenticated(request)) return; - uint8_t shadeId = asyncHasParam(request, "shadeId") ? asyncParam(request, "shadeId").toInt() : 255; - int8_t pos = asyncHasParam(request, "position") ? asyncParam(request, "position").toInt() : -1; - int8_t tiltPos = asyncHasParam(request, "tiltPosition") ? asyncParam(request, "tiltPosition").toInt() : -1; + shade_request_t req; + req.type = shade_request_type_t::set_positions; + if(asyncHasParam(request, "shadeId")) req.id = asyncParam(request, "shadeId").toInt(); + if(asyncHasParam(request, "position")) req.position = requestTarget(asyncParam(request, "position").toInt()); + if(asyncHasParam(request, "tiltPosition")) req.tiltPosition = requestTarget(asyncParam(request, "tiltPosition").toInt()); if(!json.isNull()) { JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - if(!obj["position"].isNull()) pos = obj["position"]; - if(!obj["tiltPosition"].isNull()) tiltPos = obj["tiltPosition"]; + if(!obj["shadeId"].isNull()) req.id = obj["shadeId"]; + if(!obj["position"].isNull()) req.position = requestTarget(obj["position"].as()); + if(!obj["tiltPosition"].isNull()) req.tiltPosition = requestTarget(obj["tiltPosition"].as()); } - if(shadeId != 255) { - ESP_LOGI(TAG, "handleSetPositions shade=%u pos=%d tiltPos=%d", shadeId, pos, tiltPos); - SomfyShade *shade = somfy.getShadeById(shadeId); + if(req.id != 255) { + ESP_LOGI(TAG, "handleSetPositions shade=%u pos=%d tiltPos=%d", req.id, req.position, req.tiltPosition); + SomfyShade *shade = somfy.getShadeById(req.id); if(shade) { - web_command_t cmd = {}; - cmd.type = web_cmd_t::set_positions; - cmd.shadeId = shadeId; - cmd.position = pos; - cmd.tiltPosition = tiltPos; - if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } - // The main loop applies the command after we answer so this is the state - // before it. Clients get the result from the shadeState socket event. + if(!queueShadeRequest(request, req)) return; + // loop() applies the request after we answer so this is the state before + // it. Clients get the result from the shadeState socket event. AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1181,33 +1017,30 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) { if(!this->isAuthenticated(request)) return; uint8_t shadeId = asyncHasParam(request, "shadeId") ? asyncParam(request, "shadeId").toInt() : 255; uint8_t groupId = asyncHasParam(request, "groupId") ? asyncParam(request, "groupId").toInt() : 255; - int8_t sunny = asyncHasParam(request, "sunny") ? (toBoolean(asyncParam(request, "sunny").c_str(), false) ? 1 : 0) : -1; - int8_t windy = asyncHasParam(request, "windy") ? asyncParam(request, "windy").toInt() : -1; - int8_t repeat = asyncHasParam(request, "repeat") ? asyncParam(request, "repeat").toInt() : -1; + shade_request_t req; + if(asyncHasParam(request, "sunny")) req.sunny = toBoolean(asyncParam(request, "sunny").c_str(), false) ? 1 : 0; + if(asyncHasParam(request, "windy")) req.windy = asyncParam(request, "windy").toInt(); + if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt()); if(!json.isNull()) { JsonObject obj = json.as(); if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"].as(); if(!obj["groupId"].isNull()) groupId = obj["groupId"].as(); if(!obj["sunny"].isNull()) { - if(obj["sunny"].is()) sunny = obj["sunny"].as() ? 1 : 0; - else sunny = obj["sunny"].as(); + if(obj["sunny"].is()) req.sunny = obj["sunny"].as() ? 1 : 0; + else req.sunny = obj["sunny"].as(); } if(!obj["windy"].isNull()) { - if(obj["windy"].is()) windy = obj["windy"].as() ? 1 : 0; - else windy = obj["windy"].as(); + if(obj["windy"].is()) req.windy = obj["windy"].as() ? 1 : 0; + else req.windy = obj["windy"].as(); } - if(!obj["repeat"].isNull()) repeat = obj["repeat"].as(); + if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as()); } if(shadeId != 255) { SomfyShade *shade = somfy.getShadeById(shadeId); if(shade) { - web_command_t cmd = {}; - cmd.type = web_cmd_t::shade_sensor; - cmd.shadeId = shadeId; - cmd.sunny = sunny; - cmd.windy = windy; - cmd.repeat = repeat; - if(!queueLoopCommand(request, cmd)) return; + req.type = shade_request_type_t::shade_sensor; + req.id = shadeId; + if(!queueShadeRequest(request, req)) return; // The main loop applies the command after we answer so this is the state // before it. Clients get the result from the shadeState socket event. AsyncJsonResp resp; @@ -1222,13 +1055,9 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) { else if(groupId != 255) { SomfyGroup *group = somfy.getGroupById(groupId); if(group) { - web_command_t cmd = {}; - cmd.type = web_cmd_t::group_sensor; - cmd.groupId = groupId; - cmd.sunny = sunny; - cmd.windy = windy; - cmd.repeat = repeat; - if(!queueLoopCommand(request, cmd)) return; + req.type = shade_request_type_t::group_sensor; + req.id = groupId; + if(!queueShadeRequest(request, req)) return; // As above, this is the state before the command and groupState follows. AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); @@ -1511,10 +1340,10 @@ void Web::begin() { } // The main loop restores, since it owns the shades and their rolling // codes, and then reboots. - web_command_t cmd = {}; - cmd.type = web_cmd_t::restore_shades; - cmd.restoreParts = restoreParts(opts); - if(!queueLoopCommand(request, cmd)) return; + loop_item_t item; + item.type = loop_item_type_t::restore_shades; + item.restoreParts = restoreParts(opts); + if(!queueLoopItem(request, item)) return; request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}"); } }, @@ -1776,9 +1605,8 @@ void Web::begin() { } SomfyShade* shade = somfy.getShadeById(shadeId); if(shade) { - tilt = requestedFavoriteTilt(tilt, shade->tiltType, shade->myTiltPos, shade->flipPosition); if(pos >= 0 && pos <= 100) - if(!queueSetMyPosition(request, shade, shade->transformPosition(pos), shade->transformPosition(tilt))) return; + if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1802,9 +1630,8 @@ void Web::begin() { else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } SomfyShade* shade = somfy.getShadeById(shadeId); if(shade) { - tilt = requestedFavoriteTilt(tilt, shade->tiltType, shade->myTiltPos, shade->flipPosition); if(pos >= 0 && pos <= 100) - if(!queueSetMyPosition(request, shade, shade->transformPosition(pos), shade->transformPosition(tilt))) return; + if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -2326,9 +2153,9 @@ void Web::begin() { } if(final) { // The main loop loads it, since it owns the shades. - web_command_t cmd = {}; - cmd.type = web_cmd_t::load_shades; - webServer.queueCommand(cmd); + loop_item_t item; + item.type = loop_item_type_t::load_shades; + loopQueue.submit(item); } }); diff --git a/src/entryPoints/Web.h b/src/entryPoints/Web.h index 473c471..96ec97f 100644 --- a/src/entryPoints/Web.h +++ b/src/entryPoints/Web.h @@ -1,9 +1,6 @@ #include #include -#include -#include #include "Somfy.h" -#include "WebCommand.h" #ifndef webserver_h #define webserver_h @@ -40,8 +37,5 @@ class Web { void handleBackup(AsyncWebServerRequest *request); void handleReboot(AsyncWebServerRequest *request); void handleNotFound(AsyncWebServerRequest *request); - bool queueCommand(const web_command_t &cmd); - private: - void processQueue(); }; #endif diff --git a/test/test_loopqueue/test_loopqueue.cpp b/test/test_loopqueue/test_loopqueue.cpp new file mode 100644 index 0000000..80fbd39 --- /dev/null +++ b/test/test_loopqueue/test_loopqueue.cpp @@ -0,0 +1,78 @@ +#include +#include "LoopQueue.h" + +static loop_item_t request(shade_request_type_t type) { + loop_item_t item; + item.request.type = type; + return item; +} +static loop_item_t item(loop_item_type_t type) { + loop_item_t item; + item.type = type; + return item; +} + +static const shade_request_type_t RADIO_REQUESTS[] = { + shade_request_type_t::shade_command, shade_request_type_t::group_command, shade_request_type_t::tilt_command, + shade_request_type_t::shade_repeat, shade_request_type_t::group_repeat, shade_request_type_t::shade_sensor, + shade_request_type_t::group_sensor, shade_request_type_t::set_favorite, shade_request_type_t::unpair +}; +static const shade_request_type_t OFF_RADIO_REQUESTS[] = { + shade_request_type_t::set_positions, shade_request_type_t::set_rolling_code +}; +static const loop_item_type_t OTHER_ITEMS[] = { loop_item_type_t::restore_shades, loop_item_type_t::load_shades }; + +void setUp() {} +void tearDown() {} + +static void test_only_position_and_rolling_code_requests_stay_off_the_radio() { + int offRadio = 0; + for(uint8_t t = 0; t <= static_cast(shade_request_type_t::set_rolling_code); t++) { + if(!shadeRequestUsesRadio(static_cast(t))) offRadio++; + } + TEST_ASSERT_EQUAL_INT(2, offRadio); + for(shade_request_type_t t : OFF_RADIO_REQUESTS) TEST_ASSERT_FALSE(loopItemUsesRadio(request(t))); + for(loop_item_type_t t : OTHER_ITEMS) TEST_ASSERT_FALSE(loopItemUsesRadio(item(t))); +} +static void test_off_radio_items_are_never_held() { + // Nothing goes on air, so a full or suspended radio doesn't matter. + for(shade_request_type_t t : OFF_RADIO_REQUESTS) { + TEST_ASSERT_FALSE(holdLoopItem(request(t), false, false)); + TEST_ASSERT_FALSE(holdLoopItem(request(t), true, false)); + } + for(loop_item_type_t t : OTHER_ITEMS) { + TEST_ASSERT_FALSE(holdLoopItem(item(t), false, false)); + TEST_ASSERT_FALSE(holdLoopItem(item(t), true, false)); + } +} +static void test_radio_request_waits_for_room() { + for(shade_request_type_t t : RADIO_REQUESTS) { + TEST_ASSERT_TRUE(holdLoopItem(request(t), false, false)); + TEST_ASSERT_FALSE(holdLoopItem(request(t), false, true)); + } +} +static void test_requests_are_not_held_while_suspended() { + // Nothing goes on air during a frequency scan or OTA anyway. + for(shade_request_type_t t : RADIO_REQUESTS) { + if(t == shade_request_type_t::unpair) continue; + TEST_ASSERT_FALSE(holdLoopItem(request(t), true, false)); + TEST_ASSERT_FALSE(holdLoopItem(request(t), true, true)); + } +} +static void test_unpair_waits_out_a_suspend() { + // The client was already told the shade is unpaired. + TEST_ASSERT_TRUE(holdLoopItem(request(shade_request_type_t::unpair), true, false)); + TEST_ASSERT_TRUE(holdLoopItem(request(shade_request_type_t::unpair), true, true)); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_only_position_and_rolling_code_requests_stay_off_the_radio); + RUN_TEST(test_off_radio_items_are_never_held); + RUN_TEST(test_radio_request_waits_for_room); + RUN_TEST(test_requests_are_not_held_while_suspended); + RUN_TEST(test_unpair_waits_out_a_suspend); + return UNITY_END(); +} diff --git a/test/test_mqttcommands/test_mqttcommands.cpp b/test/test_mqttcommands/test_mqttcommands.cpp new file mode 100644 index 0000000..aa5f913 --- /dev/null +++ b/test/test_mqttcommands/test_mqttcommands.cpp @@ -0,0 +1,165 @@ +#include +#include +#include "entryPoints/MqttCommands.h" + +static shade_request_t req; + +static bool parse(const char *topic, const char *value) { + req = shade_request_t(); + return parseMqttCommand(topic, reinterpret_cast(value), strlen(value), req); +} +static void assertType(shade_request_type_t expected) { + TEST_ASSERT_EQUAL_UINT8(static_cast(expected), static_cast(req.type)); +} +static void assertCommand(somfy_commands expected) { + TEST_ASSERT_EQUAL_UINT8(static_cast(expected), static_cast(req.command)); +} + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * Topics + ********************************************************************/ +static void test_the_last_four_segments_name_the_command() { + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/target/set", "30")); + assertType(shade_request_type_t::shade_command); + TEST_ASSERT_EQUAL_UINT8(4, req.id); + TEST_ASSERT_EQUAL_INT8(30, req.target); +} +static void test_the_root_topic_may_be_empty_or_have_slashes() { + TEST_ASSERT_TRUE(parse("shades/12/target/set", "30")); + TEST_ASSERT_EQUAL_UINT8(12, req.id); + TEST_ASSERT_TRUE(parse("home/somfy/rts/shades/255/target/set", "30")); + TEST_ASSERT_EQUAL_UINT8(255, req.id); +} +static void test_topics_it_does_not_take() { + TEST_ASSERT_FALSE(parse("espsomfy/shades/4/target", "30")); // not a set topic + TEST_ASSERT_FALSE(parse("espsomfy/shades/4/target/get", "30")); + TEST_ASSERT_FALSE(parse("espsomfy/rooms/4/target/set", "30")); // no such entity + TEST_ASSERT_FALSE(parse("espsomfy/shades/4/name/set", "30")); // no such command + TEST_ASSERT_FALSE(parse("espsomfy/shades/x4/target/set", "30")); // not an id + TEST_ASSERT_FALSE(parse("espsomfy/shades/256/target/set", "30")); + TEST_ASSERT_FALSE(parse("espsomfy/shades//target/set", "30")); + TEST_ASSERT_FALSE(parse("target/set", "30")); + TEST_ASSERT_FALSE(parse("", "30")); +} +static void test_the_payload_need_not_be_terminated() { + const char payload[] = { '4', '2', 'x', 'y' }; + req = shade_request_t(); + TEST_ASSERT_TRUE(parseMqttCommand("espsomfy/shades/4/target/set", reinterpret_cast(payload), 2, req)); + TEST_ASSERT_EQUAL_INT8(42, req.target); +} + +/********************************************************************* + * Shades + ********************************************************************/ +static void test_targets_must_be_in_range() { + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/target/set", "100")); + TEST_ASSERT_FALSE(parse("espsomfy/shades/4/target/set", "101")); + TEST_ASSERT_FALSE(parse("espsomfy/shades/4/target/set", "-1")); + TEST_ASSERT_FALSE(parse("espsomfy/shades/4/tiltTarget/set", "101")); +} +static void test_tilt_target() { + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/tiltTarget/set", "30")); + assertType(shade_request_type_t::tilt_command); + TEST_ASSERT_EQUAL_INT8(30, req.target); +} +static void test_direction_sends_up_down_or_my() { + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/direction/set", "-1")); + assertType(shade_request_type_t::shade_command); + TEST_ASSERT_EQUAL_INT8(-1, req.target); + assertCommand(somfy_commands::Up); + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/direction/set", "1")); + assertCommand(somfy_commands::Down); + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/direction/set", "0")); + assertCommand(somfy_commands::My); +} +static void test_favorite_lift_and_tilt() { + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/mypos/set", "30")); + assertType(shade_request_type_t::set_favorite); + TEST_ASSERT_EQUAL_INT8(30, req.position); + TEST_ASSERT_EQUAL_INT8(-1, req.tiltPosition); + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/myTiltPos/set", "60")); + assertType(shade_request_type_t::set_favorite); + TEST_ASSERT_EQUAL_INT8(-1, req.position); + TEST_ASSERT_EQUAL_INT8(60, req.tiltPosition); + TEST_ASSERT_FALSE(parse("espsomfy/shades/4/mypos/set", "120")); +} +static void test_sun_flag() { + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/sunFlag/set", "1")); + assertType(shade_request_type_t::shade_command); + assertCommand(somfy_commands::SunFlag); + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/sunFlag/set", "0")); + assertCommand(somfy_commands::Flag); +} +static void test_sunny_and_windy_leave_the_other_alone() { + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/sunny/set", "1")); + assertType(shade_request_type_t::shade_sensor); + TEST_ASSERT_EQUAL_INT8(1, req.sunny); + TEST_ASSERT_EQUAL_INT8(-1, req.windy); + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/windy/set", "0")); + TEST_ASSERT_EQUAL_INT8(-1, req.sunny); + TEST_ASSERT_EQUAL_INT8(0, req.windy); + TEST_ASSERT_FALSE(parse("espsomfy/shades/4/windy/set", "-1")); +} +static void test_position_and_tilt_position_correct_the_shade() { + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/position/set", "30")); + assertType(shade_request_type_t::set_positions); + TEST_ASSERT_EQUAL_INT8(30, req.position); + TEST_ASSERT_EQUAL_INT8(-1, req.tiltPosition); + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/tiltPosition/set", "60")); + TEST_ASSERT_EQUAL_INT8(-1, req.position); + TEST_ASSERT_EQUAL_INT8(60, req.tiltPosition); +} +static void test_shade_requests_use_the_default_repeats() { + TEST_ASSERT_TRUE(parse("espsomfy/shades/4/direction/set", "1")); + TEST_ASSERT_EQUAL_INT8(-1, req.repeat); +} + +/********************************************************************* + * Groups + ********************************************************************/ +static void test_group_direction_and_sun_flag() { + TEST_ASSERT_TRUE(parse("espsomfy/groups/2/direction/set", "-1")); + assertType(shade_request_type_t::group_command); + TEST_ASSERT_EQUAL_UINT8(2, req.id); + assertCommand(somfy_commands::Up); + TEST_ASSERT_TRUE(parse("espsomfy/groups/2/sunFlag/set", "1")); + assertType(shade_request_type_t::group_command); + assertCommand(somfy_commands::SunFlag); +} +static void test_group_sunny_and_windy() { + TEST_ASSERT_TRUE(parse("espsomfy/groups/2/sunny/set", "0")); + assertType(shade_request_type_t::group_sensor); + TEST_ASSERT_EQUAL_INT8(0, req.sunny); + TEST_ASSERT_EQUAL_INT8(-1, req.windy); + TEST_ASSERT_TRUE(parse("espsomfy/groups/2/windy/set", "1")); + TEST_ASSERT_EQUAL_INT8(1, req.windy); +} +static void test_groups_only_take_their_commands() { + TEST_ASSERT_FALSE(parse("espsomfy/groups/2/target/set", "30")); + TEST_ASSERT_FALSE(parse("espsomfy/groups/2/mypos/set", "30")); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_the_last_four_segments_name_the_command); + RUN_TEST(test_the_root_topic_may_be_empty_or_have_slashes); + RUN_TEST(test_topics_it_does_not_take); + RUN_TEST(test_the_payload_need_not_be_terminated); + RUN_TEST(test_targets_must_be_in_range); + RUN_TEST(test_tilt_target); + RUN_TEST(test_direction_sends_up_down_or_my); + RUN_TEST(test_favorite_lift_and_tilt); + RUN_TEST(test_sun_flag); + RUN_TEST(test_sunny_and_windy_leave_the_other_alone); + RUN_TEST(test_position_and_tilt_position_correct_the_shade); + RUN_TEST(test_shade_requests_use_the_default_repeats); + RUN_TEST(test_group_direction_and_sun_flag); + RUN_TEST(test_group_sunny_and_windy); + RUN_TEST(test_groups_only_take_their_commands); + return UNITY_END(); +} diff --git a/test/test_shaderequests/test_shaderequests.cpp b/test/test_shaderequests/test_shaderequests.cpp new file mode 100644 index 0000000..7d5a858 --- /dev/null +++ b/test/test_shaderequests/test_shaderequests.cpp @@ -0,0 +1,398 @@ +#include +#include +#include +#include "ShadeRequests.h" + +// Records every call as a line so a test reads as "this request, these calls". +class FakeShadeOps : public ShadeOps { + public: + std::map shades; + std::map groups; // group id -> its repeats + bool radioTakes = true; // false: the radio refuses everything + bool lastCommand = false; // what isLastCommand answers + std::string calls; + + bool findShade(uint8_t id, shade_info_t &info) override { + auto it = shades.find(id); + if(it == shades.end()) return false; + info = it->second; + return true; + } + bool findGroup(uint8_t id, uint8_t &repeats) override { + auto it = groups.find(id); + if(it == groups.end()) return false; + repeats = it->second; + return true; + } + bool moveToTarget(uint8_t id, float pos) override { return log("moveToTarget %u %g", id, pos); } + bool moveToTiltTarget(uint8_t id, float tilt) override { return log("moveToTiltTarget %u %g", id, tilt); } + bool sendCommand(uint8_t id, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) override { + return log("sendCommand %u %s repeat=%u step=%u", id, name(cmd), repeat, stepSize); + } + bool sendTiltCommand(uint8_t id, somfy_commands cmd) override { return log("sendTiltCommand %u %s", id, name(cmd)); } + bool isLastCommand(uint8_t, somfy_commands) override { return lastCommand; } + bool repeatFrame(uint8_t id, uint8_t repeat) override { return log("repeatFrame %u repeat=%u", id, repeat); } + bool sendSensorCommand(uint8_t id, int8_t windy, int8_t sunny, uint8_t repeat) override { + return log("sendSensorCommand %u windy=%d sunny=%d repeat=%u", id, windy, sunny, repeat); + } + bool setMyPosition(uint8_t id, int8_t pos, int8_t tilt) override { return log("setMyPosition %u %d %d", id, pos, tilt); } + void setPositions(uint8_t id, float pos, float tilt) override { log("setPositions %u %g %g", id, pos, tilt); } + void setUnpaired(uint8_t id) override { log("setUnpaired %u", id); } + void setRollingCode(uint8_t id, uint16_t code) override { log("setRollingCode %u %u", id, code); } + bool sendGroupCommand(uint8_t id, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) override { + return log("sendGroupCommand %u %s repeat=%u step=%u", id, name(cmd), repeat, stepSize); + } + bool isLastGroupCommand(uint8_t, somfy_commands) override { return lastCommand; } + bool repeatGroupFrame(uint8_t id, uint8_t repeat) override { return log("repeatGroupFrame %u repeat=%u", id, repeat); } + bool sendGroupSensorCommand(uint8_t id, int8_t windy, int8_t sunny, uint8_t repeat) override { + return log("sendGroupSensorCommand %u windy=%d sunny=%d repeat=%u", id, windy, sunny, repeat); + } + private: + static const char *name(somfy_commands cmd) { + static std::string s; + s = translateSomfyCommand(cmd).c_str(); + return s.c_str(); + } + template + bool log(const char *fmt, Args... args) { + char line[128]; + snprintf(line, sizeof(line), fmt, args...); + if(!calls.empty()) calls += "; "; + calls += line; + return radioTakes; + } +}; + +static FakeShadeOps ops; +static ShadeRequests requests(ops); + +static shade_info_t rollerShade() { + shade_info_t s; + s.repeats = 2; + return s; +} +static shade_info_t flippedShade() { + shade_info_t s = rollerShade(); + s.flipPosition = true; + return s; +} +static shade_info_t tiltShade(tilt_types tilt) { + shade_info_t s = rollerShade(); + s.tiltType = tilt; + return s; +} +static shade_request_t request(shade_request_type_t type, uint8_t id = 4) { + shade_request_t req; + req.type = type; + req.id = id; + return req; +} + +void setUp() { + ops.shades.clear(); + ops.groups.clear(); + ops.radioTakes = true; + ops.lastCommand = false; + ops.calls.clear(); +} +void tearDown() {} + +/********************************************************************* + * Finding the shade or group + ********************************************************************/ +static void test_a_missing_shade_is_dropped() { + shade_request_t req = request(shade_request_type_t::shade_command); + req.target = 30; + TEST_ASSERT_TRUE(requests.run(req)); + TEST_ASSERT_EQUAL_STRING("", ops.calls.c_str()); +} +static void test_a_missing_group_is_dropped() { + TEST_ASSERT_TRUE(requests.run(request(shade_request_type_t::group_command, 2))); + TEST_ASSERT_EQUAL_STRING("", ops.calls.c_str()); +} +static void test_a_shade_id_is_not_a_group_id() { + ops.shades[2] = rollerShade(); + requests.run(request(shade_request_type_t::group_command, 2)); + TEST_ASSERT_EQUAL_STRING("", ops.calls.c_str()); +} + +/********************************************************************* + * Positions as the client sees them + ********************************************************************/ +static void test_target_moves_a_shade_there() { + ops.shades[4] = rollerShade(); + shade_request_t req = request(shade_request_type_t::shade_command); + req.target = 30; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("moveToTarget 4 30", ops.calls.c_str()); +} +static void test_target_on_a_flipped_shade_is_turned_round() { + ops.shades[4] = flippedShade(); + shade_request_t req = request(shade_request_type_t::shade_command); + req.target = 30; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("moveToTarget 4 70", ops.calls.c_str()); +} +static void test_tilt_target_on_a_flipped_shade_is_turned_round() { + // MQTT used to send this one straight through. + ops.shades[4] = flippedShade(); + shade_request_t req = request(shade_request_type_t::tilt_command); + req.target = 30; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("moveToTiltTarget 4 70", ops.calls.c_str()); +} +static void test_without_a_target_the_command_is_sent() { + ops.shades[4] = rollerShade(); + shade_request_t req = request(shade_request_type_t::shade_command); + req.command = somfy_commands::Down; + req.stepSize = 3; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("sendCommand 4 Down repeat=2 step=3", ops.calls.c_str()); +} +static void test_tilt_without_a_target_sends_the_tilt_command() { + ops.shades[4] = rollerShade(); + shade_request_t req = request(shade_request_type_t::tilt_command); + req.command = somfy_commands::Up; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("sendTiltCommand 4 Up", ops.calls.c_str()); +} +static void test_set_positions_on_a_flipped_shade_are_turned_round() { + // /setPositions used to send these straight through. + ops.shades[4] = flippedShade(); + ops.shades[4].tiltType = tilt_types::integrated; + shade_request_t req = request(shade_request_type_t::set_positions); + req.position = 20; + req.tiltPosition = 90; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("setPositions 4 80 10", ops.calls.c_str()); +} +static void test_set_positions_leaves_the_tilt_of_a_shade_without_one() { + ops.shades[4] = rollerShade(); + shade_request_t req = request(shade_request_type_t::set_positions); + req.position = 20; + req.tiltPosition = 90; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("setPositions 4 20 -1", ops.calls.c_str()); +} +static void test_set_positions_with_nothing_in_range_does_nothing() { + ops.shades[4] = tiltShade(tilt_types::integrated); + shade_request_t req = request(shade_request_type_t::set_positions); + req.position = -1; + req.tiltPosition = 101; + TEST_ASSERT_TRUE(requests.run(req)); + TEST_ASSERT_EQUAL_STRING("", ops.calls.c_str()); +} +static void test_client_values_are_checked_on_the_way_in() { + TEST_ASSERT_EQUAL_INT8(0, requestTarget(0)); + TEST_ASSERT_EQUAL_INT8(100, requestTarget(100)); + TEST_ASSERT_EQUAL_INT8(-1, requestTarget(101)); + TEST_ASSERT_EQUAL_INT8(-1, requestTarget(255)); + TEST_ASSERT_EQUAL_INT8(-1, requestRepeat(-5)); + TEST_ASSERT_EQUAL_INT8(0, requestRepeat(0)); + TEST_ASSERT_EQUAL_INT8(127, requestRepeat(200)); +} + +/********************************************************************* + * Repeats + ********************************************************************/ +static void test_no_repeat_means_the_shades_own() { + ops.shades[4] = rollerShade(); + shade_request_t req = request(shade_request_type_t::shade_command); + req.command = somfy_commands::Up; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("sendCommand 4 Up repeat=2 step=0", ops.calls.c_str()); +} +static void test_repeat_zero_means_no_repeats_for_a_shade_too() { + // It used to mean "the shade's own" for /shadeCommand but not for groups. + ops.shades[4] = rollerShade(); + ops.groups[2] = 3; + shade_request_t req = request(shade_request_type_t::shade_command); + req.command = somfy_commands::Up; + req.repeat = 0; + requests.run(req); + shade_request_t group = request(shade_request_type_t::group_command, 2); + group.command = somfy_commands::Up; + group.repeat = 0; + requests.run(group); + TEST_ASSERT_EQUAL_STRING("sendCommand 4 Up repeat=0 step=0; sendGroupCommand 2 Up repeat=0 step=0", ops.calls.c_str()); +} +static void test_no_repeat_means_the_groups_own() { + ops.groups[2] = 3; + shade_request_t req = request(shade_request_type_t::group_command, 2); + req.command = somfy_commands::Down; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("sendGroupCommand 2 Down repeat=3 step=0", ops.calls.c_str()); +} + +/********************************************************************* + * Repeating the last command + ********************************************************************/ +static void test_repeat_sends_a_new_command_when_it_was_not_the_last() { + ops.shades[4] = rollerShade(); + shade_request_t req = request(shade_request_type_t::shade_repeat); + req.command = somfy_commands::Prog; + req.repeat = 10; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("sendCommand 4 Prog repeat=10 step=0", ops.calls.c_str()); +} +static void test_repeat_repeats_the_last_frame() { + ops.shades[4] = rollerShade(); + ops.lastCommand = true; + shade_request_t req = request(shade_request_type_t::shade_repeat); + req.command = somfy_commands::Prog; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("repeatFrame 4 repeat=2", ops.calls.c_str()); +} +static void test_prog_on_a_one_button_garage_door_is_toggle() { + ops.shades[4] = rollerShade(); + ops.shades[4].shadeType = shade_types::garage1; + shade_request_t req = request(shade_request_type_t::shade_repeat); + req.command = somfy_commands::Prog; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("sendCommand 4 Toggle repeat=2 step=0", ops.calls.c_str()); +} +static void test_group_repeat_repeats_the_last_frame() { + ops.groups[2] = 3; + ops.lastCommand = true; + requests.run(request(shade_request_type_t::group_repeat, 2)); + TEST_ASSERT_EQUAL_STRING("repeatGroupFrame 2 repeat=3", ops.calls.c_str()); +} + +/********************************************************************* + * Sensor frames + ********************************************************************/ +static void test_sensor_sends_what_was_given() { + ops.shades[4] = rollerShade(); + shade_request_t req = request(shade_request_type_t::shade_sensor); + req.sunny = 1; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("sendSensorCommand 4 windy=-1 sunny=1 repeat=2", ops.calls.c_str()); +} +static void test_group_sensor_uses_the_groups_repeats() { + ops.groups[2] = 3; + shade_request_t req = request(shade_request_type_t::group_sensor, 2); + req.windy = 0; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("sendGroupSensorCommand 2 windy=0 sunny=-1 repeat=3", ops.calls.c_str()); +} + +/********************************************************************* + * The favorite + ********************************************************************/ +static void test_favorite_on_a_flipped_shade_is_turned_round() { + ops.shades[4] = flippedShade(); + shade_request_t req = request(shade_request_type_t::set_favorite); + req.position = 30; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("setMyPosition 4 70 -1", ops.calls.c_str()); +} +static void test_favorite_without_a_tilt_keeps_the_favorite_tilt() { + // MQTT mypos used to set the favorite tilt to 0 here. + ops.shades[4] = tiltShade(tilt_types::integrated); + ops.shades[4].myTiltPos = 40.0f; + shade_request_t req = request(shade_request_type_t::set_favorite); + req.position = 30; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("setMyPosition 4 30 40", ops.calls.c_str()); +} +static void test_favorite_tilt_alone_keeps_the_favorite_lift() { + ops.shades[4] = tiltShade(tilt_types::integrated); + ops.shades[4].myPos = 60.0f; + ops.shades[4].currentPos = 20.0f; + shade_request_t req = request(shade_request_type_t::set_favorite); + req.tiltPosition = 25; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("setMyPosition 4 60 25", ops.calls.c_str()); +} +static void test_favorite_tilt_alone_without_a_favorite_uses_where_the_shade_is() { + ops.shades[4] = tiltShade(tilt_types::integrated); + ops.shades[4].currentPos = 20.0f; + shade_request_t req = request(shade_request_type_t::set_favorite); + req.tiltPosition = 25; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("setMyPosition 4 20 25", ops.calls.c_str()); +} +static void test_favorite_tilt_on_a_flipped_shade_is_turned_round() { + ops.shades[4] = flippedShade(); + ops.shades[4].tiltType = tilt_types::integrated; + shade_request_t req = request(shade_request_type_t::set_favorite); + req.position = 30; + req.tiltPosition = 10; + requests.run(req); + TEST_ASSERT_EQUAL_STRING("setMyPosition 4 70 90", ops.calls.c_str()); +} + +/********************************************************************* + * Pairing and rolling codes + ********************************************************************/ +static void test_unpair_holds_prog_longer_on_a_56_bit_motor() { + ops.shades[4] = rollerShade(); + ops.shades[5] = rollerShade(); + ops.shades[5].bitLength = 80; + requests.run(request(shade_request_type_t::unpair, 4)); + requests.run(request(shade_request_type_t::unpair, 5)); + TEST_ASSERT_EQUAL_STRING( + "sendCommand 4 Prog repeat=7 step=0; setUnpaired 4; sendCommand 5 Prog repeat=1 step=0; setUnpaired 5", + ops.calls.c_str()); +} +static void test_unpair_the_radio_refused_leaves_the_shade_paired() { + ops.shades[4] = rollerShade(); + ops.radioTakes = false; + TEST_ASSERT_FALSE(requests.run(request(shade_request_type_t::unpair))); + TEST_ASSERT_EQUAL_STRING("sendCommand 4 Prog repeat=7 step=0", ops.calls.c_str()); +} +static void test_rolling_code_is_set() { + ops.shades[4] = rollerShade(); + shade_request_t req = request(shade_request_type_t::set_rolling_code); + req.rollingCode = 1234; + TEST_ASSERT_TRUE(requests.run(req)); + TEST_ASSERT_EQUAL_STRING("setRollingCode 4 1234", ops.calls.c_str()); +} + +/********************************************************************* + * The radio refusing + ********************************************************************/ +static void test_a_refused_command_is_reported() { + ops.shades[4] = rollerShade(); + ops.radioTakes = false; + shade_request_t req = request(shade_request_type_t::shade_command); + req.target = 50; + TEST_ASSERT_FALSE(requests.run(req)); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_a_missing_shade_is_dropped); + RUN_TEST(test_a_missing_group_is_dropped); + RUN_TEST(test_a_shade_id_is_not_a_group_id); + RUN_TEST(test_target_moves_a_shade_there); + RUN_TEST(test_target_on_a_flipped_shade_is_turned_round); + RUN_TEST(test_tilt_target_on_a_flipped_shade_is_turned_round); + RUN_TEST(test_without_a_target_the_command_is_sent); + RUN_TEST(test_tilt_without_a_target_sends_the_tilt_command); + RUN_TEST(test_set_positions_on_a_flipped_shade_are_turned_round); + RUN_TEST(test_set_positions_leaves_the_tilt_of_a_shade_without_one); + RUN_TEST(test_set_positions_with_nothing_in_range_does_nothing); + RUN_TEST(test_client_values_are_checked_on_the_way_in); + RUN_TEST(test_no_repeat_means_the_shades_own); + RUN_TEST(test_repeat_zero_means_no_repeats_for_a_shade_too); + RUN_TEST(test_no_repeat_means_the_groups_own); + RUN_TEST(test_repeat_sends_a_new_command_when_it_was_not_the_last); + RUN_TEST(test_repeat_repeats_the_last_frame); + RUN_TEST(test_prog_on_a_one_button_garage_door_is_toggle); + RUN_TEST(test_group_repeat_repeats_the_last_frame); + RUN_TEST(test_sensor_sends_what_was_given); + RUN_TEST(test_group_sensor_uses_the_groups_repeats); + RUN_TEST(test_favorite_on_a_flipped_shade_is_turned_round); + RUN_TEST(test_favorite_without_a_tilt_keeps_the_favorite_tilt); + RUN_TEST(test_favorite_tilt_alone_keeps_the_favorite_lift); + RUN_TEST(test_favorite_tilt_alone_without_a_favorite_uses_where_the_shade_is); + RUN_TEST(test_favorite_tilt_on_a_flipped_shade_is_turned_round); + RUN_TEST(test_unpair_holds_prog_longer_on_a_56_bit_motor); + RUN_TEST(test_unpair_the_radio_refused_leaves_the_shade_paired); + RUN_TEST(test_rolling_code_is_set); + RUN_TEST(test_a_refused_command_is_reported); + return UNITY_END(); +} diff --git a/test/test_webcmd/test_webcmd.cpp b/test/test_webcmd/test_webcmd.cpp deleted file mode 100644 index 40c86d3..0000000 --- a/test/test_webcmd/test_webcmd.cpp +++ /dev/null @@ -1,62 +0,0 @@ -#include -#include "WebCommand.h" - -static const web_cmd_t RADIO_COMMANDS[] = { - web_cmd_t::shade_command, web_cmd_t::group_command, web_cmd_t::tilt_command, - web_cmd_t::shade_repeat, web_cmd_t::group_repeat, web_cmd_t::shade_sensor, - web_cmd_t::group_sensor, web_cmd_t::set_my_position, web_cmd_t::unpair_shade -}; - -void setUp() {} -void tearDown() {} - -static const web_cmd_t LOOP_COMMANDS[] = { - web_cmd_t::set_positions, web_cmd_t::set_rolling_code, web_cmd_t::restore_shades, web_cmd_t::load_shades -}; - -static void test_only_loop_commands_stay_off_the_radio() { - int offRadio = 0; - for(uint8_t t = 0; t <= static_cast(web_cmd_t::load_shades); t++) { - if(!webCommandUsesRadio(static_cast(t))) offRadio++; - } - TEST_ASSERT_EQUAL_INT(4, offRadio); - for(web_cmd_t t : LOOP_COMMANDS) TEST_ASSERT_FALSE(webCommandUsesRadio(t)); -} -static void test_loop_commands_are_never_held() { - // Nothing goes on air, so a full or suspended radio doesn't matter. - for(web_cmd_t t : LOOP_COMMANDS) { - TEST_ASSERT_FALSE(holdWebCommand(t, false, false)); - TEST_ASSERT_FALSE(holdWebCommand(t, true, false)); - } -} -static void test_radio_command_waits_for_room() { - for(web_cmd_t t : RADIO_COMMANDS) { - TEST_ASSERT_TRUE(holdWebCommand(t, false, false)); - TEST_ASSERT_FALSE(holdWebCommand(t, false, true)); - } -} -static void test_commands_are_not_held_while_suspended() { - // Nothing goes on air during a frequency scan or OTA anyway. - for(web_cmd_t t : RADIO_COMMANDS) { - if(t == web_cmd_t::unpair_shade) continue; - TEST_ASSERT_FALSE(holdWebCommand(t, true, false)); - TEST_ASSERT_FALSE(holdWebCommand(t, true, true)); - } -} -static void test_unpair_waits_out_a_suspend() { - // The client was already told the shade is unpaired. - TEST_ASSERT_TRUE(holdWebCommand(web_cmd_t::unpair_shade, true, false)); - TEST_ASSERT_TRUE(holdWebCommand(web_cmd_t::unpair_shade, true, true)); -} - -int main(int argc, char **argv) { - (void)argc; - (void)argv; - UNITY_BEGIN(); - RUN_TEST(test_only_loop_commands_stay_off_the_radio); - RUN_TEST(test_loop_commands_are_never_held); - RUN_TEST(test_radio_command_waits_for_room); - RUN_TEST(test_commands_are_not_held_while_suspended); - RUN_TEST(test_unpair_waits_out_a_suspend); - return UNITY_END(); -}