Merge pull request #40 from cjkas/refactor/shade-requests

refactor: carry out shade requests from one module behind a LoopQueue
This commit is contained in:
cjkas 2026-10-16 14:04:26 +02:00 • committed by GitHub
commit ddc5e3b941
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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

@ -113,7 +113,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();
}