refactor: carry out shade requests from one module behind a LoopQueue

HTTP routes and MQTT commands now both build a shade request and submit it
to the LoopQueue, which loop() drains into ShadeRequests. ShadeRequests
finds the shade or group, turns client positions into stored ones, checks
ranges and fills in default repeats, and reaches the shades through the
ShadeOps seam: SomfyShadeOps on the device, a recording fake in the native
tests. MqttCommands turns a .../set topic into a shade request.

Behaviour that changes:
- MQTT tiltTarget, mypos, myTiltPos and tiltPosition, and /setPositions,
  now honour flipPosition like the other routes.
- MQTT mypos keeps the favorite tilt (was set to 0) and myTiltPos keeps
  the favorite lift.
- repeat 0 on /shadeCommand means no repeats, as it did for groups; a
  JSON repeat over 127 is capped instead of wrapping to the default.
- Position corrections go through the setters, so MQTT publishes them.
- MQTT commands wait while the radio is full instead of being refused.
- The queue is drained every loop() pass and no longer from inside a
  GitHub firmware download.

WebCommand.h is replaced by LoopQueue.h and ShadeRequests.h. 45 new native
tests, 271 in all. Flash: esp32devdbg +976 bytes, esp32dev +1,380 bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-16 09:36:46 +02:00
parent 5fd5feda28
commit 395cb2d1c7
20 changed files with 1354 additions and 569 deletions

View file

@ -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. - `/linkToGroup` and `/unlinkFromGroup` change a group's linked shades.
- `/setPaired` sets `paired`. - `/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). - `/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 ## Oversized `/controller` items are left out

View file

@ -1,6 +1,6 @@
# Plan: domain modules behind the LoopQueue # 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). **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 ## 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 | | 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 | | 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 ### 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: 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`. 1. Take a backup through `/backup`.

View file

@ -109,7 +109,7 @@ lib_deps =
platform = native platform = native
test_framework = unity test_framework = unity
test_build_src = yes test_build_src = yes
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp>
build_flags = build_flags =
-std=gnu++17 -std=gnu++17
-Isrc -Isrc

66
src/LoopQueue.cpp Normal file
View file

@ -0,0 +1,66 @@
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#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<QueueHandle_t>(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<QueueHandle_t>(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<uint8_t>(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;
}
}
}

58
src/LoopQueue.h Normal file
View file

@ -0,0 +1,58 @@
#ifndef LOOP_QUEUE_H
#define LOOP_QUEUE_H
#include <stdint.h>
#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

89
src/ShadeRequests.cpp Normal file
View file

@ -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<int8_t>(repeat);
}
int8_t requestTarget(int32_t target) { return target >= 0 && target <= 100 ? static_cast<int8_t>(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<float>(pos), flip); }
static uint8_t repeatsOr(int8_t repeat, uint8_t own) { return repeat >= 0 ? static_cast<uint8_t>(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;
}

101
src/ShadeRequests.h Normal file
View file

@ -0,0 +1,101 @@
#ifndef SHADE_REQUESTS_H
#define SHADE_REQUESTS_H
#include <stdint.h>
#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

View file

@ -9,6 +9,9 @@
#include "Utils.h" #include "Utils.h"
#include "Somfy.h" #include "Somfy.h"
#include "entryPoints/MQTT.h" #include "entryPoints/MQTT.h"
#include "LoopQueue.h"
#include "ShadeRequests.h"
#include "SomfyShadeOps.h"
#include "GitOTA.h" #include "GitOTA.h"
#include "esp_core_dump.h" #include "esp_core_dump.h"
#include "esp_system.h" #include "esp_system.h"
@ -33,6 +36,9 @@ rebootDelay_t rebootDelay;
SomfyShadeController somfy; SomfyShadeController somfy;
MQTTClass mqtt; MQTTClass mqtt;
GitUpdater git; GitUpdater git;
SomfyShadeOps shadeOps;
ShadeRequests shadeRequests(shadeOps);
LoopQueue loopQueue;
uint32_t oldheap = 0; uint32_t oldheap = 0;
@ -112,6 +118,8 @@ void setup() {
settings.begin(); settings.begin();
if(WiFi.status() == WL_CONNECTED) WiFi.disconnect(true); if(WiFi.status() == WL_CONNECTED) WiFi.disconnect(true);
delay(10); delay(10);
// Before anything that can submit to it: the web servers and MQTT.
loopQueue.begin();
webServer.startup(); webServer.startup();
webServer.begin(); webServer.begin();
delay(1000); delay(1000);
@ -145,6 +153,9 @@ void loop() {
somfy.loop(); somfy.loop();
logIfSlow(TAG, "Loop somfy", timing.lap(), LOOP_SLOW_MS); logIfSlow(TAG, "Loop somfy", timing.lap(), LOOP_SLOW_MS);
esp_task_wdt_reset(); 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(net.connected() || net.softAPOpened) {
if(!rebootDelay.reboot && net.connected() && !net.softAPOpened) { if(!rebootDelay.reboot && net.connected() && !net.softAPOpened) {
git.loop(); git.loop();

70
src/SomfyShadeOps.cpp Normal file
View file

@ -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;
}

27
src/SomfyShadeOps.h Normal file
View file

@ -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

View file

@ -1,75 +0,0 @@
#ifndef WEB_COMMAND_H
#define WEB_COMMAND_H
#include <stdint.h>
#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

View file

@ -5,7 +5,9 @@
#include "esp_log.h" #include "esp_log.h"
#include "ConfigSettings.h" #include "ConfigSettings.h"
#include "MQTT.h" #include "MQTT.h"
#include "MqttCommands.h"
#include "Somfy.h" #include "Somfy.h"
#include "LoopQueue.h"
#include "ESPNetwork.h" #include "ESPNetwork.h"
#include "Utils.h" #include "Utils.h"
@ -21,6 +23,7 @@ extern ConfigSettings settings;
extern SomfyShadeController somfy; extern SomfyShadeController somfy;
extern ESPNetwork net; extern ESPNetwork net;
extern rebootDelay_t rebootDelay; extern rebootDelay_t rebootDelay;
extern LoopQueue loopQueue;
bool MQTTClass::begin() { bool MQTTClass::begin() {
@ -49,131 +52,14 @@ bool MQTTClass::loop() {
void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) { void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) {
esp_task_wdt_reset(); // Make sure we do not reboot here. 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); ESP_LOGD(TAG, "MQTT Topic:%s payload:%.*s", topic, (int)length, (const char *)payload);
// This already runs on loop(), but going through the loop queue keeps MQTT
// We need to start at the last slash in the data // and web requests in order and holds them while the radio is full.
uint8_t len = strlen(topic); loop_item_t item;
if(!parseMqttCommand(topic, payload, length, item.request)) {
uint8_t slashes = 0; ESP_LOGW(TAG, "MQTT command not understood: %s", topic);
uint16_t ndx = strlen(topic) - 1; return;
while(ndx > 0) {
if(topic[ndx] == '/') slashes++;
if(slashes == 4) break;
ndx--;
} }
char entityId[4]; loopQueue.submit(item);
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.
} }
bool MQTTClass::connect() { bool MQTTClass::connect() {
esp_task_wdt_reset(); // Make sure we do not reboot here. esp_task_wdt_reset(); // Make sure we do not reboot here.

View file

@ -0,0 +1,126 @@
#include <stdlib.h>
#include <string.h>
#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<uint8_t>(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<int8_t>(val);
}
else if(cmd.is("tiltTarget")) {
if(!inRange(val)) return false;
r.type = shade_request_type_t::tilt_command;
r.target = static_cast<int8_t>(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<int8_t>(val);
}
else if(cmd.is("myTiltPos")) {
if(!inRange(val)) return false;
r.type = shade_request_type_t::set_favorite;
r.tiltPosition = static_cast<int8_t>(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<int8_t>(val);
}
else if(cmd.is("tiltPosition")) {
if(!inRange(val)) return false;
r.type = shade_request_type_t::set_positions;
r.tiltPosition = static_cast<int8_t>(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;
}

View file

@ -0,0 +1,14 @@
#ifndef MQTT_COMMANDS_H
#define MQTT_COMMANDS_H
#include <stddef.h>
#include <stdint.h>
#include "ShadeRequests.h"
// The MQTT command topics, <root>/shades/<id>/<command>/set and
// <root>/groups/<id>/<command>/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

View file

@ -10,6 +10,7 @@
#include "SSDP.h" #include "SSDP.h"
#include "Somfy.h" #include "Somfy.h"
#include "WResp.h" #include "WResp.h"
#include "LoopQueue.h"
#include "Web.h" #include "Web.h"
#include "MQTT.h" #include "MQTT.h"
#include "GitOTA.h" #include "GitOTA.h"
@ -28,6 +29,7 @@ extern Web webServer;
extern MQTTClass mqtt; extern MQTTClass mqtt;
extern GitUpdater git; extern GitUpdater git;
extern ESPNetwork net; extern ESPNetwork net;
extern LoopQueue loopQueue;
//#define WEB_MAX_RESPONSE 34768 //#define WEB_MAX_RESPONSE 34768
#define WEB_MAX_RESPONSE 4096 #define WEB_MAX_RESPONSE 4096
@ -47,61 +49,53 @@ static const char _encoding_json[] = "application/json";
static const char *TAG = "Web"; static const char *TAG = "Web";
static QueueHandle_t webCmdQueue = nullptr;
static void sendQueueFull(AsyncWebServerRequest *request); static void sendQueueFull(AsyncWebServerRequest *request);
static void sendRadioUnavailable(AsyncWebServerRequest *request); static void sendRadioUnavailable(AsyncWebServerRequest *request);
// Hands a command to the main loop, which owns the shades. When it can't be // Hands an item to loop(), which owns the shades. When it can't be the
// the request is answered with the reason and false is returned. // request is answered with the reason and false is returned.
static bool queueLoopCommand(AsyncWebServerRequest *request, const web_command_t &cmd) { 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 // Nothing can go on air during a frequency scan so tell the client now rather
// than dropping the command once it reaches the radio. // than dropping the request once it reaches the radio.
if(webCommandUsesRadio(cmd.type) && somfy.transceiver.txSuspended()) { sendRadioUnavailable(request); return false; } if(loopItemUsesRadio(item) && somfy.transceiver.txSuspended()) { sendRadioUnavailable(request); return false; }
if(!webServer.queueCommand(cmd)) { sendQueueFull(request); return false; } if(!loopQueue.submit(item)) { sendQueueFull(request); return false; }
return true; 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) { static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) {
web_command_t cmd = {}; shade_request_t req;
cmd.type = web_cmd_t::set_my_position; req.type = shade_request_type_t::set_favorite;
cmd.shadeId = shade->getShadeId(); req.id = shade->getShadeId();
cmd.position = pos; req.position = pos;
cmd.tiltPosition = tilt; req.tiltPosition = tilt;
return queueLoopCommand(request, cmd); return queueShadeRequest(request, req);
} }
// The main loop clears paired and saves it once the Prog is queued. The // loop() clears paired and saves it once the Prog is queued. The shade
// shade belongs to the main loop so it isn't changed here. // belongs to loop() so it isn't changed here.
static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) { static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) {
web_command_t cmd = {}; shade_request_t req;
cmd.type = web_cmd_t::unpair_shade; req.type = shade_request_type_t::unpair;
cmd.shadeId = shade->getShadeId(); req.id = shade->getShadeId();
return queueLoopCommand(request, cmd); return queueShadeRequest(request, req);
} }
// Sending a command reads and commits the rolling code on the main loop, so // Sending a command reads and commits the rolling code on loop(), so a new
// a new code is set there too. // code is set there too.
static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) { static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) {
web_command_t cmd = {}; shade_request_t req;
cmd.type = web_cmd_t::set_rolling_code; req.type = shade_request_type_t::set_rolling_code;
cmd.shadeId = shade->getShadeId(); req.id = shade->getShadeId();
cmd.rollingCode = rollingCode; req.rollingCode = rollingCode;
return queueLoopCommand(request, cmd); 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) { static uint8_t restoreParts(const restore_options_t &opts) {
return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) | return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) |
(opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) | (opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) |
(opts.repeaters ? RESTORE_REPEATERS : 0) | (opts.mqtt ? RESTORE_MQTT : 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 // 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 // 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. // client goes away mid-upload, so make sure the device reboots then too.
@ -125,7 +119,6 @@ AsyncWebServer asyncServer(80);
AsyncWebServer asyncApiServer(8081); AsyncWebServer asyncApiServer(8081);
void Web::startup() { void Web::startup() {
ESP_LOGI(TAG, "Launching web server..."); 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) { asyncServer.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) {
AsyncJsonResponse *response = new AsyncJsonResponse(); AsyncJsonResponse *response = new AsyncJsonResponse();
@ -143,128 +136,8 @@ void Web::startup() {
ESP_LOGI(TAG, "Async API server started on port 8081"); ESP_LOGI(TAG, "Async API server started on port 8081");
} }
void Web::loop() { void Web::loop() {
this->processQueue();
delay(1); 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<uint8_t>(cmd.type));
}
}
bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) { bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) {
ESP_LOGD(TAG, "Checking async authentication"); ESP_LOGD(TAG, "Checking async authentication");
if(settings.Security.type == security_types::None) return true; 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 // 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 // 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) { static void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) {
json.addElem("shadeId", shade->getShadeId()); json.addElem("shadeId", shade->getShadeId());
json.addElem("roomId", shade->roomId); json.addElem("roomId", shade->roomId);
@ -898,40 +771,30 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json) { void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return; if(!this->isAuthenticated(request)) return;
uint8_t shadeId = 255; shade_request_t req;
uint8_t target = 255; req.type = shade_request_type_t::shade_command;
uint8_t stepSize = 0;
int8_t repeat = -1;
somfy_commands command = somfy_commands::My;
// Try query params // Try query params
if(asyncHasParam(request, "shadeId")) { if(asyncHasParam(request, "shadeId")) {
shadeId = asyncParam(request, "shadeId").toInt(); req.id = asyncParam(request, "shadeId").toInt();
if(asyncHasParam(request, "command")) command = translateSomfyCommand(asyncParam(request, "command")); if(asyncHasParam(request, "command")) req.command = translateSomfyCommand(asyncParam(request, "command"));
else if(asyncHasParam(request, "target")) target = asyncParam(request, "target").toInt(); else if(asyncHasParam(request, "target")) req.target = requestTarget(asyncParam(request, "target").toInt());
if(asyncHasParam(request, "repeat")) repeat = asyncParam(request, "repeat").toInt(); if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt());
if(asyncHasParam(request, "stepSize")) stepSize = asyncParam(request, "stepSize").toInt(); if(asyncHasParam(request, "stepSize")) req.stepSize = asyncParam(request, "stepSize").toInt();
} }
else if(!json.isNull()) { else if(!json.isNull()) {
JsonObject obj = json.as<JsonObject>(); JsonObject obj = json.as<JsonObject>();
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; } 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); } if(!obj["command"].isNull()) { String scmd = obj["command"]; req.command = translateSomfyCommand(scmd); }
else if(!obj["target"].isNull()) target = obj["target"].as<uint8_t>(); else if(!obj["target"].isNull()) req.target = requestTarget(obj["target"].as<int>());
if(!obj["repeat"].isNull()) repeat = obj["repeat"].as<uint8_t>(); if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as<int>());
if(!obj["stepSize"].isNull()) stepSize = obj["stepSize"].as<uint8_t>(); if(!obj["stepSize"].isNull()) req.stepSize = obj["stepSize"].as<uint8_t>();
} }
else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } 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) { if(shade) {
ESP_LOGI(TAG, "handleShadeCommand shade=%u target=%u command=%s", shadeId, target, translateSomfyCommand(command).c_str()); ESP_LOGI(TAG, "handleShadeCommand shade=%u target=%d command=%s", req.id, req.target, translateSomfyCommand(req.command).c_str());
web_command_t cmd = {}; if(!queueShadeRequest(request, req)) return;
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;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -944,35 +807,27 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json)
void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) { void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return; if(!this->isAuthenticated(request)) return;
uint8_t groupId = 255; shade_request_t req;
uint8_t stepSize = 0; req.type = shade_request_type_t::group_command;
int8_t repeat = -1;
somfy_commands command = somfy_commands::My;
if(asyncHasParam(request, "groupId")) { if(asyncHasParam(request, "groupId")) {
groupId = asyncParam(request, "groupId").toInt(); req.id = asyncParam(request, "groupId").toInt();
if(asyncHasParam(request, "command")) command = translateSomfyCommand(asyncParam(request, "command")); if(asyncHasParam(request, "command")) req.command = translateSomfyCommand(asyncParam(request, "command"));
if(asyncHasParam(request, "repeat")) repeat = asyncParam(request, "repeat").toInt(); if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt());
if(asyncHasParam(request, "stepSize")) stepSize = asyncParam(request, "stepSize").toInt(); if(asyncHasParam(request, "stepSize")) req.stepSize = asyncParam(request, "stepSize").toInt();
} }
else if(!json.isNull()) { else if(!json.isNull()) {
JsonObject obj = json.as<JsonObject>(); JsonObject obj = json.as<JsonObject>();
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; } 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["command"].isNull()) { String scmd = obj["command"]; req.command = translateSomfyCommand(scmd); }
if(!obj["repeat"].isNull()) repeat = obj["repeat"].as<uint8_t>(); if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as<int>());
if(!obj["stepSize"].isNull()) stepSize = obj["stepSize"].as<uint8_t>(); if(!obj["stepSize"].isNull()) req.stepSize = obj["stepSize"].as<uint8_t>();
} }
else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); return; } 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) { if(group) {
ESP_LOGI(TAG, "handleGroupCommand group=%u command=%s", groupId, translateSomfyCommand(command).c_str()); ESP_LOGI(TAG, "handleGroupCommand group=%u command=%s", req.id, translateSomfyCommand(req.command).c_str());
web_command_t cmd = {}; if(!queueShadeRequest(request, req)) return;
cmd.type = web_cmd_t::group_command;
cmd.groupId = groupId;
cmd.command = command;
cmd.repeat = repeat;
cmd.stepSize = stepSize;
if(!queueLoopCommand(request, cmd)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -985,31 +840,25 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json)
void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) { void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return; if(!this->isAuthenticated(request)) return;
uint8_t shadeId = 255; shade_request_t req;
uint8_t target = 255; req.type = shade_request_type_t::tilt_command;
somfy_commands command = somfy_commands::My;
if(asyncHasParam(request, "shadeId")) { if(asyncHasParam(request, "shadeId")) {
shadeId = asyncParam(request, "shadeId").toInt(); req.id = asyncParam(request, "shadeId").toInt();
if(asyncHasParam(request, "command")) command = translateSomfyCommand(asyncParam(request, "command")); if(asyncHasParam(request, "command")) req.command = translateSomfyCommand(asyncParam(request, "command"));
else if(asyncHasParam(request, "target")) target = asyncParam(request, "target").toInt(); else if(asyncHasParam(request, "target")) req.target = requestTarget(asyncParam(request, "target").toInt());
} }
else if(!json.isNull()) { else if(!json.isNull()) {
JsonObject obj = json.as<JsonObject>(); JsonObject obj = json.as<JsonObject>();
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; } 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); } if(!obj["command"].isNull()) { String scmd = obj["command"]; req.command = translateSomfyCommand(scmd); }
else if(!obj["target"].isNull()) target = obj["target"].as<uint8_t>(); else if(!obj["target"].isNull()) req.target = requestTarget(obj["target"].as<int>());
} }
else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } 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) { if(shade) {
ESP_LOGI(TAG, "handleTiltCommand shade=%u target=%u command=%s", shadeId, target, translateSomfyCommand(command).c_str()); ESP_LOGI(TAG, "handleTiltCommand shade=%u target=%d command=%s", req.id, req.target, translateSomfyCommand(req.command).c_str());
web_command_t cmd = {}; if(!queueShadeRequest(request, req)) return;
cmd.type = web_cmd_t::tilt_command;
cmd.shadeId = shadeId;
cmd.target = target;
cmd.command = command;
if(!queueLoopCommand(request, cmd)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1024,33 +873,27 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json)
if(!this->isAuthenticated(request)) return; if(!this->isAuthenticated(request)) return;
uint8_t shadeId = 255; uint8_t shadeId = 255;
uint8_t groupId = 255; uint8_t groupId = 255;
uint8_t stepSize = 0; shade_request_t req;
int8_t repeat = -1;
somfy_commands command = somfy_commands::My;
if(asyncHasParam(request, "shadeId")) shadeId = asyncParam(request, "shadeId").toInt(); if(asyncHasParam(request, "shadeId")) shadeId = asyncParam(request, "shadeId").toInt();
else if(asyncHasParam(request, "groupId")) groupId = asyncParam(request, "groupId").toInt(); else if(asyncHasParam(request, "groupId")) groupId = asyncParam(request, "groupId").toInt();
if(asyncHasParam(request, "command")) command = translateSomfyCommand(asyncParam(request, "command")); if(asyncHasParam(request, "command")) req.command = translateSomfyCommand(asyncParam(request, "command"));
if(asyncHasParam(request, "repeat")) repeat = asyncParam(request, "repeat").toInt(); if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt());
if(asyncHasParam(request, "stepSize")) stepSize = asyncParam(request, "stepSize").toInt(); if(asyncHasParam(request, "stepSize")) req.stepSize = asyncParam(request, "stepSize").toInt();
if(shadeId == 255 && groupId == 255 && !json.isNull()) { if(shadeId == 255 && groupId == 255 && !json.isNull()) {
JsonObject obj = json.as<JsonObject>(); JsonObject obj = json.as<JsonObject>();
if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"];
if(!obj["groupId"].isNull()) groupId = obj["groupId"]; if(!obj["groupId"].isNull()) groupId = obj["groupId"];
if(!obj["stepSize"].isNull()) stepSize = obj["stepSize"]; if(!obj["stepSize"].isNull()) req.stepSize = obj["stepSize"];
if(!obj["command"].isNull()) { String scmd = obj["command"]; command = translateSomfyCommand(scmd); } if(!obj["command"].isNull()) { String scmd = obj["command"]; req.command = translateSomfyCommand(scmd); }
if(!obj["repeat"].isNull()) repeat = obj["repeat"].as<uint8_t>(); if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as<int>());
} }
if(shadeId != 255) { 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); SomfyShade *shade = somfy.getShadeById(shadeId);
if(!shade) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade reference could not be found.\"}")); return; } if(!shade) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade reference could not be found.\"}")); return; }
web_command_t cmd = {}; req.type = shade_request_type_t::shade_repeat;
cmd.type = web_cmd_t::shade_repeat; req.id = shadeId;
cmd.shadeId = shadeId; if(!queueShadeRequest(request, req)) return;
cmd.command = command;
cmd.repeat = repeat;
cmd.stepSize = stepSize;
if(!queueLoopCommand(request, cmd)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1059,16 +902,12 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json)
resp.endResponse(); resp.endResponse();
} }
else if(groupId != 255) { 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); SomfyGroup *group = somfy.getGroupById(groupId);
if(!group) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group reference could not be found.\"}")); return; } if(!group) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group reference could not be found.\"}")); return; }
web_command_t cmd = {}; req.type = shade_request_type_t::group_repeat;
cmd.type = web_cmd_t::group_repeat; req.id = groupId;
cmd.groupId = groupId; if(!queueShadeRequest(request, req)) return;
cmd.command = command;
cmd.repeat = repeat;
cmd.stepSize = stepSize;
if(!queueLoopCommand(request, cmd)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1144,27 +983,24 @@ void Web::handleGroup(AsyncWebServerRequest *request) {
void Web::handleSetPositions(AsyncWebServerRequest *request, JsonVariant &json) { void Web::handleSetPositions(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return; if(!this->isAuthenticated(request)) return;
uint8_t shadeId = asyncHasParam(request, "shadeId") ? asyncParam(request, "shadeId").toInt() : 255; shade_request_t req;
int8_t pos = asyncHasParam(request, "position") ? asyncParam(request, "position").toInt() : -1; req.type = shade_request_type_t::set_positions;
int8_t tiltPos = asyncHasParam(request, "tiltPosition") ? asyncParam(request, "tiltPosition").toInt() : -1; 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()) { if(!json.isNull()) {
JsonObject obj = json.as<JsonObject>(); JsonObject obj = json.as<JsonObject>();
if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; if(!obj["shadeId"].isNull()) req.id = obj["shadeId"];
if(!obj["position"].isNull()) pos = obj["position"]; if(!obj["position"].isNull()) req.position = requestTarget(obj["position"].as<int>());
if(!obj["tiltPosition"].isNull()) tiltPos = obj["tiltPosition"]; if(!obj["tiltPosition"].isNull()) req.tiltPosition = requestTarget(obj["tiltPosition"].as<int>());
} }
if(shadeId != 255) { if(req.id != 255) {
ESP_LOGI(TAG, "handleSetPositions shade=%u pos=%d tiltPos=%d", shadeId, pos, tiltPos); ESP_LOGI(TAG, "handleSetPositions shade=%u pos=%d tiltPos=%d", req.id, req.position, req.tiltPosition);
SomfyShade *shade = somfy.getShadeById(shadeId); SomfyShade *shade = somfy.getShadeById(req.id);
if(shade) { if(shade) {
web_command_t cmd = {}; if(!queueShadeRequest(request, req)) return;
cmd.type = web_cmd_t::set_positions; // loop() applies the request after we answer so this is the state before
cmd.shadeId = shadeId; // it. Clients get the result from the shadeState socket event.
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.
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1181,33 +1017,30 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) {
if(!this->isAuthenticated(request)) return; if(!this->isAuthenticated(request)) return;
uint8_t shadeId = asyncHasParam(request, "shadeId") ? asyncParam(request, "shadeId").toInt() : 255; uint8_t shadeId = asyncHasParam(request, "shadeId") ? asyncParam(request, "shadeId").toInt() : 255;
uint8_t groupId = asyncHasParam(request, "groupId") ? asyncParam(request, "groupId").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; shade_request_t req;
int8_t windy = asyncHasParam(request, "windy") ? asyncParam(request, "windy").toInt() : -1; if(asyncHasParam(request, "sunny")) req.sunny = toBoolean(asyncParam(request, "sunny").c_str(), false) ? 1 : 0;
int8_t repeat = asyncHasParam(request, "repeat") ? asyncParam(request, "repeat").toInt() : -1; if(asyncHasParam(request, "windy")) req.windy = asyncParam(request, "windy").toInt();
if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt());
if(!json.isNull()) { if(!json.isNull()) {
JsonObject obj = json.as<JsonObject>(); JsonObject obj = json.as<JsonObject>();
if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"].as<uint8_t>(); if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"].as<uint8_t>();
if(!obj["groupId"].isNull()) groupId = obj["groupId"].as<uint8_t>(); if(!obj["groupId"].isNull()) groupId = obj["groupId"].as<uint8_t>();
if(!obj["sunny"].isNull()) { if(!obj["sunny"].isNull()) {
if(obj["sunny"].is<bool>()) sunny = obj["sunny"].as<bool>() ? 1 : 0; if(obj["sunny"].is<bool>()) req.sunny = obj["sunny"].as<bool>() ? 1 : 0;
else sunny = obj["sunny"].as<int8_t>(); else req.sunny = obj["sunny"].as<int8_t>();
} }
if(!obj["windy"].isNull()) { if(!obj["windy"].isNull()) {
if(obj["windy"].is<bool>()) windy = obj["windy"].as<bool>() ? 1 : 0; if(obj["windy"].is<bool>()) req.windy = obj["windy"].as<bool>() ? 1 : 0;
else windy = obj["windy"].as<int8_t>(); else req.windy = obj["windy"].as<int8_t>();
} }
if(!obj["repeat"].isNull()) repeat = obj["repeat"].as<uint8_t>(); if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as<int>());
} }
if(shadeId != 255) { if(shadeId != 255) {
SomfyShade *shade = somfy.getShadeById(shadeId); SomfyShade *shade = somfy.getShadeById(shadeId);
if(shade) { if(shade) {
web_command_t cmd = {}; req.type = shade_request_type_t::shade_sensor;
cmd.type = web_cmd_t::shade_sensor; req.id = shadeId;
cmd.shadeId = shadeId; if(!queueShadeRequest(request, req)) return;
cmd.sunny = sunny;
cmd.windy = windy;
cmd.repeat = repeat;
if(!queueLoopCommand(request, cmd)) return;
// The main loop applies the command after we answer so this is the state // 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. // before it. Clients get the result from the shadeState socket event.
AsyncJsonResp resp; AsyncJsonResp resp;
@ -1222,13 +1055,9 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) {
else if(groupId != 255) { else if(groupId != 255) {
SomfyGroup *group = somfy.getGroupById(groupId); SomfyGroup *group = somfy.getGroupById(groupId);
if(group) { if(group) {
web_command_t cmd = {}; req.type = shade_request_type_t::group_sensor;
cmd.type = web_cmd_t::group_sensor; req.id = groupId;
cmd.groupId = groupId; if(!queueShadeRequest(request, req)) return;
cmd.sunny = sunny;
cmd.windy = windy;
cmd.repeat = repeat;
if(!queueLoopCommand(request, cmd)) return;
// As above, this is the state before the command and groupState follows. // As above, this is the state before the command and groupState follows.
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); 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 // The main loop restores, since it owns the shades and their rolling
// codes, and then reboots. // codes, and then reboots.
web_command_t cmd = {}; loop_item_t item;
cmd.type = web_cmd_t::restore_shades; item.type = loop_item_type_t::restore_shades;
cmd.restoreParts = restoreParts(opts); item.restoreParts = restoreParts(opts);
if(!queueLoopCommand(request, cmd)) return; if(!queueLoopItem(request, item)) return;
request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}"); request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}");
} }
}, },
@ -1776,9 +1605,8 @@ void Web::begin() {
} }
SomfyShade* shade = somfy.getShadeById(shadeId); SomfyShade* shade = somfy.getShadeById(shadeId);
if(shade) { if(shade) {
tilt = requestedFavoriteTilt(tilt, shade->tiltType, shade->myTiltPos, shade->flipPosition);
if(pos >= 0 && pos <= 100) 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; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1802,9 +1630,8 @@ void Web::begin() {
else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; }
SomfyShade* shade = somfy.getShadeById(shadeId); SomfyShade* shade = somfy.getShadeById(shadeId);
if(shade) { if(shade) {
tilt = requestedFavoriteTilt(tilt, shade->tiltType, shade->myTiltPos, shade->flipPosition);
if(pos >= 0 && pos <= 100) 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; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -2326,9 +2153,9 @@ void Web::begin() {
} }
if(final) { if(final) {
// The main loop loads it, since it owns the shades. // The main loop loads it, since it owns the shades.
web_command_t cmd = {}; loop_item_t item;
cmd.type = web_cmd_t::load_shades; item.type = loop_item_type_t::load_shades;
webServer.queueCommand(cmd); loopQueue.submit(item);
} }
}); });

View file

@ -1,9 +1,6 @@
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <AsyncJson.h> #include <AsyncJson.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include "Somfy.h" #include "Somfy.h"
#include "WebCommand.h"
#ifndef webserver_h #ifndef webserver_h
#define webserver_h #define webserver_h
@ -40,8 +37,5 @@ class Web {
void handleBackup(AsyncWebServerRequest *request); void handleBackup(AsyncWebServerRequest *request);
void handleReboot(AsyncWebServerRequest *request); void handleReboot(AsyncWebServerRequest *request);
void handleNotFound(AsyncWebServerRequest *request); void handleNotFound(AsyncWebServerRequest *request);
bool queueCommand(const web_command_t &cmd);
private:
void processQueue();
}; };
#endif #endif

View file

@ -0,0 +1,78 @@
#include <unity.h>
#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<uint8_t>(shade_request_type_t::set_rolling_code); t++) {
if(!shadeRequestUsesRadio(static_cast<shade_request_type_t>(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();
}

View file

@ -0,0 +1,165 @@
#include <unity.h>
#include <string.h>
#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<const uint8_t *>(value), strlen(value), req);
}
static void assertType(shade_request_type_t expected) {
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(expected), static_cast<uint8_t>(req.type));
}
static void assertCommand(somfy_commands expected) {
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(expected), static_cast<uint8_t>(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<const uint8_t *>(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();
}

View file

@ -0,0 +1,398 @@
#include <unity.h>
#include <map>
#include <string>
#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<uint8_t, shade_info_t> shades;
std::map<uint8_t, uint8_t> 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<typename... Args>
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();
}

View file

@ -1,62 +0,0 @@
#include <unity.h>
#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<uint8_t>(web_cmd_t::load_shades); t++) {
if(!webCommandUsesRadio(static_cast<web_cmd_t>(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();
}