Merge pull request #35 from cjkas/fix/radio-tx-task

Move radio TX to a dedicated task so the HA WebSocket stays connected
This commit is contained in:
cjkas 2026-10-12 21:00:30 +02:00 • committed by GitHub
commit 125a758d1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 4720 additions and 1264 deletions

View file

@ -8,7 +8,28 @@ on:
types: [published]
jobs:
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install PlatformIO
run: pip install platformio
- name: Run native unit tests
run: pio test -e native
build:
# Don't build or publish firmware that fails its tests.
needs: test
permissions: write-all
name: ${{ matrix.name }}
runs-on: ubuntu-latest

32
GLOSSARY.md Normal file
View file

@ -0,0 +1,32 @@
# Glossary
Domain terms used in the code, commits and design discussions.
## Radio
**Radio job**
One transmission handed to the radio task: an encoded first frame plus a number of repeat frames. A job is either *our own* (a command or sensor frame from one of our remotes, with outcome events) or a *repeater frame* (a frame received from someone else's remote and sent again). A repeater frame reports no events, and since it carries none of our rolling codes it never holds back a stop.
**RadioScheduler**
The module that owns the radio jobs waiting for, or on, the air. It decides their order, when a delayed job is due, which jobs a flush or suspend drops, and the outcome of every job. Producers hand it jobs and get a ticket back. The radio task asks it for the next job and reports back how the job went. It knows nothing about FreeRTOS, the CC1101 or shades.
**Ticket**
What submitting a radio job returns: the job's sequence number, 0 when the radio refused it. Every event about the job carries it. A command that went out over GPIO, not the radio, has ticket 0.
**Back job / front job**
A job normally joins the back of the queue. A *priority* job (a stop) goes to the front, unless an earlier job from the same remote is still waiting. Overtaking that job would put the remote's rolling codes out of order, and the motor would reject the older frame.
**Delayed job**
A job that must not go on air before a given time (for example the long press that stores a favorite). It holds up the jobs behind it, but front jobs may overtake it while it waits.
**Flush**
Drops every waiting job (each gets a *dropped* event) and stops the job on air after its current frame (it gets *aborted*).
**Suspend**
Stops the radio taking jobs for a reason (frequency scan, OTA). Suspending flushes; while suspended, new jobs are refused.
**Job outcome**
Every job we submit gets exactly one *started* event, unless it is dropped before going on air. It then ends with exactly one of *finished* (every frame went on air), *aborted* (cut short) or *dropped* (never went on air).
**Rolling code**
The per-remote counter in every Somfy frame. A motor ignores any frame whose code is not newer than the last one it accepted, so a remote's frames must go on air in the order their codes were assigned.

View file

@ -12,9 +12,13 @@
default_envs = esp32devdbg
src_dir = src
[env]
; Shared by every firmware env (they extend it). Kept out of [env] so the
; native test env doesn't inherit the Arduino framework and ESP32 libraries.
[esp32]
platform = espressif32 @ 6.13.0
framework = arduino
; Unit tests only run on the PC: pio test -e native
test_ignore = *
lib_deps =
bblanchon/ArduinoJson@^7.2.2
lsatan/SmartRC-CC1101-Driver-Lib@^2.5.7
@ -33,35 +37,53 @@ build_flags =
-DCONFIG_ESP_COREDUMP_CHECKSUM_CRC32=1
-DCONFIG_ESP_TASK_WDT_PANIC=1
-DCONFIG_ESP_COREDUMP_DECODE_INFO=1
; Route log_printfv through src/LogLock.cpp so lines from both cores don't
; interleave. The core already wraps log_printf for ESP Insights.
-Wl,--wrap=log_printfv
monitor_speed = 115200
monitor_filters =
time
esp32_exception_decoder
log2file
; Dual-core targets: keep the async web/socket task on core 0 so the radio
; task on core 1 (with loop()) never competes with network handling.
[dualcore]
build_flags =
${esp32.build_flags}
-DCONFIG_ASYNC_TCP_RUNNING_CORE=0
[env:esp32dev]
extends = esp32
board = esp32dev
board_build.partitions = esp32_3MB.csv
build_flags = ${dualcore.build_flags}
[env:esp32devdbg]
extends = esp32
board = esp32dev
build_type = debug
board_build.partitions = esp32_3MB.csv
build_flags = ${dualcore.build_flags}
[env:esp32c3]
extends = esp32
board = esp32-c3-devkitm-1
board_build.partitions = esp32_3MB.csv
[env:esp32s3]
extends = esp32
board = esp32-s3-devkitc-1
build_flags = ${dualcore.build_flags}
[env:esp32c6]
extends = esp32
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
board = seeed_xiao_esp32c6
build_type = debug
board_build.partitions = esp32_huge_app.csv
build_flags =
${env.build_flags}
build_flags =
${esp32.build_flags}
-DCONFIG_FREERTOS_HZ=1000
-include "driver/gpio.h"
lib_deps =
@ -76,3 +98,15 @@ lib_deps =
; High-level wrappers/servers
ESP32Async/ESPAsyncWebServer
; Host-side unit tests: pio test -e native (needs gcc/g++ on PATH).
; Only the sources the tests cover are built; test/stubs stands in for Arduino.h.
[env:native]
platform = native
test_framework = unity
test_build_src = yes
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp>
build_flags =
-std=gnu++17
-Isrc
-Itest/stubs
-D_POSIX_THREAD_SAFE_FUNCTIONS

View file

@ -608,8 +608,10 @@ bool ShadeConfigFile::restoreFile(SomfyShadeController *s, const char *filename,
if(opts.shades) s->commit();
if(opts.transceiver)
{
this->readTransRecord(s->transceiver.config);
s->transceiver.save();
// Read into a copy so the radio task never transmits on a half restored config.
transceiver_config_t cfg = s->transceiver.config;
this->readTransRecord(cfg);
s->transceiver.setConfig(cfg);
}
if(opts.settings || opts.network) settings.save();
if(opts.settings) settings.NTP.save();

View file

@ -32,8 +32,6 @@ extern SomfyShadeController somfy;
static unsigned long _lastHeapEmit = 0;
static bool _apScanning = false;
static uint32_t _lastMaxHeap = 0;
static uint32_t _lastHeap = 0;
int connectRetries = 0;
void ESPNetwork::end() {
SSDP.end();
@ -111,11 +109,11 @@ void ESPNetwork::loop() {
if(this->getStrongestAP(settings.WIFI.ssid, bssid, &channel)) {
if(!WiFi.BSSID() || memcmp(bssid, WiFi.BSSID(), sizeof(bssid)) != 0) {
if(!this->connected()) {
ESP_LOGD(TAG, "Connecting to AP %02X:%02X:%02X:%02X:%02X:%02X CH: %d", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], channel);
ESP_LOGI(TAG, "Connecting to AP %02X:%02X:%02X:%02X:%02X:%02X CH: %d", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], channel);
this->connectWiFi(bssid, channel);
}
else {
ESP_LOGD(TAG, "Found stronger AP %02X:%02X:%02X:%02X:%02X:%02X CH: %d", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], channel);
ESP_LOGI(TAG, "Found stronger AP %02X:%02X:%02X:%02X:%02X:%02X CH: %d", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], channel);
this->changeAP(bssid, channel);
}
}
@ -181,8 +179,9 @@ bool ESPNetwork::changeAP(const uint8_t *bssid, const int32_t channel) {
}
void ESPNetwork::emitSockets() {
this->emitHeap();
if(this->needsBroadcast ||
(this->connType == conn_types_t::wifi && (abs(abs(WiFi.RSSI()) - abs(this->lastRSSI)) > 1 || WiFi.channel() != this->lastChannel))) {
if(this->needsBroadcast ||
(this->connType == conn_types_t::wifi &&
this->rssiBroadcast.due(WiFi.RSSI(), WiFi.channel(), millis(), RSSI_EMIT_MIN_DBM, RSSI_EMIT_MIN_MS))) {
this->emitSockets(255);
sockEmit.loop();
this->needsBroadcast = false;
@ -210,8 +209,7 @@ void ESPNetwork::emitSockets(uint8_t num) {
json->addElem("channel", (int32_t)this->channel);
json->endObject();
sockEmit.endEmit(num);
this->lastRSSI = WiFi.RSSI();
this->lastChannel = WiFi.channel();
this->rssiBroadcast.sent(num == 255, WiFi.RSSI(), WiFi.channel(), millis());
}
else {
JsonSockEvent *json = sockEmit.beginEmit("wifiStrength");
@ -229,11 +227,11 @@ void ESPNetwork::emitSockets(uint8_t num) {
json->addElem("fullduplex", false);
json->endObject();
sockEmit.endEmit(num);
this->lastRSSI = -100;
this->lastChannel = -1;
this->rssiBroadcast.sent(num == 255, -100, -1, millis());
}
}
this->emitHeap(num);
// A broadcast comes from emitSockets(), which has already sent memStatus.
if(num != 255) this->emitHeap(num);
}
void ESPNetwork::setConnected(conn_types_t connType) {
safe_wdt_reset();
@ -535,8 +533,9 @@ uint32_t ESPNetwork::getChipId() {
return chipId;
}
bool ESPNetwork::getStrongestAP(const char *ssid, uint8_t *bssid, int32_t *channel) {
// The new AP must be at least 10dbm greater.
int32_t strength = this->connected() ? WiFi.RSSI() + 10 : -127;
// The new AP must be at least ROAM_MIN_GAIN_DB stronger.
const bool connected = this->connected();
int32_t strength = connected ? WiFi.RSSI() + ROAM_MIN_GAIN_DB : -127;
int32_t chan = -1;
memset(bssid, 0x00, 6);
esp_task_wdt_delete(NULL);
@ -545,6 +544,14 @@ bool ESPNetwork::getStrongestAP(const char *ssid, uint8_t *bssid, int32_t *chann
esp_task_wdt_add(NULL);
for(int16_t i = 0; i < n; i++) {
if(WiFi.SSID(i).compareTo(ssid) == 0) {
// Only on the roaming scans, so the log shows whether a better AP is
// around without filling up while we scan to reconnect.
if(connected) {
const uint8_t *b = WiFi.BSSID(i);
const uint8_t *cur = WiFi.BSSID();
ESP_LOGI(TAG, "AP %02X:%02X:%02X:%02X:%02X:%02X ch %ld %ld dBm%s", b[0], b[1], b[2], b[3], b[4], b[5],
(long)WiFi.channel(i), (long)WiFi.RSSI(i), (cur && memcmp(b, cur, 6) == 0) ? " (connected)" : "");
}
if(WiFi.RSSI(i) > strength) {
strength = WiFi.RSSI(i);
memcpy(bssid, WiFi.BSSID(i), 6);
@ -661,20 +668,12 @@ void ESPNetwork::networkEvent(WiFiEvent_t event) {
}
}
void ESPNetwork::emitHeap(uint8_t num) {
bool bEmit = false;
bool bTimeEmit = millis() - _lastHeapEmit > 15000;
bool bRoomEmit = false;
bool bValEmit = false;
if(num != 255 || this->needsBroadcast) bEmit = true;
if(millis() - _lastHeapEmit > 15000) bTimeEmit = true;
uint32_t freeHeap = ESP.getFreeHeap();
uint32_t maxHeap = ESP.getMaxAllocHeap();
uint32_t minHeap = ESP.getMinFreeHeap();
if(abs((int)(freeHeap - _lastHeap)) > 1500) bValEmit = true;
if(abs((int)(maxHeap - _lastMaxHeap)) > 1500) bValEmit = true;
bRoomEmit = sockEmit.activeClients(0) > 0;
if(bValEmit) bTimeEmit = millis() - _lastHeapEmit > 7000;
if(bEmit || bTimeEmit || bRoomEmit || bValEmit) {
const bool bBroadcast = num == 255 && millis() - _lastHeapEmit >= HEAP_EMIT_MS;
const bool bRoomEmit = sockEmit.activeClients(0) > 0;
if(num != 255 || bBroadcast || bRoomEmit) {
JsonSockEvent *json = sockEmit.beginEmit("memStatus");
json->beginObject();
json->addElem("max", maxHeap);
@ -683,20 +682,11 @@ void ESPNetwork::emitHeap(uint8_t num) {
json->addElem("total", ESP.getHeapSize());
json->addElem("uptime", (uint64_t)millis());
json->endObject();
if(num == 255 && bTimeEmit && bValEmit) {
if(bBroadcast) {
sockEmit.endEmit(num);
_lastHeapEmit = millis();
_lastHeap = freeHeap;
_lastMaxHeap = maxHeap;
//Serial.printf("BROAD HEAP: Emit:%d TimeEmit:%d ValEmit:%d\n", bEmit, bTimeEmit, bValEmit);
}
else if(num != 255) {
sockEmit.endEmit(num);
//Serial.printf("TARGET HEAP %d: Emit:%d TimeEmit:%d ValEmit:%d\n", num, bEmit, bTimeEmit, bValEmit);
}
else if(bRoomEmit) {
sockEmit.endEmitRoom(0);
//Serial.printf("ROOM HEAP: Emit:%d TimeEmit:%d ValEmit:%d\n", bEmit, bTimeEmit, bValEmit);
}
else if(num != 255) sockEmit.endEmit(num);
else sockEmit.endEmitRoom(0);
}
}

View file

@ -1,5 +1,6 @@
#include <Arduino.h>
#include <WiFi.h>
#include "Utils.h"
#ifndef ESPNetwork_h
#define ESPNetwork_h
@ -8,12 +9,20 @@
#define CONNECT_TIMEOUT 20000
#define SSID_SCAN_INTERVAL 600000
// A weak signal moves a dBm or two all the time, so only broadcast real
// changes and not more often than this.
#define RSSI_EMIT_MIN_DBM 3
#define RSSI_EMIT_MIN_MS 10000
// How often memStatus is broadcast. It is also the one event that is always
// sent, so it must stay well under Home Assistant's 60s receive timeout.
#define HEAP_EMIT_MS 30000
// A roaming scan only moves to another AP this much stronger.
#define ROAM_MIN_GAIN_DB 6
class ESPNetwork {
protected:
unsigned long lastEmit = 0;
unsigned long lastMDNS = 0;
int lastRSSI = 0;
int lastChannel = 0;
RssiBroadcast rssiBroadcast;
int linkSpeed = 0;
bool _connecting = false;
public:

148
src/JsonFormatter.cpp Normal file
View file

@ -0,0 +1,148 @@
#include "JsonFormatter.h"
#include "esp_log.h"
static const char *TAG = "JSON";
void BufferedJsonFormatter::setBuffer(char *b, size_t sz) {
this->buff = b;
this->buffSize = sz;
this->buff[0] = 0;
this->_len = 0;
this->_nocomma = true;
this->_overflow = false;
this->_objects = 0;
this->_arrays = 0;
}
size_t BufferedJsonFormatter::length() const {
return this->_len;
}
void JsonFormatter::beginObject(const char *name) {
if(name && strlen(name) > 0) this->appendElem(name);
else if(!this->_nocomma) this->_safecat(",");
this->_safecat("{");
this->_objects++;
this->_nocomma = true;
}
void JsonFormatter::endObject() {
//if(strlen(this->buff) + 1 > this->buffSize - 1) this->send();
this->_safecat("}");
this->_objects--;
this->_nocomma = false;
}
void JsonFormatter::beginArray(const char *name) {
if(name && strlen(name) > 0) this->appendElem(name);
else if(!this->_nocomma) this->_safecat(",");
this->_safecat("[");
this->_arrays++;
this->_nocomma = true;
}
void JsonFormatter::endArray() {
//if(strlen(this->buff) + 1 > this->buffSize - 1) this->send();
this->_safecat("]");
this->_arrays--;
this->_nocomma = false;
}
void JsonFormatter::appendElem(const char *name) {
if(!this->_nocomma) this->_safecat(",");
if(name && strlen(name) > 0) {
this->_safecat(name, true);
this->_safecat(":");
}
this->_nocomma = false;
}
void JsonFormatter::addElem(const char *name, const char *val) {
if(!val) return;
this->appendElem(name);
this->_safecat(val, true);
}
void JsonFormatter::addElem(const char *val) { this->addElem(nullptr, val); }
void JsonFormatter::addElem(float fval) { sprintf(this->_numbuff, "%.4f", fval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(int8_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(uint8_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(int32_t nval) { sprintf(this->_numbuff, "%ld", (long)nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(uint32_t nval) { sprintf(this->_numbuff, "%lu", (unsigned long)nval); this->_appendNumber(nullptr); }
/*
void JsonFormatter::addElem(int16_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(uint16_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(int64_t lval) { sprintf(this->_numbuff, "%lld", (long long)lval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(uint64_t lval) { sprintf(this->_numbuff, "%llu", (unsigned long long)lval); this->_appendNumber(nullptr); }
*/
void JsonFormatter::addElem(bool bval) { strcpy(this->_numbuff, bval ? "true" : "false"); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(const char *name, float fval) { sprintf(this->_numbuff, "%.4f", fval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, int8_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, uint8_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, int32_t nval) { sprintf(this->_numbuff, "%ld", (long)nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, uint32_t nval) { sprintf(this->_numbuff, "%lu", (unsigned long)nval); this->_appendNumber(name); }
/*
void JsonFormatter::addElem(const char *name, int16_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, uint16_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, int64_t lval) { sprintf(this->_numbuff, "%lld", (long long)lval); this->_appendNumber(name); }
*/
void JsonFormatter::addElem(const char *name, uint64_t lval) { sprintf(this->_numbuff, "%llu", (unsigned long long)lval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, bool bval) { strcpy(this->_numbuff, bval ? "true" : "false"); this->_appendNumber(name); }
void JsonFormatter::_safecat(const char *val, bool escape) {
const size_t vlen = escape ? this->calcEscapedLength(val) + 2 : strlen(val);
if(this->_len + vlen >= this->buffSize) {
if(!this->_overflow) ESP_LOGE(TAG, "JSON exceeded buffer size %u", (unsigned)this->buffSize);
this->_overflow = true;
return;
}
this->_appendAt(val, escape, vlen);
}
void JsonFormatter::_appendAt(const char *val, bool escape, size_t vlen) {
char *p = this->buff + this->_len;
if(escape) {
*p++ = '"';
*p = 0;
this->escapeString(val, p);
p += vlen - 2;
*p++ = '"';
}
else {
memcpy(p, val, vlen);
p += vlen;
}
*p = 0;
this->_len += vlen;
}
void JsonFormatter::_appendNumber(const char *name) { this->appendElem(name); this->_safecat(this->_numbuff); }
size_t JsonFormatter::escapeChar(char c, char *out) {
const char *esc = nullptr;
switch(c) {
case '"': esc = "\\\""; break;
case '/': esc = "\\/"; break;
case '\b': esc = "\\b"; break;
case '\f': esc = "\\f"; break;
case '\n': esc = "\\n"; break;
case '\r': esc = "\\r"; break;
case '\t': esc = "\\t"; break;
case '\\': esc = "\\\\"; break;
default: break;
}
if(esc) {
strcpy(out, esc);
return 2;
}
// Any other control character is not allowed raw in a JSON string.
if((unsigned char)c < 0x20) return snprintf(out, 7, "\\u%04x", (unsigned char)c);
out[0] = c;
out[1] = 0x00;
return 1;
}
uint32_t JsonFormatter::calcEscapedLength(const char *raw) {
char tmp[7];
uint32_t len = 0;
for(size_t i = 0; raw[i]; i++) len += escapeChar(raw[i], tmp);
return len;
}
void JsonFormatter::escapeString(const char *raw, char *escaped) {
char *out = escaped + strlen(escaped);
for(size_t i = 0; raw[i]; i++) out += escapeChar(raw[i], out);
}

72
src/JsonFormatter.h Normal file
View file

@ -0,0 +1,72 @@
#ifndef json_formatter_h
#define json_formatter_h
#include <Arduino.h>
// Streams JSON into a fixed buffer. Free of the web server so it can be unit
// tested on the PC (pio test -e native).
class JsonFormatter {
protected:
char *buff;
size_t buffSize;
// What is in buff, kept so appending never has to strlen() the buffer.
size_t _len = 0;
bool _headersSent = false;
uint8_t _objects = 0;
uint8_t _arrays = 0;
bool _nocomma = true;
// Something didn't fit and was left out so the JSON is incomplete.
bool _overflow = false;
char _numbuff[25] = {0};
virtual void _safecat(const char *val, bool escape = false);
// Appends val at buff + _len. vlen is what it takes, including the quotes
// when escaped; the caller has made sure it fits.
void _appendAt(const char *val, bool escape, size_t vlen);
void _appendNumber(const char *name);
public:
bool overflowed() const { return _overflow; }
// Writes c as it must appear inside a JSON string (out needs 7 bytes) and
// returns its length.
static size_t escapeChar(char c, char *out);
void escapeString(const char *raw, char *escaped);
uint32_t calcEscapedLength(const char *raw);
void beginObject(const char *name = nullptr);
void endObject();
void beginArray(const char *name = nullptr);
void endArray();
void appendElem(const char *name = nullptr);
void addElem(const char* val);
void addElem(float fval);
void addElem(int8_t nval);
void addElem(uint8_t nval);
/*
void addElem(int32_t nval);
void addElem(int16_t nval);
void addElem(uint16_t nval);
void addElem(unsigned int nval);
*/
void addElem(int32_t lval);
void addElem(uint32_t lval);
void addElem(bool bval);
void addElem(const char* name, float fval);
void addElem(const char* name, int8_t nval);
void addElem(const char* name, uint8_t nval);
/*
void addElem(const char* name, int nval);
void addElem(const char* name, int16_t nval);
void addElem(const char* name, uint16_t nval);
void addElem(const char* name, unsigned int nval);
*/
void addElem(const char* name, int32_t lval);
void addElem(const char* name, uint32_t lval);
void addElem(const char* name, bool bval);
void addElem(const char *name, const char *val);
void addElem(const char* name, uint64_t lval);
};
class BufferedJsonFormatter : public JsonFormatter {
public:
void setBuffer(char *b, size_t sz);
size_t length() const;
};
#endif

83
src/LogLock.cpp Normal file
View file

@ -0,0 +1,83 @@
// Prints each log line whole.
//
// The Arduino core's log_printfv() writes with ets_printf() and has its UART
// lock commented out, so lines logged at the same time from async_tcp (core 0)
// and loop() (core 1) come out interleaved character by character. It also
// formats short lines into one static buffer shared by every task.
//
// The core already links with --wrap=log_printf, which sends every
// ESP_LOGx/log_x call to the ESP Insights hook in libesp_diagnostics, and that
// hook prints through log_printfv(). platformio.ini adds
// -Wl,--wrap=log_printfv so that call lands here and takes this lock.
//
// The lock is a binary semaphore, not a mutex. A mutex passes its waiter's
// priority to the holder, so async_tcp waiting on loop() would lift loop()
// above the radio task and let it break into a frame on the air. Without that
// boost a holder preempted on the other core (loop() while the radio task sends
// a whole job) can't let go, so a waiter only waits while the holder can make
// progress (see LogLockPolicy.h).
#include <Arduino.h>
#include <stdarg.h>
#include "esp_idf_version.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "LogLockPolicy.h"
extern "C" int __real_log_printfv(const char *format, va_list arg);
static SemaphoreHandle_t createLogLock() {
SemaphoreHandle_t sem = xSemaphoreCreateBinary();
if(sem) xSemaphoreGive(sem);
return sem;
}
// Created during static initialisation, before anything can race for it. A
// constructor that logs before this runs simply goes unlocked.
static SemaphoreHandle_t logLock = createLogLock();
// The task holding the lock, so a log from inside a log doesn't wait on itself.
static volatile TaskHandle_t logOwner = nullptr;
// Never wait longer than this for the lock; the line is printed anyway, so a
// task stuck mid-log can only garble output, not hang another task. A long
// line takes about 20ms at 115200 baud.
#define LOG_LOCK_WAIT_MS 20
#if ESP_IDF_VERSION_MAJOR >= 5
#define taskOnCore(core) xTaskGetCurrentTaskHandleForCore(core)
#define taskCore(task) xTaskGetCoreID(task)
#else
#define taskOnCore(core) xTaskGetCurrentTaskHandleForCPU(core)
#define taskCore(task) xTaskGetAffinity(task)
#endif
static LogHolder logHolder(TaskHandle_t owner) {
if(!owner) return LogHolder::unknown;
for(BaseType_t core = 0; core < portNUM_PROCESSORS; core++)
if(taskOnCore(core) == owner) return LogHolder::running;
// A task pinned elsewhere only runs once whatever preempted it there is done.
const BaseType_t core = taskCore(owner);
if(core == tskNO_AFFINITY || core == xPortGetCoreID()) return LogHolder::preemptedHere;
return LogHolder::preemptedElsewhere;
}
// Takes the lock, waiting in one tick slices for as long as the holder can let
// go of it. False when the line has to be printed without it.
static bool takeLogLock() {
uint32_t waitedMs = 0;
while(xSemaphoreTake(logLock, 1) != pdTRUE) {
waitedMs += portTICK_PERIOD_MS;
if(!keepWaitingForLogLock(logHolder(logOwner), waitedMs, LOG_LOCK_WAIT_MS)) return false;
}
return true;
}
extern "C" int __wrap_log_printfv(const char *format, va_list arg) {
// No blocking before the scheduler runs, in an ISR or in a critical section.
const bool canLock = logLock && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING && xPortCanYield() &&
logOwner != xTaskGetCurrentTaskHandle();
const bool locked = canLock && takeLogLock();
if(locked) logOwner = xTaskGetCurrentTaskHandle();
int len = __real_log_printfv(format, arg);
if(locked) {
logOwner = nullptr;
xSemaphoreGive(logLock);
}
return len;
}

21
src/LogLockPolicy.h Normal file
View file

@ -0,0 +1,21 @@
#ifndef LOG_LOCK_POLICY_H
#define LOG_LOCK_POLICY_H
#include <stdint.h>
// When a log call stops waiting for the log lock (src/LogLock.cpp). Kept free
// of FreeRTOS so it can be unit tested on the PC (pio test -e native).
// What the task holding the log lock is doing.
enum class LogHolder {
unknown, // Just took the lock and hasn't recorded itself yet.
running, // Running on a core, so it lets go within a line.
preemptedHere, // Waiting for this core, so blocking lets it finish.
preemptedElsewhere // Preempted on another core (the radio task during a
// whole transmission), so waiting gets nowhere.
};
// Whether to keep waiting after waitedMs. Never past maxMs.
inline bool keepWaitingForLogLock(LogHolder holder, uint32_t waitedMs, uint32_t maxMs) {
if(waitedMs >= maxMs) return false;
return holder != LogHolder::preemptedElsewhere;
}
#endif

130
src/RadioScheduler.cpp Normal file
View file

@ -0,0 +1,130 @@
#include "RadioScheduler.h"
#include "esp_log.h"
// Logged as part of the radio code it came from.
static const char *TAG = "Somfy";
#define TX_EVENT_QUEUE_SIZE (TX_JOB_QUEUE_SIZE * 3)
// Whether a job fits with spaces slots free. Normal jobs leave the reserved
// slots free for stops.
static bool admits(uint32_t spaces, bool priority) {
return spaces > (priority ? 0u : (uint32_t)TX_RESERVED_SLOTS);
}
uint32_t RadioScheduler::submit(somfy_tx_job_t &job, bool priority, uint16_t delayMs, uint32_t now) {
job.seq = 0;
if(this->suspendMask) {
ESP_LOGW(TAG, "Radio TX suspended, dropping transmission");
return 0;
}
if(!admits(TX_JOB_QUEUE_SIZE - this->count, priority)) {
ESP_LOGE(TAG, "Radio TX queue full, dropping transmission");
return 0;
}
// 0 means no job so it is skipped on wrap.
if(++this->lastSeq == 0) ++this->lastSeq;
job.seq = this->lastSeq;
job.priority = priority;
job.front = priority && !job.raw && !this->waitingFor(job.frame.remoteAddress);
job.notBefore = 0;
if(delayMs > 0) {
job.notBefore = now + delayMs;
if(job.notBefore == 0) job.notBefore = 1; // 0 means not delayed.
}
// Front jobs go out in the order they came, ahead of the back.
uint8_t at = this->count;
if(job.front) {
at = 0;
while(at < this->count && this->jobs[at].front) at++;
}
for(uint8_t i = this->count; i > at; i--) this->jobs[i] = this->jobs[i - 1];
this->jobs[at] = job;
this->count++;
return job.seq;
}
bool RadioScheduler::hasRoom(bool priority) const {
return !this->suspendMask && admits(TX_JOB_QUEUE_SIZE - this->count, priority);
}
bool RadioScheduler::waitingFor(uint32_t remoteAddress) const {
for(uint8_t i = 0; i < this->count; i++) {
if(!this->jobs[i].raw && this->jobs[i].frame.remoteAddress == remoteAddress) return true;
}
return false;
}
void RadioScheduler::remove(uint8_t i) {
for(; i + 1 < this->count; i++) this->jobs[i] = this->jobs[i + 1];
this->count--;
}
bool RadioScheduler::next(uint32_t now, somfy_tx_job_t &job, uint32_t &waitMs) {
waitMs = RADIO_WAIT_FOREVER;
if(this->count == 0) return false;
// Jobs go out in order, so a delayed job holds up the ones behind it. A
// front job queued meanwhile is ahead of it.
const somfy_tx_job_t &head = this->jobs[0];
if(head.notBefore != 0) {
// Works across a millis() wrap.
const int32_t due = (int32_t)(head.notBefore - now);
if(due > 0) {
waitMs = (uint32_t)due;
return false;
}
}
job = head;
this->remove(0);
this->taken = job.seq;
this->takenFlushed = false;
this->takenJob = job;
return true;
}
bool RadioScheduler::start(uint32_t ticket, uint32_t now) {
if(ticket == 0 || ticket != this->taken) return false;
if(this->takenFlushed) {
// Flushed after the radio task took it.
this->post(tx_event_t::dropped, this->takenJob, now);
this->taken = 0;
return false;
}
this->post(tx_event_t::started, this->takenJob, now);
return true;
}
bool RadioScheduler::keepSending(uint32_t ticket) const {
return ticket != 0 && ticket == this->taken && !this->takenFlushed;
}
void RadioScheduler::ended(uint32_t ticket, bool complete, uint32_t now) {
if(ticket == 0 || ticket != this->taken) return;
this->post(complete ? tx_event_t::finished : tx_event_t::aborted, this->takenJob, now);
this->taken = 0;
}
void RadioScheduler::flush(uint32_t now) {
// Every discarded job is reported as dropped.
for(uint8_t i = 0; i < this->count; i++) this->post(tx_event_t::dropped, this->jobs[i], now);
this->count = 0;
if(this->taken) this->takenFlushed = true;
}
void RadioScheduler::suspend(uint8_t reason, uint32_t now) {
this->suspendMask |= reason;
this->flush(now);
}
void RadioScheduler::resume(uint8_t reason) { this->suspendMask &= ~reason; }
void RadioScheduler::post(tx_event_t type, const somfy_tx_job_t &job, uint32_t at) {
// Repeater frames don't belong to any of our remotes.
if(job.raw) return;
if(this->eventCount >= TX_EVENT_QUEUE_SIZE) {
ESP_LOGW(TAG, "Radio TX event queue full");
return;
}
somfy_tx_event_t &evt = this->events[(this->eventHead + this->eventCount) % TX_EVENT_QUEUE_SIZE];
evt.type = type;
evt.cmd = job.frame.cmd;
evt.remoteAddress = job.frame.remoteAddress;
evt.seq = job.seq;
evt.at = at;
this->eventCount++;
}
bool RadioScheduler::popEvent(somfy_tx_event_t &evt) {
if(this->eventCount == 0) return false;
evt = this->events[this->eventHead];
this->eventHead = (uint8_t)((this->eventHead + 1) % TX_EVENT_QUEUE_SIZE);
this->eventCount--;
return true;
}

70
src/RadioScheduler.h Normal file
View file

@ -0,0 +1,70 @@
#ifndef RADIO_SCHEDULER_H
#define RADIO_SCHEDULER_H
#include <stdint.h>
#include "TxBuffer.h"
// Reasons the radio must not transmit. TX resumes once every reason is cleared.
#define TX_SUSPEND_SCAN 0x01
#define TX_SUSPEND_OTA 0x02
// No job is waiting so the radio task sleeps until it is woken.
#define RADIO_WAIT_FOREVER 0xFFFFFFFFu
// The radio jobs waiting for, or on, the air (see GLOSSARY.md). It decides
// their order, when a delayed job is due, what a flush or suspend drops, and
// reports the outcome of every job. Kept free of FreeRTOS and the radio so it
// can be unit tested on the PC (pio test -e native).
//
// Not thread safe. The caller serializes every call and passes the time in.
class RadioScheduler {
public:
// Queues a job. Its ticket comes back and is also set in job.seq, 0 when
// the queue is full or suspended. A priority job (a stop) may use the
// reserved slots and goes ahead of every job queued at the back, unless a
// job of its remote is already waiting: overtaking that would put the
// rolling codes out of order. A delayed job doesn't go on air before
// delayMs from now and holds up the jobs queued behind it.
uint32_t submit(somfy_tx_job_t &job, bool priority, uint16_t delayMs, uint32_t now);
// Whether a job would be taken right now.
bool hasRoom(bool priority) const;
// For the radio task: takes the job to send next. False when there is
// none due, waitMs then says how long until there is (RADIO_WAIT_FOREVER
// when nothing waits). A job taken goes on air through start() and
// ended().
bool next(uint32_t now, somfy_tx_job_t &job, uint32_t &waitMs);
// The job taken is about to go on air. False when it was flushed since,
// it is then dropped and must not be sent.
bool start(uint32_t ticket, uint32_t now);
// Whether the job on air should send its next frame. A flush cuts it
// short.
bool keepSending(uint32_t ticket) const;
// The job on air stopped, after every frame when complete.
void ended(uint32_t ticket, bool complete, uint32_t now);
// Drops every waiting job and cuts short the one on air.
void flush(uint32_t now);
// Refuses new jobs until every reason (TX_SUSPEND_*) is resumed. Flushes.
void suspend(uint8_t reason, uint32_t now);
void resume(uint8_t reason);
bool suspended() const { return this->suspendMask != 0; }
// Takes the oldest job outcome not yet handed out. Every job submitted
// that isn't a repeater frame is started and then finished or aborted, or
// dropped.
bool popEvent(somfy_tx_event_t &evt);
private:
somfy_tx_job_t jobs[TX_JOB_QUEUE_SIZE];
uint8_t count = 0;
uint32_t lastSeq = 0;
uint8_t suspendMask = 0;
// The job taken by next() and not yet ended (0 for none), kept for its
// events.
uint32_t taken = 0;
somfy_tx_job_t takenJob;
bool takenFlushed = false;
somfy_tx_event_t events[TX_JOB_QUEUE_SIZE * 3];
uint8_t eventHead = 0;
uint8_t eventCount = 0;
// Whether a job of the remote is still waiting.
bool waitingFor(uint32_t remoteAddress) const;
void remove(uint8_t i);
void post(tx_event_t type, const somfy_tx_job_t &job, uint32_t at);
};
#endif

16
src/RollingCode.h Normal file
View file

@ -0,0 +1,16 @@
#ifndef ROLLING_CODE_H
#define ROLLING_CODE_H
#include <stdint.h>
// Working out a remote's next rolling code (src/Somfy.cpp). Kept free of NVS
// so it can be unit tested on the PC (pio test -e native).
// The code the next frame uses. It is only read here and consumed once the
// frame using it has been queued. synced says last matches what is stored for
// this address, so the store is only read through loadStored until a code has
// been written.
template<typename LoadStored>
inline uint16_t nextRollingCode(bool synced, uint16_t last, LoadStored loadStored) {
return (uint16_t)((synced ? last : loadStored()) + 1);
}
#endif

97
src/ShadeMotion.cpp Normal file
View file

@ -0,0 +1,97 @@
#include <algorithm>
#include "ShadeMotion.h"
float travelPosition(float startPos, int8_t dir, uint32_t elapsedMs, int32_t travelMs) {
// Work in time travelled from the end we are moving away from: the start
// position is turned into the time it would take to get there, the elapsed
// time is added and the total is turned back into a position. The addition
// wraps like the uint32 millis() so it keeps working across a rollover.
if(dir > 0) {
if(travelMs == 0) return 100.0f;
int32_t ms = (int32_t)floor((startPos / 100) * travelMs);
ms += elapsedMs;
ms = std::min(travelMs, ms);
if(ms >= travelMs) return 100.0f;
return std::min(std::max(0.0f, (float)ms / (float)travelMs), 1.0f) * 100;
}
if(dir < 0) {
if(travelMs == 0) return 0.0f;
int32_t ms = travelMs - (int32_t)floor((startPos / 100) * travelMs);
ms += elapsedMs;
ms = std::min(travelMs, ms);
if(ms >= travelMs) return 0.0f;
float pos = (1.0f - std::min(std::max(0.0f, (float)ms / (float)travelMs), 1.0f)) * 100;
return pos <= 0.0f ? 0.0f : pos;
}
return startPos;
}
int8_t displayPosition(float pos, bool flip) {
if(pos < 0) return -1;
return static_cast<int8_t>(flip && pos >= 0.00f ? floor(100.0f - pos) : floor(pos));
}
float limitTarget(int8_t dir) { return dir > 0 ? 100.0f : 0.0f; }
void TxHold::mark(uint32_t jobSeq, bool moving, uint32_t now) {
if(jobSeq == 0 || this->seq != 0 || moving) return;
this->seq = jobSeq;
this->since = now;
}
bool TxHold::release(uint32_t jobSeq) {
if(jobSeq == 0 || jobSeq != this->seq) return false;
this->seq = 0;
return true;
}
TxHoldCheck TxHold::check(bool moving, uint32_t now, uint32_t timeoutMs) {
if(!this->held()) return TxHoldCheck::none;
if(!moving) {
// The command didn't set the shade moving so there is nothing to hold.
this->clear();
return TxHoldCheck::none;
}
if(!this->timedOut(now, timeoutMs)) return TxHoldCheck::holding;
this->clear();
return TxHoldCheck::expired;
}
void ShadeTxState::stopSent(uint32_t seq, uint32_t now, int8_t dir, bool tilt) {
this->stopSeq = seq;
this->stopAt = now;
this->stopDir = dir;
this->stopTilt = tilt;
}
void ShadeTxState::setMySent(uint32_t seq, float oldPos, float oldTiltPos) {
this->mySeq = seq;
this->myOldPos = oldPos;
this->myOldTiltPos = oldTiltPos;
}
ShadeTxActions ShadeTxState::onEvent(const somfy_tx_event_t &evt, bool moving) {
ShadeTxActions act;
if(evt.seq == 0) return act;
if(this->hold.release(evt.seq)) {
if(evt.type == tx_event_t::started) {
act.startMovement = true;
act.startAt = evt.at;
}
else act.cancelMovement = true;
}
if(this->mySeq == evt.seq && evt.type != tx_event_t::started) {
this->mySeq = 0;
// The motor only stores the favorite when it gets the whole long press.
if(evt.type == tx_event_t::finished) act.commitMy = true;
else {
act.restoreMy = true;
act.myOldPos = this->myOldPos;
act.myOldTiltPos = this->myOldTiltPos;
}
}
if(this->stopSeq == evt.seq) {
this->stopSeq = 0;
// Unless something has moved the shade since, the motor never got the stop
// and is still running.
if(evt.type == tx_event_t::dropped && !moving) {
act.runToLimit = true;
act.limitDir = this->stopDir;
act.limitTilt = this->stopTilt;
act.limitSince = this->stopAt;
}
}
return act;
}

89
src/ShadeMotion.h Normal file
View file

@ -0,0 +1,89 @@
#ifndef SHADE_MOTION_H
#define SHADE_MOTION_H
#include <Arduino.h>
#include "TxBuffer.h"
// Shade position maths. Kept free of the shade state, radio and network so it
// can be unit tested on the PC (pio test -e native).
// Where a shade (or its tilt) is after travelling elapsedMs from startPos
// (0-100). dir > 0 moves toward 100 and dir < 0 toward 0. travelMs is the
// time a full travel takes in that direction, 0 meaning it gets there at once.
float travelPosition(float startPos, int8_t dir, uint32_t elapsedMs, int32_t travelMs);
// A position as reported to clients: -1 when unset, otherwise the whole
// percentage, counted from the other end when flipped.
int8_t displayPosition(float pos, bool flip);
// Where a shade (or its tilt) moving in dir ends up when nothing stops it.
float limitTarget(int8_t dir);
// What a held shade does on this pass of checkMovement().
enum class TxHoldCheck {
none, // Nothing is held.
holding, // Still waiting for the job to go on air.
expired // Waited too long, so the hold was dropped and the shade moves anyway.
};
// The radio job that set an idle shade moving. Its simulated movement is held
// until the job goes on air, since that is when the motor starts.
struct TxHold {
uint32_t seq = 0; // 0 when nothing is held.
uint32_t since = 0; // millis() when the hold started.
// Holds for job seq unless the shade was already moving (its motor carries on
// with the earlier command) or a job is already held. The first job held is
// the one that starts the motor. A command sent while processing another
// registers before it: for a simMy shade My/Favorite queues its Up/Down from
// moveToMyPosition() first, and that Up/Down is what starts a motor with no
// favorite of its own, so the outer My/Favorite is ignored on purpose.
void mark(uint32_t jobSeq, bool moving, uint32_t now);
// Ends the hold when jobSeq is the held job. True when it was.
bool release(uint32_t jobSeq);
bool held() const { return this->seq != 0; }
bool timedOut(uint32_t now, uint32_t timeoutMs) const { return now - this->since >= timeoutMs; }
void clear() { this->seq = 0; }
// Drops a hold on a shade the command didn't set moving, or one that has
// waited timeoutMs for the radio.
TxHoldCheck check(bool moving, uint32_t now, uint32_t timeoutMs);
};
// What a shade has to do about a radio event. More than one can apply to the
// same event.
struct ShadeTxActions {
// The held job went on air, so the motor starts at startAt.
bool startMovement = false;
uint32_t startAt = 0;
// The held job never went on air, so the motor hasn't moved.
bool cancelMovement = false;
// The favorite went on air in full and is committed, or it didn't and the
// previous one is put back.
bool commitMy = false;
bool restoreMy = false;
float myOldPos = -1.0f;
float myOldTiltPos = -1.0f;
// The stop never went on air, so the motor runs on to its end limit from
// where and when it should have stopped.
bool runToLimit = false;
int8_t limitDir = 0;
bool limitTilt = false;
uint32_t limitSince = 0;
bool any() const { return startMovement || cancelMovement || commitMy || restoreMy || runToLimit; }
};
// The radio jobs a shade waits on to find out what its motor really did.
struct ShadeTxState {
// Radio job that set the shade moving. The simulated position is held
// until it goes on air.
TxHold hold;
// Stop sent when the simulated target was reached. If it never goes on
// air the motor runs on to its end limit. 0 once an event for it arrived.
uint32_t stopSeq = 0;
uint32_t stopAt = 0;
int8_t stopDir = 0;
bool stopTilt = false;
// Command telling the motor to store the favorite. The favorite is only
// committed once all of it is on air and restored if it never is.
uint32_t mySeq = 0;
float myOldPos = -1.0f;
float myOldTiltPos = -1.0f;
void stopSent(uint32_t seq, uint32_t now, int8_t dir, bool tilt);
void setMySent(uint32_t seq, float oldPos, float oldTiltPos);
// What to do about evt. moving is whether the shade is moving now.
ShadeTxActions onEvent(const somfy_tx_event_t &evt, bool moving);
};
#endif

View file

@ -7,6 +7,7 @@
#include "Somfy.h"
#include "ESPNetwork.h"
#include "GitOTA.h"
#include "Timing.h"
static const char *TAG = "Sockets";
@ -21,7 +22,10 @@ AsyncWebSocket ws("/");
#define MAX_WS_CLIENTS 5
static uint32_t clientMap[MAX_WS_CLIENTS] = {0,0,0,0,0};
// When each slot connected and last saw a ping, so the logs show how long a
// client stayed and how far apart its pings arrived.
static uint32_t connectedAt[MAX_WS_CLIENTS] = {0,0,0,0,0};
static uint32_t lastPing[MAX_WS_CLIENTS] = {0,0,0,0,0};
static uint8_t mapClientId(uint32_t asyncId) {
for(uint8_t i = 0; i < MAX_WS_CLIENTS; i++)
if(clientMap[i] == asyncId) return i;
@ -131,13 +135,22 @@ void SocketEmitter::initClients() {
if(asyncId != 0 && ws.hasClient(asyncId)) {
ESP_LOGD(TAG, "Initializing Socket Client %u (asyncId=%lu)", slot, asyncId);
esp_task_wdt_reset();
ElapsedTimer total, step;
settings.emitSockets(slot);
logElapsed(TAG, "Socket init settings", step.us());
if(!ws.hasClient(asyncId)) { this->newClients[i] = 255; continue; }
step.restart();
somfy.emitState(slot);
logElapsed(TAG, "Socket init somfy state", step.us());
if(!ws.hasClient(asyncId)) { this->newClients[i] = 255; continue; }
step.restart();
git.emitUpdateCheck(slot);
logElapsed(TAG, "Socket init update check", step.us());
if(!ws.hasClient(asyncId)) { this->newClients[i] = 255; continue; }
step.restart();
net.emitSockets(slot);
logElapsed(TAG, "Socket init network", step.us());
logElapsed(TAG, "Socket init total", total.us());
esp_task_wdt_reset();
}
this->newClients[i] = 255;
@ -176,7 +189,9 @@ void SocketEmitter::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client
return;
}
IPAddress ip = client->remoteIP();
ESP_LOGD(TAG, "Socket [%lu] Connected from %d.%d.%d.%d (slot %u)", asyncId, ip[0], ip[1], ip[2], ip[3], slot);
ESP_LOGI(TAG, "Socket [%lu] Connected from %d.%d.%d.%d (slot %u)", asyncId, ip[0], ip[1], ip[2], ip[3], slot);
connectedAt[slot] = millis();
lastPing[slot] = 0;
client->text("Connected");
client->setCloseClientOnQueueFull(false);
sockEmit.delayInit(slot);
@ -185,8 +200,9 @@ void SocketEmitter::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client
case WS_EVT_DISCONNECT:
{
uint8_t slot = mapClientId(asyncId);
ESP_LOGD(TAG, "Socket [%lu] Disconnected (slot %u)", asyncId, slot);
if(slot != 255) {
ESP_LOGI(TAG, "Socket [%lu] Disconnected (slot %u) after %lums, last ping %lums ago", asyncId, slot,
(unsigned long)(millis() - connectedAt[slot]), lastPing[slot] ? (unsigned long)(millis() - lastPing[slot]) : 0UL);
for(uint8_t i = 0; i < SOCK_MAX_ROOMS; i++)
sockEmit.rooms[i].leave(slot);
}
@ -218,6 +234,19 @@ void SocketEmitter::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client
case WS_EVT_ERROR:
ESP_LOGE(TAG, "Socket [%lu] Error", asyncId);
break;
case WS_EVT_PING:
{
// Home Assistant only pings after 20s without a message from us, so
// the gap depends on our own traffic and says nothing about the link.
// It is kept for the disconnect line.
uint8_t slot = mapClientId(asyncId);
if(slot != 255) {
uint32_t now = millis();
if(lastPing[slot]) ESP_LOGD(TAG, "Socket [%lu] ping, %lums since the last", asyncId, (unsigned long)(now - lastPing[slot]));
lastPing[slot] = now;
}
}
break;
case WS_EVT_PONG:
break;
}

File diff suppressed because it is too large Load diff

View file

@ -21,35 +21,10 @@
#define SOMFY_NO_WIND_REMOTE_TIMEOUT SECS_TO_MILLIS(30)
enum class radio_proto : byte { // Ordinal byte 0-255
RTS = 0x00,
RTW = 0x01,
RTV = 0x02,
GP_Relay = 0x08,
GP_Remote = 0x09
};
enum class somfy_commands : byte {
Unknown0 = 0x0,
My = 0x1,
Up = 0x2,
MyUp = 0x3,
Down = 0x4,
MyDown = 0x5,
UpDown = 0x6,
MyUpDown = 0x7,
Prog = 0x8,
SunFlag = 0x9,
Flag = 0xA,
StepDown = 0xB,
Toggle = 0xC,
UnknownD = 0xD,
Sensor = 0xE,
RTWProto = 0xF, // RTW Protocol
// Command extensions for 80 bit frames
StepUp = 0x8B,
Favorite = 0xC1,
Stop = 0xF1
};
#include "SomfyFrame.h"
#include "TxBuffer.h"
#include "RadioScheduler.h"
#include "ShadeMotion.h"
enum class group_types : byte {
channel = 0x00
};
@ -79,12 +54,9 @@ enum class tilt_types : byte {
tiltonly = 0x03,
euromode = 0x04
};
String translateSomfyCommand(const somfy_commands cmd);
somfy_commands translateSomfyCommand(const String& string);
#define MAX_TIMINGS 300
#define MAX_RX_BUFFER 3
#define MAX_TX_BUFFER 5
typedef enum {
waiting_synchro = 0,
@ -125,33 +97,6 @@ struct somfy_rx_queue_t {
void push(somfy_rx_t *rx);
bool pop(somfy_rx_t *rx);
};
struct somfy_tx_t {
void clear() {
this->hwsync = 0;
this->bit_length = 0;
memset(this->payload, 0x00, sizeof(this->payload));
}
uint8_t hwsync = 0;
uint8_t bit_length = 0;
uint8_t payload[10] = {};
};
struct somfy_tx_queue_t {
somfy_tx_queue_t() { this->clear(); }
void clear() {
for (uint8_t i = 0; i < MAX_TX_BUFFER; i++) {
this->index[i] = 255;
this->items[i].clear();
}
this->length = 0;
}
unsigned long delay_time = 0;
uint8_t length = 0;
uint8_t index[MAX_TX_BUFFER] = {255};
somfy_tx_t items[MAX_TX_BUFFER];
bool pop(somfy_tx_t *tx);
void push(somfy_rx_t *rx); // Used for repeats
void push(uint8_t hwsync, byte *payload, uint8_t bit_length);
};
enum class somfy_flags_t : byte {
SunFlag = 0x01,
@ -171,36 +116,6 @@ struct somfy_relay_t {
uint8_t sync = 0;
byte frame[10] = {0};
};
struct somfy_frame_t {
bool valid = false;
bool processed = false;
bool synonym = false;
radio_proto proto = radio_proto::RTS;
int rssi = 0;
byte lqi = 0x0;
somfy_commands cmd;
uint32_t remoteAddress = 0;
uint16_t rollingCode = 0;
uint8_t encKey = 0xA7;
uint8_t checksum = 0;
uint8_t hwsync = 0;
uint8_t repeats = 0;
uint32_t await = 0;
uint8_t bitLength = 56;
uint16_t pulseCount = 0;
uint8_t stepSize = 0;
uint8_t rawCmd = 0;
void print();
void encode80BitFrame(byte *frame, uint8_t repeat);
byte calc80Checksum(byte b0, byte b1, byte b2);
byte encode80Byte7(byte start, uint8_t repeat);
void encodeFrame(byte *frame);
void decodeFrame(byte* frame);
void decodeFrame(somfy_rx_t *rx);
bool isRepeat(somfy_frame_t &f);
bool isSynonym(somfy_frame_t &f);
void copy(somfy_frame_t &f);
};
class SomfyRoom {
public:
@ -216,6 +131,13 @@ class SomfyRoom {
void unpublish();
};
// What sending a command did. ticket is the radio job carrying it, 0 when the
// command went out over GPIO instead.
struct send_result_t {
bool sent = false;
uint32_t ticket = 0;
explicit operator bool() const { return this->sent; }
};
class SomfyRemote {
// These sizes for the data have been
// confirmed. The address is actually 24bits
@ -223,6 +145,9 @@ class SomfyRemote {
protected:
char m_remotePrefId[11] = "";
uint32_t m_remoteAddress = 0;
// lastRollingCode is known to match NVS for this address, so the next code
// can be worked out without opening NVS.
bool m_rollingCodeSynced = false;
public:
radio_proto proto = radio_proto::RTS;
uint8_t gpioFlags = 0;
@ -241,7 +166,8 @@ class SomfyRemote {
char *getRemotePrefId() {return m_remotePrefId;}
virtual void setRemoteAddress(uint32_t address);
virtual uint32_t getRemoteAddress();
virtual uint16_t getNextRollingCode();
uint16_t peekNextRollingCode();
void commitRollingCode(uint16_t code);
virtual uint16_t setRollingCode(uint16_t code);
bool hasSunSensor();
bool hasLight();
@ -249,10 +175,13 @@ class SomfyRemote {
void setSunSensor(bool bHasSensor);
void setLight(bool bHasLight);
void setSimMy(bool bSimMy);
virtual void sendCommand(somfy_commands cmd);
virtual void sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0);
void sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat);
void repeatFrame(uint8_t repeat);
// The send functions return false only when the radio could not take the
// command. Nothing about the remote or shade state has changed in that case.
virtual bool sendCommand(somfy_commands cmd);
virtual bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0);
send_result_t sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority = false);
bool sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat);
bool repeatFrame(uint8_t repeat);
virtual uint16_t p_lastRollingCode(uint16_t code);
somfy_commands transformCommand(somfy_commands cmd);
virtual void triggerGPIOs(somfy_frame_t &frame);
@ -282,6 +211,11 @@ class SomfyShade : public SomfyRemote {
bool settingPos = false;
bool settingTiltPos = false;
uint32_t awaitMy = 0;
// Radio jobs whose outcome decides what the motor really did.
ShadeTxState tx;
bool sendStop(int8_t dir, bool tilt);
bool sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos);
void runToLimit(int8_t dir, bool tilt, uint32_t since);
public:
uint8_t roomId = 0;
int8_t sortOrder = 0;
@ -317,17 +251,21 @@ class SomfyShade : public SomfyRemote {
bool isInGroup();
void checkMovement();
void processFrame(somfy_frame_t &frame, bool internal = false);
// The shade itself or the linked remote with this address, else nullptr.
SomfyRemote *findRemote(uint32_t address);
void processInternalCommand(somfy_commands cmd, uint8_t repeat = 1);
void markTxPending(uint32_t seq);
void processTxEvent(const somfy_tx_event_t &evt);
void setTiltMovement(int8_t dir);
void setMovement(int8_t dir);
void setTarget(float target);
bool isAtTarget();
bool isToggle();
void moveToTarget(float pos, float tilt = -1.0f);
bool moveToTarget(float pos, float tilt = -1.0f);
void moveToTiltTarget(float target);
void sendTiltCommand(somfy_commands cmd);
void sendCommand(somfy_commands cmd);
void sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0);
bool sendCommand(somfy_commands cmd);
bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0);
bool linkRemote(uint32_t remoteAddress, uint16_t rollingCode = 0);
bool unlinkRemote(uint32_t remoteAddress);
void emitState(const char *evt = "shadeState");
@ -335,7 +273,7 @@ class SomfyShade : public SomfyRemote {
void emitCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand");
void emitCommand(uint8_t num, somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand");
void setMyPosition(int8_t pos, int8_t tilt = -1);
void moveToMyPosition();
bool moveToMyPosition();
void processWaitingFrame();
void publish();
void unpublish();
@ -402,8 +340,8 @@ class SomfyGroup : public SomfyRemote {
void updateFlags();
void emitState(const char *evt = "groupState");
void emitState(uint8_t num, const char *evt = "groupState");
void sendCommand(somfy_commands cmd);
void sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0);
bool sendCommand(somfy_commands cmd);
bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0);
int8_t p_direction(int8_t dir);
bool publish(const char *topic, uint8_t val, bool retain = false);
bool publish(const char *topic, int8_t val, bool retain = false);
@ -488,14 +426,22 @@ struct transceiver_config_t {
class Transceiver {
private:
static void handleReceive();
static void txTask(void *arg);
bool _received = false;
somfy_frame_t frame;
// Only touched under the TX queue lock.
RadioScheduler sched;
void startTxTask();
bool transmitJob(somfy_tx_job_t &job);
// Sends a job taken off the queue and leaves a frame beat after it.
void runJob(somfy_tx_job_t &job);
public:
transceiver_config_t config;
bool printBuffer = false;
//bool toJSON(JsonObject& obj);
bool fromJSON(JsonObject& obj);
bool save();
// Swaps in, saves and applies the config.
void setConfig(const transceiver_config_t &cfg);
bool begin();
void loop();
bool end();
@ -505,6 +451,14 @@ class Transceiver {
void disableReceive();
somfy_frame_t& lastFrame();
void sendFrame(byte *frame, uint8_t sync, uint8_t bitLength = 56);
// Hands a job to the radio task, see RadioScheduler::submit(). Returns its
// ticket, 0 when the radio can't take it. Safe from any task.
uint32_t submitTx(somfy_tx_job_t &job, bool priority = false, uint16_t delayMs = 0);
void flushTx();
void suspendTx(uint8_t reason);
void resumeTx(uint8_t reason);
bool txSuspended();
bool txHasRoom();
void beginTransmit();
void endTransmit();
void emitFrame(somfy_frame_t *frame, somfy_rx_t *rx = nullptr);
@ -560,8 +514,11 @@ class SomfyShadeController {
SomfyGroup * getGroupById(uint8_t groupId);
SomfyShade * findShadeByRemoteAddress(uint32_t address);
SomfyGroup * findGroupByRemoteAddress(uint32_t address);
void sendFrame(somfy_frame_t &frame, uint8_t repeats = 0);
// Returns the radio job's ticket, 0 when the radio can't take it.
uint32_t sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false);
void processFrame(somfy_frame_t &frame, bool internal = false);
void markTxPending(uint32_t remoteAddress, uint32_t seq);
void processTxEvent(const somfy_tx_event_t &evt);
void emitState(uint8_t num = 255);
void publish();
void processWaitingFrame();

View file

@ -11,8 +11,19 @@
#include "MQTT.h"
#include "GitOTA.h"
#include "esp_core_dump.h"
#include "esp_system.h"
#include "esp_idf_version.h"
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
#include "esp_app_desc.h"
#define getRunningElfSha esp_app_get_elf_sha256
#else
#include "esp_ota_ops.h"
#define getRunningElfSha esp_ota_get_app_elf_sha256
#endif
#include "Timing.h"
static const char *TAG = "Main";
#define LOOP_SLOW_MS 100
ConfigSettings settings;
Web webServer;
@ -49,37 +60,55 @@ void setup() {
ESP_LOGI(TAG, "Startup/Boot....");
esp_core_dump_summary_t summary;
if (esp_core_dump_get_summary(&summary) == ESP_OK) {
ESP_LOGW(TAG, "*** Previous crash coredump found ***");
ESP_LOGW(TAG, " Task: %s", summary.exc_task);
ESP_LOGW(TAG, " PC: 0x%08x", summary.exc_pc);
// The dump stays in flash until the next crash replaces it, so it can still
// be read later with espcoredump.py. Only shout about it on the boot right
// after the crash.
const esp_reset_reason_t reason = esp_reset_reason();
const bool newCrash = reason == ESP_RST_PANIC || reason == ESP_RST_TASK_WDT ||
reason == ESP_RST_INT_WDT || reason == ESP_RST_WDT;
#define CRASH_LOG(fmt, ...) do { if(newCrash) ESP_LOGW(TAG, fmt, ##__VA_ARGS__); else ESP_LOGI(TAG, fmt, ##__VA_ARGS__); } while(0)
if(newCrash) ESP_LOGW(TAG, "*** Crash coredump from the last boot ***");
else ESP_LOGI(TAG, "Stored coredump from an earlier crash");
CRASH_LOG(" Task: %s", summary.exc_task);
CRASH_LOG(" PC: 0x%08x", summary.exc_pc);
#ifdef CONFIG_IDF_TARGET_ARCH_XTENSA
ESP_LOGW(TAG, " Cause: %d", summary.ex_info.exc_cause);
CRASH_LOG(" Cause: %d", summary.ex_info.exc_cause);
char bt_buf[256] = {0};
int pos = 0;
for (int i = 0; i < summary.exc_bt_info.depth; i++) {
pos += snprintf(bt_buf + pos, sizeof(bt_buf) - pos, " 0x%08x", summary.exc_bt_info.bt[i]);
}
ESP_LOGW(TAG, " Backtrace:%s", bt_buf);
CRASH_LOG(" Backtrace:%s", bt_buf);
#elif CONFIG_IDF_TARGET_ARCH_RISCV
ESP_LOGW(TAG, " Cause: %d", summary.ex_info.mcause);
ESP_LOGW(TAG, " MTVAL: 0x%08x RA: 0x%08x SP: 0x%08x",
CRASH_LOG(" Cause: %d", summary.ex_info.mcause);
CRASH_LOG(" MTVAL: 0x%08x RA: 0x%08x SP: 0x%08x",
summary.ex_info.mtval, summary.ex_info.ra, summary.ex_info.sp);
#endif
// The backtrace only decodes against the firmware.elf of the build that
// crashed, so say whether that is the one running now.
// app_elf_sha256 isn't always terminated, so copy it before comparing or
// printing it.
char crashedSha[APP_ELF_SHA256_SZ];
static_assert(sizeof(crashedSha) <= sizeof(summary.app_elf_sha256), "copy would read past app_elf_sha256");
SETCHARPROP(crashedSha, (const char *)summary.app_elf_sha256, sizeof(crashedSha));
char runningSha[APP_ELF_SHA256_SZ] = {0};
getRunningElfSha(runningSha, sizeof(runningSha));
if(strcmp(crashedSha, runningSha) == 0)
CRASH_LOG(" Build: %s (running %s, same build)", crashedSha, runningSha);
else
CRASH_LOG(" Build: %s (running %s, different build, decode the backtrace with the matching firmware.elf)", crashedSha, runningSha);
#undef CRASH_LOG
}
ESP_LOGI(TAG, "Mounting File System...");
if (LittleFS.begin()) {
ESP_LOGI(TAG, "Total: %d bytes", LittleFS.totalBytes());
ESP_LOGI(TAG, "Used: %d bytes", LittleFS.usedBytes());
ESP_LOGI(TAG, "Free: %d bytes", LittleFS.totalBytes() - LittleFS.usedBytes());
listDir("/", 3);
ESP_LOGI(TAG, "File system mounted successfully");
} else {
ESP_LOGE(TAG, "LittleFS mount failed!");
}
if(LittleFS.begin()) ESP_LOGI(TAG, "File system mounted successfully");
else ESP_LOGE(TAG, "Error mounting file system");
settings.begin();
if(WiFi.status() == WL_CONNECTED) WiFi.disconnect(true);
delay(10);
@ -108,30 +137,30 @@ void loop() {
ESP.restart();
return;
}
uint32_t timing = millis();
// Each step only logs when it holds up the loop for LOOP_SLOW_MS or more.
ElapsedTimer timing;
net.loop();
if(millis() - timing > 100) ESP_LOGD(TAG, "Timing Net: %ldms", millis() - timing);
timing = millis();
logIfSlow(TAG, "Loop net", timing.us(), LOOP_SLOW_MS);
timing.restart();
esp_task_wdt_reset();
somfy.loop();
if(millis() - timing > 100) ESP_LOGD(TAG, "Timing Somfy: %ldms", millis() - timing);
timing = millis();
logIfSlow(TAG, "Loop somfy", timing.us(), LOOP_SLOW_MS);
esp_task_wdt_reset();
if(net.connected() || net.softAPOpened) {
if(!rebootDelay.reboot && net.connected() && !net.softAPOpened) {
timing.restart();
git.loop();
logIfSlow(TAG, "Loop git", timing.us(), LOOP_SLOW_MS);
esp_task_wdt_reset();
}
timing.restart();
webServer.loop();
logIfSlow(TAG, "Loop web server", timing.us(), LOOP_SLOW_MS);
esp_task_wdt_reset();
if(millis() - timing > 100) ESP_LOGD(TAG, "Timing WebServer: %ldms", millis() - timing);
esp_task_wdt_reset();
timing = millis();
timing.restart();
sockEmit.loop();
if(millis() - timing > 100) ESP_LOGD(TAG, "Timing Socket: %ldms", millis() - timing);
logIfSlow(TAG, "Loop sockets", timing.us(), LOOP_SLOW_MS);
esp_task_wdt_reset();
timing = millis();
}
if(rebootDelay.reboot && millis() > rebootDelay.rebootTime) {
net.end();

432
src/SomfyFrame.cpp Normal file
View file

@ -0,0 +1,432 @@
#include <Arduino.h>
#include "esp_log.h"
#include "SomfyFrame.h"
static const char *TAG = "Somfy";
somfy_commands translateSomfyCommand(const String& string) {
if (string.equalsIgnoreCase("My")) return somfy_commands::My;
else if (string.equalsIgnoreCase("Up")) return somfy_commands::Up;
else if (string.equalsIgnoreCase("MyUp")) return somfy_commands::MyUp;
else if (string.equalsIgnoreCase("Down")) return somfy_commands::Down;
else if (string.equalsIgnoreCase("MyDown")) return somfy_commands::MyDown;
else if (string.equalsIgnoreCase("UpDown")) return somfy_commands::UpDown;
else if (string.equalsIgnoreCase("MyUpDown")) return somfy_commands::MyUpDown;
else if (string.equalsIgnoreCase("Prog")) return somfy_commands::Prog;
else if (string.equalsIgnoreCase("SunFlag")) return somfy_commands::SunFlag;
else if (string.equalsIgnoreCase("StepUp")) return somfy_commands::StepUp;
else if (string.equalsIgnoreCase("StepDown")) return somfy_commands::StepDown;
else if (string.equalsIgnoreCase("Flag")) return somfy_commands::Flag;
else if (string.equalsIgnoreCase("Sensor")) return somfy_commands::Sensor;
else if (string.equalsIgnoreCase("Toggle")) return somfy_commands::Toggle;
else if (string.equalsIgnoreCase("Favorite")) return somfy_commands::Favorite;
else if (string.equalsIgnoreCase("Stop")) return somfy_commands::Stop;
// The names translateSomfyCommand(cmd) reports so they parse back.
else if (string.equalsIgnoreCase("Sun Flag")) return somfy_commands::SunFlag;
else if (string.equalsIgnoreCase("Step Up")) return somfy_commands::StepUp;
else if (string.equalsIgnoreCase("Step Down")) return somfy_commands::StepDown;
else if (string.equalsIgnoreCase("My+Up")) return somfy_commands::MyUp;
else if (string.equalsIgnoreCase("Up+Down")) return somfy_commands::UpDown;
else if (string.equalsIgnoreCase("My+Down")) return somfy_commands::MyDown;
else if (string.equalsIgnoreCase("My+Up+Down")) return somfy_commands::MyUpDown;
else if (string.startsWith("fav") || string.startsWith("FAV")) return somfy_commands::Favorite;
else if (string.startsWith("mud") || string.startsWith("MUD")) return somfy_commands::MyUpDown;
else if (string.startsWith("md") || string.startsWith("MD")) return somfy_commands::MyDown;
else if (string.startsWith("ud") || string.startsWith("UD")) return somfy_commands::UpDown;
else if (string.startsWith("mu") || string.startsWith("MU")) return somfy_commands::MyUp;
else if (string.startsWith("su") || string.startsWith("SU")) return somfy_commands::StepUp;
else if (string.startsWith("sd") || string.startsWith("SD")) return somfy_commands::StepDown;
else if (string.startsWith("sen") || string.startsWith("SEN")) return somfy_commands::Sensor;
else if (string.startsWith("p") || string.startsWith("P")) return somfy_commands::Prog;
else if (string.startsWith("u") || string.startsWith("U")) return somfy_commands::Up;
else if (string.startsWith("d") || string.startsWith("D")) return somfy_commands::Down;
else if (string.startsWith("m") || string.startsWith("M")) return somfy_commands::My;
else if (string.startsWith("f") || string.startsWith("F")) return somfy_commands::Flag;
else if (string.startsWith("s") || string.startsWith("S")) return somfy_commands::SunFlag;
else if (string.startsWith("t") || string.startsWith("T")) return somfy_commands::Toggle;
else if (string.length() == 1) return static_cast<somfy_commands>(strtol(string.c_str(), nullptr, 16));
else return somfy_commands::My;
}
String translateSomfyCommand(const somfy_commands cmd) {
switch (cmd) {
case somfy_commands::Up:
return "Up";
case somfy_commands::Down:
return "Down";
case somfy_commands::My:
return "My";
case somfy_commands::MyUp:
return "My+Up";
case somfy_commands::UpDown:
return "Up+Down";
case somfy_commands::MyDown:
return "My+Down";
case somfy_commands::MyUpDown:
return "My+Up+Down";
case somfy_commands::Prog:
return "Prog";
case somfy_commands::SunFlag:
return "Sun Flag";
case somfy_commands::Flag:
return "Flag";
case somfy_commands::StepUp:
return "Step Up";
case somfy_commands::StepDown:
return "Step Down";
case somfy_commands::Sensor:
return "Sensor";
case somfy_commands::Toggle:
return "Toggle";
case somfy_commands::Favorite:
return "Favorite";
case somfy_commands::Stop:
return "Stop";
default:
return "Unknown(" + String((uint8_t)cmd) + ")";
}
}
somfy_commands flipCommand(somfy_commands cmd) {
switch(cmd) {
case somfy_commands::Up:
return somfy_commands::Down;
case somfy_commands::MyUp:
return somfy_commands::MyDown;
case somfy_commands::Down:
return somfy_commands::Up;
case somfy_commands::MyDown:
return somfy_commands::MyUp;
case somfy_commands::StepUp:
return somfy_commands::StepDown;
case somfy_commands::StepDown:
return somfy_commands::StepUp;
default:
return cmd;
}
}
byte somfy_frame_t::calc80Checksum(byte b0, byte b1, byte b2) {
byte cs80 = 0;
cs80 = (((b0 & 0xF0) >> 4) ^ ((b1 & 0xF0) >> 4));
cs80 ^= ((b2 & 0xF0) >> 4);
cs80 ^= (b0 & 0x0F);
cs80 ^= (b1 & 0x0F);
return cs80;
}
void somfy_frame_t::decodeFrame(byte* frame) {
byte decoded[10];
decoded[0] = frame[0];
// The last 3 bytes are not encoded even on 80-bits. Go figure.
decoded[7] = frame[7];
decoded[8] = frame[8];
decoded[9] = frame[9];
for (byte i = 1; i < 7; i++) {
decoded[i] = frame[i] ^ frame[i - 1];
}
byte checksum = 0;
// We only want the upper nibble for the command byte.
for (byte i = 0; i < 7; i++) {
if (i == 1) checksum = checksum ^ (decoded[i] >> 4);
else checksum = checksum ^ decoded[i] ^ (decoded[i] >> 4);
}
checksum &= 0b1111; // We keep the last 4 bits only
this->checksum = decoded[1] & 0b1111;
this->encKey = decoded[0];
// Lets first determine the protocol.
this->rawCmd = decoded[1] >> 4;
this->cmd = (somfy_commands)(this->rawCmd);
if(this->cmd == somfy_commands::RTWProto) {
if(this->encKey >= 160) {
this->proto = radio_proto::RTS;
if(this->encKey == 164) this->cmd = somfy_commands::Toggle;
}
else if(this->encKey > 148) {
this->proto = radio_proto::RTV;
this->cmd = (somfy_commands)(this->encKey - 148);
}
else if(this->encKey >= 133) {
this->proto = radio_proto::RTW;
this->cmd = (somfy_commands)(this->encKey - 132);
}
}
else this->proto = radio_proto::RTS;
// We reuse this memory address so we must reset the processed
// flag. This will ensure we can see frames on the first beat.
this->processed = false;
this->rollingCode = decoded[3] + (decoded[2] << 8);
this->remoteAddress = (decoded[6] + (decoded[5] << 8) + (decoded[4] << 16));
this->valid = this->checksum == checksum && this->remoteAddress > 0 && this->remoteAddress < 16777215;
if (this->cmd != somfy_commands::Sensor && this->valid) this->valid = (this->rollingCode > 0);
// Next lets process some of the RTS extensions for 80-bit frames
if(this->valid && this->proto == radio_proto::RTS && this->bitLength == 80) {
// Do a parity checksum on the 80 bit data.
if((decoded[9] & 0x0F) != this->calc80Checksum(decoded[7], decoded[8], decoded[9])) this->valid = false;
if(this->valid) {
// Translate extensions for stop and favorite.
if(this->cmd == somfy_commands::My) this->cmd = (somfy_commands)((decoded[1] >> 4) | ((decoded[8] & 0x0F) << 4));
// Bit packing to get the step size prohibits translation on the byte.
else if(this->cmd == somfy_commands::StepDown) this->cmd = (somfy_commands)((decoded[1] >> 4) | ((decoded[8] & 0x08) << 4));
}
}
if (this->valid) {
// Check for valid command.
switch (this->cmd) {
//case somfy_commands::Unknown0:
case somfy_commands::My:
case somfy_commands::Up:
case somfy_commands::MyUp:
case somfy_commands::Down:
case somfy_commands::MyDown:
case somfy_commands::UpDown:
case somfy_commands::MyUpDown:
case somfy_commands::Prog:
case somfy_commands::Flag:
case somfy_commands::SunFlag:
case somfy_commands::Sensor:
break;
case somfy_commands::UnknownD:
case somfy_commands::RTWProto:
this->valid = false;
break;
case somfy_commands::StepUp:
case somfy_commands::StepDown:
// Decode the step size.
this->stepSize = ((decoded[8] & 0x07) << 4) | ((decoded[9] & 0xF0) >> 4);
break;
case somfy_commands::Toggle:
case somfy_commands::Favorite:
case somfy_commands::Stop:
break;
default:
this->valid = false;
break;
}
}
if(this->valid && this->encKey == 0) this->valid = false;
if (!this->valid) {
ESP_LOGW(TAG, "INVALID FRAME KEY:%d ADDR:%d CMD:%s RCODE:%d",
this->encKey, this->remoteAddress,
translateSomfyCommand(this->cmd).c_str(), this->rollingCode);
ESP_LOGD(TAG, " KEY 1 2 3 4 5 6 ");
ESP_LOGD(TAG, "--------------------------------");
char enc_buf[64], dec_buf[64];
int enc_pos = 0, dec_pos = 0;
enc_pos += snprintf(enc_buf + enc_pos, sizeof(enc_buf) - enc_pos, "ENC ");
dec_pos += snprintf(dec_buf + dec_pos, sizeof(dec_buf) - dec_pos, "DEC ");
for (byte i = 0; i < 10; i++) {
enc_pos += snprintf(enc_buf + enc_pos, sizeof(enc_buf) - enc_pos, "%3d ", frame[i]);
dec_pos += snprintf(dec_buf + dec_pos, sizeof(dec_buf) - dec_pos, "%3d ", decoded[i]);
}
ESP_LOGD(TAG, "%s", enc_buf);
ESP_LOGD(TAG, "%s", dec_buf);
}
}
byte somfy_frame_t::encode80Byte7(byte start, uint8_t repeat) {
while((repeat * 4) + start > 255) repeat -= 15;
return start + (repeat * 4);
}
void somfy_frame_t::encode80BitFrame(byte *frame, uint8_t repeat) {
switch(this->cmd) {
// Step up and down commands encode the step size into the last 3 bytes.
case somfy_commands::StepUp:
if(repeat == 0) frame[1] = (static_cast<byte>(somfy_commands::StepDown) << 4) | (frame[1] & 0x0F);
if(this->stepSize == 0) this->stepSize = 1;
frame[7] = 132; // For simplicity this appears to be constant.
frame[8] = ((this->stepSize & 0x70) >> 4) | 0x38;
frame[9] = ((this->stepSize & 0x0F) << 4);
frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]);
break;
case somfy_commands::StepDown:
if(repeat == 0) frame[1] = (static_cast<byte>(somfy_commands::StepDown) << 4) | (frame[1] & 0x0F);
if(this->stepSize == 0) this->stepSize = 1;
frame[7] = 132; // For simplicity this appears to be constant.
frame[8] = ((this->stepSize & 0x70) >> 4) | 0x30;
frame[9] = ((this->stepSize & 0x0F) << 4);
frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]);
break;
case somfy_commands::Favorite:
if(repeat == 0) frame[1] = (static_cast<byte>(somfy_commands::My) << 4) | (frame[1] & 0x0F);
frame[7] = repeat > 0 ? 132 : 196;
frame[8] = 44;
frame[9] = 0x90;
frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]);
break;
case somfy_commands::Stop:
if(repeat == 0) frame[1] = (static_cast<byte>(somfy_commands::My) << 4) | (frame[1] & 0x0F);
frame[7] = repeat > 0 ? 132 : 196;
frame[8] = 47;
frame[9] = 0xF0;
frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]);
break;
case somfy_commands::Toggle:
frame[0] = 164;
frame[1] |= 0xF0;
frame[7] = this->encode80Byte7(196, repeat);
frame[8] = 0;
frame[9] = 0x10;
frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]);
break;
case somfy_commands::Up:
frame[7] = this->encode80Byte7(196, repeat);
frame[8] = 32;
frame[9] = 0x00;
frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]);
break;
case somfy_commands::Down:
frame[7] = this->encode80Byte7(196, repeat);
frame[8] = 44;
frame[9] = 0x80;
frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]);
break;
case somfy_commands::Prog:
case somfy_commands::UpDown:
case somfy_commands::MyDown:
case somfy_commands::MyUp:
case somfy_commands::MyUpDown:
case somfy_commands::My:
frame[7] = this->encode80Byte7(196, repeat);
frame[8] = 0x00;
frame[9] = 0x10;
frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]);
break;
default:
break;
}
}
void somfy_frame_t::encodeFrame(byte *frame) {
const byte btn = static_cast<byte>(cmd);
this->valid = true;
frame[0] = this->encKey; // Encryption key. Doesn't matter much
frame[1] = (btn & 0x0F) << 4; // Which button did you press? The 4 LSB will be the checksum
frame[2] = this->rollingCode >> 8; // Rolling code (big endian)
frame[3] = this->rollingCode; // Rolling code
frame[4] = this->remoteAddress >> 16; // Remote address
frame[5] = this->remoteAddress >> 8; // Remote address
frame[6] = this->remoteAddress; // Remote address
frame[7] = 132;
frame[8] = 0;
frame[9] = 29;
// Ok so if this is an RTW things are a bit different.
if(this->proto == radio_proto::RTW) {
frame[1] = 0xF0;
switch(this->cmd) {
case somfy_commands::My:
frame[0] = 133;
break;
case somfy_commands::Up:
frame[0] = 134;
break;
case somfy_commands::MyUp:
frame[0] = 135;
break;
case somfy_commands::Down:
frame[0] = 136;
break;
case somfy_commands::MyDown:
frame[0] = 137;
break;
case somfy_commands::UpDown:
frame[0] = 138;
break;
case somfy_commands::MyUpDown:
frame[0] = 139;
break;
case somfy_commands::Prog:
frame[0] = 140;
break;
case somfy_commands::SunFlag:
frame[0] = 141;
break;
case somfy_commands::Flag:
frame[0] = 142;
break;
default:
break;
}
}
else if(this->proto == radio_proto::RTV) {
frame[1] = 0xF0;
switch(this->cmd) {
case somfy_commands::My:
frame[0] = 149;
break;
case somfy_commands::Up:
frame[0] = 150;
break;
case somfy_commands::MyUp:
frame[0] = 151;
break;
case somfy_commands::Down:
frame[0] = 152;
break;
case somfy_commands::MyDown:
frame[0] = 153;
break;
case somfy_commands::UpDown:
frame[0] = 154;
break;
case somfy_commands::MyUpDown:
frame[0] = 155;
break;
case somfy_commands::Prog:
frame[0] = 156;
break;
case somfy_commands::SunFlag:
frame[0] = 157;
break;
case somfy_commands::Flag:
frame[0] = 158;
break;
default:
break;
}
}
else {
if(this->bitLength == 80) this->encode80BitFrame(&frame[0], this->repeats);
}
byte checksum = 0;
for (byte i = 0; i < 7; i++) {
checksum = checksum ^ frame[i] ^ (frame[i] >> 4);
}
checksum &= 0b1111; // We keep the last 4 bits only
// Checksum integration
frame[1] |= checksum;
// Obfuscation: a XOR of all the bytes
for (byte i = 1; i < 7; i++) {
frame[i] ^= frame[i - 1];
}
}
void somfy_frame_t::print() {
ESP_LOGD(TAG, "----------- Receiving -------------");
ESP_LOGD(TAG, "RSSI:%d LQI:%d", this->rssi, this->lqi);
ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d",
translateSomfyCommand(this->cmd).c_str(), this->remoteAddress, this->rollingCode);
ESP_LOGD(TAG, "KEY:%02X CS:%d", this->encKey, this->checksum);
}
bool somfy_frame_t::isSynonym(somfy_frame_t &frame) { return this->remoteAddress == frame.remoteAddress && this->cmd != frame.cmd && this->rollingCode == frame.rollingCode; }
bool somfy_frame_t::isRepeat(somfy_frame_t &frame) { return this->remoteAddress == frame.remoteAddress && this->cmd == frame.cmd && this->rollingCode == frame.rollingCode; }
void somfy_frame_t::copy(somfy_frame_t &frame) {
if(this->isRepeat(frame)) {
this->repeats++;
this->rssi = frame.rssi;
this->lqi = frame.lqi;
}
else {
this->synonym = this->isSynonym(frame);
this->valid = frame.valid;
if(!this->synonym) this->processed = frame.processed;
this->rssi = frame.rssi;
this->lqi = frame.lqi;
this->cmd = frame.cmd;
this->remoteAddress = frame.remoteAddress;
this->rollingCode = frame.rollingCode;
this->encKey = frame.encKey;
this->checksum = frame.checksum;
this->hwsync = frame.hwsync;
this->repeats = frame.repeats;
}
}

72
src/SomfyFrame.h Normal file
View file

@ -0,0 +1,72 @@
#ifndef SOMFY_FRAME_H
#define SOMFY_FRAME_H
#include <Arduino.h>
// The RTS frame codec and command names. Kept free of the radio, storage and
// network so it can be unit tested on the PC (pio test -e native).
enum class radio_proto : byte { // Ordinal byte 0-255
RTS = 0x00,
RTW = 0x01,
RTV = 0x02,
GP_Relay = 0x08,
GP_Remote = 0x09
};
enum class somfy_commands : byte {
Unknown0 = 0x0,
My = 0x1,
Up = 0x2,
MyUp = 0x3,
Down = 0x4,
MyDown = 0x5,
UpDown = 0x6,
MyUpDown = 0x7,
Prog = 0x8,
SunFlag = 0x9,
Flag = 0xA,
StepDown = 0xB,
Toggle = 0xC,
UnknownD = 0xD,
Sensor = 0xE,
RTWProto = 0xF, // RTW Protocol
// Command extensions for 80 bit frames
StepUp = 0x8B,
Favorite = 0xC1,
Stop = 0xF1
};
String translateSomfyCommand(const somfy_commands cmd);
somfy_commands translateSomfyCommand(const String& string);
// The command with up and down swapped, for motors wired the other way round.
somfy_commands flipCommand(somfy_commands cmd);
struct somfy_rx_t;
struct somfy_frame_t {
bool valid = false;
bool processed = false;
bool synonym = false;
radio_proto proto = radio_proto::RTS;
int rssi = 0;
byte lqi = 0x0;
somfy_commands cmd;
uint32_t remoteAddress = 0;
uint16_t rollingCode = 0;
uint8_t encKey = 0xA7;
uint8_t checksum = 0;
uint8_t hwsync = 0;
uint8_t repeats = 0;
uint32_t await = 0;
uint8_t bitLength = 56;
uint16_t pulseCount = 0;
uint8_t stepSize = 0;
uint8_t rawCmd = 0;
void print();
void encode80BitFrame(byte *frame, uint8_t repeat);
byte calc80Checksum(byte b0, byte b1, byte b2);
byte encode80Byte7(byte start, uint8_t repeat);
void encodeFrame(byte *frame);
void decodeFrame(byte* frame);
void decodeFrame(somfy_rx_t *rx);
bool isRepeat(somfy_frame_t &f);
bool isSynonym(somfy_frame_t &f);
void copy(somfy_frame_t &f);
};
#endif

44
src/Timing.h Normal file
View file

@ -0,0 +1,44 @@
#ifndef timing_h
#define timing_h
#include <Arduino.h>
#include "esp_log.h"
// Timings shorter than this are not logged. Every line costs several ms on a
// 115200 baud console, and per-request timings would add dozens of lines to
// every page load on the task that serves the sockets. Build with
// -DTIMING_LOG_MIN_MS=0 to see every timing.
#ifndef TIMING_LOG_MIN_MS
#define TIMING_LOG_MIN_MS 100
#endif
// Anything that takes at least this long is logged as a warning so the slow
// calls stand out from the routine timings.
#ifndef TIMING_SLOW_MS
#define TIMING_SLOW_MS 1000
#endif
// Measures elapsed time in microseconds. micros() wraps every ~71 minutes but
// the unsigned subtraction keeps any span shorter than that correct.
class ElapsedTimer {
uint32_t _start;
public:
ElapsedTimer() : _start(micros()) {}
void restart() { _start = micros(); }
uint32_t us() const { return micros() - _start; }
};
// Whether logElapsed() would print a timing this long. Check it before
// building the description so the common, fast case costs nothing.
static inline bool worthLogging(uint32_t us) { return us / 1000 >= TIMING_LOG_MIN_MS; }
// Logs how long something took as milliseconds with microsecond precision.
static inline void logElapsed(const char *tag, const char *what, uint32_t us) {
if(!worthLogging(us)) return;
const unsigned long ms = us / 1000, frac = us % 1000;
if(ms >= TIMING_SLOW_MS) ESP_LOGW(tag, "%s took %lu.%03lums (slow)", what, ms, frac);
else ESP_LOGI(tag, "%s took %lu.%03lums", what, ms, frac);
}
// For calls that run constantly, like loop(), where logging every pass would
// flood the console. Only logs once the time reaches minMs.
static inline void logIfSlow(const char *tag, const char *what, uint32_t us, uint32_t minMs) {
if(us >= minMs * 1000) logElapsed(tag, what, us);
}
#endif

78
src/TxBuffer.cpp Normal file
View file

@ -0,0 +1,78 @@
#include "TxBuffer.h"
#include "esp_log.h"
// Logged as part of the radio code it came from.
static const char *TAG = "Somfy";
int8_t somfy_tx_queue_t::oldest() const {
// The newest index is at position 0 so the oldest is the last one in use.
for(int8_t i = MAX_TX_BUFFER - 1; i >= 0; i--) {
if(this->index[i] < MAX_TX_BUFFER) return i;
}
return -1;
}
bool somfy_tx_queue_t::peek(somfy_tx_t *tx) const {
const int8_t i = this->oldest();
if(i < 0) return false;
memcpy(tx, &this->items[this->index[i]], sizeof(somfy_tx_t));
return true;
}
bool somfy_tx_queue_t::pop(somfy_tx_t *tx) {
const int8_t i = this->oldest();
if(i < 0) return false;
uint8_t ndx = this->index[i];
memcpy(tx, &this->items[ndx], sizeof(somfy_tx_t));
this->items[ndx].clear();
if(this->length > 0) this->length--;
this->index[i] = 255;
return true;
}
void somfy_tx_queue_t::push(uint8_t hwsync, uint8_t *payload, uint8_t bit_length) {
if(this->length >= MAX_TX_BUFFER) {
// We have overflowed the buffer simply empty the last item
// in this instance we will simply throw it away.
uint8_t ndx = this->index[MAX_TX_BUFFER - 1];
if(ndx < MAX_TX_BUFFER) this->items[ndx].clear();
this->index[MAX_TX_BUFFER - 1] = 255;
this->length--;
}
uint8_t first = 0;
// Place this record in the first empty slot. There will
// be one since we cleared a space above should there
// be an overflow.
for(uint8_t i = 0; i < MAX_TX_BUFFER; i++) {
if(this->items[i].bit_length == 0) {
first = i;
this->items[i].bit_length = bit_length;
this->items[i].hwsync = hwsync;
memcpy(&this->items[i].payload, payload, sizeof(this->items[i].payload));
break;
}
}
// Move the index so that it is the at position 0. The oldest item will fall off.
for(uint8_t i = MAX_TX_BUFFER - 1; i > 0; i--) {
this->index[i] = this->index[i - 1];
}
this->length++;
// When popping from the queue we always pull from the end
this->index[0] = first;
}
void encodeTxJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat) {
frame.encodeFrame(job.tx.payload);
job.tx.hwsync = frame.bitLength == 56 ? 2 : 12;
job.tx.bit_length = frame.bitLength;
job.frame = frame;
if(repeat > TX_MAX_REPEATS) {
ESP_LOGW(TAG, "Limiting %u repeats to %u", repeat, TX_MAX_REPEATS);
repeat = TX_MAX_REPEATS;
}
job.repeats = repeat;
}
void encodeRepeatJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat) {
encodeTxJob(job, frame, repeat);
job.firstRepeat = frame.repeats + 2;
}
void noteRepeatQueued(somfy_frame_t &frame, const somfy_tx_job_t &job) {
// Only advance the counter for frames that will go on air.
frame.repeats += job.repeats + 1;
}

91
src/TxBuffer.h Normal file
View file

@ -0,0 +1,91 @@
#ifndef TX_BUFFER_H
#define TX_BUFFER_H
#include <Arduino.h>
#include "SomfyFrame.h"
// Repeater frames waiting to be handed to the radio task, and the jobs and
// events passed to and from it. Kept free of the radio and timers so it can be
// unit tested on the PC (pio test -e native).
#define MAX_TX_BUFFER 5
#define TX_JOB_QUEUE_SIZE 32
// Slots only priority jobs (stops) may use so a stop always fits in the queue.
#define TX_RESERVED_SLOTS 4
// The radio task bit-bangs a whole job on loop()'s core at a higher priority
// so loop() only runs between jobs. A frame takes about 140ms so this keeps a
// job around 7s, well inside the 15s task watchdog loop() is subscribed to.
#define TX_MAX_REPEATS 50
struct somfy_tx_t {
void clear() {
this->hwsync = 0;
this->bit_length = 0;
memset(this->payload, 0x00, sizeof(this->payload));
}
uint8_t hwsync = 0;
uint8_t bit_length = 0;
uint8_t payload[10] = {};
};
struct somfy_tx_queue_t {
somfy_tx_queue_t() { this->clear(); }
void clear() {
for (uint8_t i = 0; i < MAX_TX_BUFFER; i++) {
this->index[i] = 255;
this->items[i].clear();
}
this->length = 0;
}
unsigned long delay_time = 0; // Set by the caller, not before this millis().
uint8_t length = 0;
uint8_t index[MAX_TX_BUFFER] = {255};
somfy_tx_t items[MAX_TX_BUFFER];
// The oldest frame without removing it, so it can stay queued until the radio
// takes it.
bool peek(somfy_tx_t *tx) const;
bool pop(somfy_tx_t *tx);
// When full the oldest frame is thrown away.
void push(uint8_t hwsync, byte *payload, uint8_t bit_length);
private:
// Position in index of the oldest frame, -1 when empty.
int8_t oldest() const;
};
// A transmission handed off to the radio task. The first frame is encoded by
// the producer into tx. The frame is a copy so the radio task never touches the
// remote's lastFrame and is only used to re-encode 80-bit repeat frames.
struct somfy_tx_job_t {
somfy_tx_t tx; // Encoded first frame with its hw sync count and bit length.
somfy_frame_t frame;
uint8_t repeats = 0; // Number of repeat frames after the first frame.
uint8_t firstRepeat = 1; // 80-bit repeat counter used for the first repeat frame.
bool raw = false; // Repeater frame. Waits for the receiver to be idle.
// Set by RadioScheduler::submit().
bool priority = false; // May use the reserved slots.
bool front = false; // Queued ahead of the back. Only for priority jobs.
uint32_t seq = 0; // Ticket the job's events are matched up by.
uint32_t notBefore = 0; // millis() before which the job must not go on air (0 = none).
};
enum class tx_event_t : uint8_t {
started, // The job is going on air.
finished, // Every frame of the job went on air.
aborted, // The job was cut short by a flush or a radio reconfiguration.
dropped // The job was flushed and will never go on air.
};
// Reported by the radio task for every queued non-repeater job.
struct somfy_tx_event_t {
tx_event_t type = tx_event_t::started;
somfy_commands cmd = somfy_commands::My;
uint32_t remoteAddress = 0;
uint32_t seq = 0;
uint32_t at = 0; // millis() when the job started or was dropped.
};
// Encodes the first frame of a transmission into the job. Encoding here keeps
// its side effects on the frame (valid flag, default step size) visible to the
// caller and the radio task only re-encodes the 80-bit repeat frames. More
// than TX_MAX_REPEATS repeats are cut down to it.
void encodeTxJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat);
// Encodes a repeat of the frame last sent. The first frame carries the
// frame's current repeat counter and the radio task counts up from there.
void encodeRepeatJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat);
// Advances the frame's repeat counter past a repeat job the radio took.
void noteRepeatQueued(somfy_frame_t &frame, const somfy_tx_job_t &job);
#endif

View file

@ -120,8 +120,9 @@ int Timestamp::calcTZOffset(time_t *dt) {
localtime_r(dt, &tmLocal);
long diff = mktime(&tmLocal) - mktime(&tmUTC);
if(tmLocal.tm_isdst) diff += 3600;
// Encoded as hhmm, e.g. +05:30 is 530 and -03:30 is -330.
int hrs = (int)((diff/3600) * 100);
int mins = diff - (hrs * 36);
int mins = (int)((diff % 3600) / 60);
return hrs + mins;
}
int Timestamp::tzOffset() {
@ -129,3 +130,17 @@ int Timestamp::tzOffset() {
time(&now);
return Timestamp::calcTZOffset(&now);
}
bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, uint32_t sinceLastMs, int minDelta, uint32_t minMs) {
if(channel != lastChannel) return true;
int delta = rssi > lastRssi ? rssi - lastRssi : lastRssi - rssi;
return delta >= minDelta && sinceLastMs >= minMs;
}
bool RssiBroadcast::due(int rssi, int channel, uint32_t now, int minDelta, uint32_t minMs) const {
return rssiNeedsEmit(rssi, this->rssi, channel, this->channel, now - this->at, minDelta, minMs);
}
void RssiBroadcast::sent(bool broadcast, int rssi, int channel, uint32_t now) {
if(!broadcast) return;
this->rssi = rssi;
this->channel = channel;
this->at = now;
}

View file

@ -56,6 +56,19 @@ struct rebootDelay_t {
else if(str[0] == 'f' || str[0] == 'F' || str[0] == '0') return false;
return def;
}
// Whether a wifiStrength broadcast is worth sending: always on a channel change,
// otherwise only for an RSSI change of at least minDelta dBm and not more often
// than every minMs.
bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, uint32_t sinceLastMs, int minDelta, uint32_t minMs);
// What the last wifiStrength broadcast told every client. Only broadcasts
// move it: an emit to one new client tells the others nothing.
struct RssiBroadcast {
int rssi = 0;
int channel = 0;
uint32_t at = 0; // millis() of the last broadcast.
bool due(int rssi, int channel, uint32_t now, int minDelta, uint32_t minMs) const;
void sent(bool broadcast, int rssi, int channel, uint32_t now);
};
class Timestamp {
char _timeBuffer[128];

View file

@ -1,220 +1,102 @@
#include "WResp.h"
#include "esp_log.h"
#include "Timing.h"
static const char *TAG = "WResp";
#define EMIT_SLOW_MS 20
void JsonSockEvent::beginEvent(AsyncWebSocket *server, const char *evt, char *buff, size_t buffSize) {
this->server = server;
this->_evt = evt;
this->buff = buff;
this->buffSize = buffSize;
this->_nocomma = true;
this->_closed = false;
snprintf(this->buff, buffSize, "42[%s,", evt);
this->_overflow = false;
const int n = snprintf(this->buff, buffSize, "42[%s,", evt);
this->_len = n < 0 ? 0 : ((size_t)n < buffSize ? (size_t)n : buffSize - 1);
}
void JsonSockEvent::closeEvent() {
if(!this->_closed) {
if(strlen(this->buff) < buffSize) strcat(this->buff, "]");
else this->buff[buffSize - 1] = ']';
// The ] and the terminator both have to fit.
if(this->_len + 1 < buffSize) this->_appendAt("]", false, 1);
else this->_overflow = true;
}
this->_nocomma = true;
this->_closed = true;
}
void JsonSockEvent::endEvent(uint32_t clientId) {
this->closeEvent();
if(clientId == 0) this->server->textAll(this->buff);
else this->server->text(clientId, this->buff);
}
void JsonSockEvent::_safecat(const char *val, bool escape) {
size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff);
if(escape) len += 2;
if(len >= this->buffSize) {
ESP_LOGE(TAG, "Socket exceeded buffer size %d - %d: %s", this->buffSize, len, this->buff);
// Part of it is missing so clients would only fail to parse it.
if(this->_overflow) {
ESP_LOGE(TAG, "Socket event too large for %u bytes, not sent: %.32s", (unsigned)this->buffSize, this->buff);
return;
}
if(escape) strcat(this->buff, "\"");
if(escape) this->escapeString(val, &this->buff[strlen(this->buff)]);
else strcat(this->buff, val);
if(escape) strcat(this->buff, "\"");
ElapsedTimer timer;
if(clientId == 0) this->server->textAll(this->buff);
else this->server->text(clientId, this->buff);
// Sends happen every few seconds, so only the slow ones are worth a line.
const uint32_t us = timer.us();
if(us < EMIT_SLOW_MS * 1000) return;
char what[80];
if(clientId == 0) snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to all", this->_evt, (unsigned)this->_len);
else snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to %lu", this->_evt, (unsigned)this->_len, (unsigned long)clientId);
logElapsed(TAG, what, us);
}
void BufferedJsonFormatter::setBuffer(char *b, size_t sz) {
this->buff = b;
this->buffSize = sz;
this->buff[0] = 0;
this->_nocomma = true;
this->_objects = 0;
this->_arrays = 0;
}
size_t BufferedJsonFormatter::length() const {
return strlen(this->buff);
void JsonSockEvent::_safecat(const char *val, bool escape) {
const size_t vlen = escape ? this->calcEscapedLength(val) + 2 : strlen(val);
if(this->_len + vlen >= this->buffSize) {
this->_overflow = true;
return;
}
this->_appendAt(val, escape, vlen);
}
void AsyncJsonResp::beginResponse(AsyncWebServerRequest *request, char *buff, size_t buffSize) {
this->_request = request;
this->buff = buff;
this->buffSize = buffSize;
this->buff[0] = 0x00;
this->_len = 0;
this->_nocomma = true;
this->_headersSent = false;
this->_overflow = false;
this->_stream = request->beginResponseStream("application/json");
}
void AsyncJsonResp::endResponse() {
if(strlen(this->buff)) this->flush();
if(this->_len) this->flush();
if(this->_request && this->_stream) {
this->_request->send(this->_stream);
}
}
void AsyncJsonResp::flush() {
if(this->_stream && strlen(this->buff) > 0) {
this->_stream->print(this->buff);
if(this->_stream && this->_len > 0) {
this->_stream->write((const uint8_t *)this->buff, this->_len);
this->buff[0] = 0x00;
this->_len = 0;
}
}
void AsyncJsonResp::_safecat(const char *val, bool escape) {
size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff);
if(escape) len += 2;
if(len >= this->buffSize) {
const size_t vlen = escape ? this->calcEscapedLength(val) + 2 : strlen(val);
if(vlen + this->_len >= this->buffSize) {
this->flush();
}
if(escape) strcat(this->buff, "\"");
if(escape) this->escapeString(val, &this->buff[strlen(this->buff)]);
else strcat(this->buff, val);
if(escape) strcat(this->buff, "\"");
}
void JsonFormatter::beginObject(const char *name) {
if(name && strlen(name) > 0) this->appendElem(name);
else if(!this->_nocomma) this->_safecat(",");
this->_safecat("{");
this->_objects++;
this->_nocomma = true;
}
void JsonFormatter::endObject() {
//if(strlen(this->buff) + 1 > this->buffSize - 1) this->send();
this->_safecat("}");
this->_objects--;
this->_nocomma = false;
}
void JsonFormatter::beginArray(const char *name) {
if(name && strlen(name) > 0) this->appendElem(name);
else if(!this->_nocomma) this->_safecat(",");
this->_safecat("[");
this->_arrays++;
this->_nocomma = true;
}
void JsonFormatter::endArray() {
//if(strlen(this->buff) + 1 > this->buffSize - 1) this->send();
this->_safecat("]");
this->_arrays--;
this->_nocomma = false;
}
void JsonFormatter::appendElem(const char *name) {
if(!this->_nocomma) this->_safecat(",");
if(name && strlen(name) > 0) {
this->_safecat(name, true);
this->_safecat(":");
}
this->_nocomma = false;
}
void JsonFormatter::addElem(const char *name, const char *val) {
if(!val) return;
this->appendElem(name);
this->_safecat(val, true);
}
void JsonFormatter::addElem(const char *val) { this->addElem(nullptr, val); }
void JsonFormatter::addElem(float fval) { sprintf(this->_numbuff, "%.4f", fval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(int8_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(uint8_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(int32_t nval) { sprintf(this->_numbuff, "%ld", (long)nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(uint32_t nval) { sprintf(this->_numbuff, "%lu", (unsigned long)nval); this->_appendNumber(nullptr); }
/*
void JsonFormatter::addElem(int16_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(uint16_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(int64_t lval) { sprintf(this->_numbuff, "%lld", (long long)lval); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(uint64_t lval) { sprintf(this->_numbuff, "%llu", (unsigned long long)lval); this->_appendNumber(nullptr); }
*/
void JsonFormatter::addElem(bool bval) { strcpy(this->_numbuff, bval ? "true" : "false"); this->_appendNumber(nullptr); }
void JsonFormatter::addElem(const char *name, float fval) { sprintf(this->_numbuff, "%.4f", fval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, int8_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, uint8_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, int32_t nval) { sprintf(this->_numbuff, "%ld", (long)nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, uint32_t nval) { sprintf(this->_numbuff, "%lu", (unsigned long)nval); this->_appendNumber(name); }
/*
void JsonFormatter::addElem(const char *name, int16_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, uint16_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, int64_t lval) { sprintf(this->_numbuff, "%lld", (long long)lval); this->_appendNumber(name); }
*/
void JsonFormatter::addElem(const char *name, uint64_t lval) { sprintf(this->_numbuff, "%llu", (unsigned long long)lval); this->_appendNumber(name); }
void JsonFormatter::addElem(const char *name, bool bval) { strcpy(this->_numbuff, bval ? "true" : "false"); this->_appendNumber(name); }
void JsonFormatter::_safecat(const char *val, bool escape) {
size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff);
if(escape) len += 2;
if(len >= this->buffSize) {
return;
}
if(escape) strcat(this->buff, "\"");
if(escape) this->escapeString(val, &this->buff[strlen(this->buff)]);
else strcat(this->buff, val);
if(escape) strcat(this->buff, "\"");
}
void JsonFormatter::_appendNumber(const char *name) { this->appendElem(name); this->_safecat(this->_numbuff); }
uint32_t JsonFormatter::calcEscapedLength(const char *raw) {
uint32_t len = 0;
for(size_t i = strlen(raw); i > 0; i--) {
switch(raw[i]) {
case '"':
case '/':
case '\b':
case '\f':
case '\n':
case '\r':
case '\t':
case '\\':
len += 2;
break;
default:
len++;
break;
}
}
return len;
}
void JsonFormatter::escapeString(const char *raw, char *escaped) {
for(uint32_t i = 0; i < strlen(raw); i++) {
switch(raw[i]) {
case '"':
strcat(escaped, "\\\"");
break;
case '/':
strcat(escaped, "\\/");
break;
case '\b':
strcat(escaped, "\\b");
break;
case '\f':
strcat(escaped, "\\f");
break;
case '\n':
strcat(escaped, "\\n");
break;
case '\r':
strcat(escaped, "\\r");
break;
case '\t':
strcat(escaped, "\\t");
break;
case '\\':
strcat(escaped, "\\\\");
break;
default:
size_t len = strlen(escaped);
escaped[len] = raw[i];
escaped[len+1] = 0x00;
break;
if(vlen + this->_len >= this->buffSize) {
// Bigger than the whole buffer so stream it straight out.
if(!this->_stream) {
this->_overflow = true;
return;
}
if(!escape) {
this->_stream->print(val);
return;
}
char esc[7];
this->_stream->print("\"");
for(size_t i = 0; val[i]; i++) {
escapeChar(val[i], esc);
this->_stream->print(esc);
}
this->_stream->print("\"");
return;
}
}
this->_appendAt(val, escape, vlen);
}

View file

@ -2,61 +2,8 @@
#include "Somfy.h"
#ifndef wresp_h
#define wresp_h
#include "JsonFormatter.h"
class JsonFormatter {
protected:
char *buff;
size_t buffSize;
bool _headersSent = false;
uint8_t _objects = 0;
uint8_t _arrays = 0;
bool _nocomma = true;
char _numbuff[25] = {0};
virtual void _safecat(const char *val, bool escape = false);
void _appendNumber(const char *name);
public:
void escapeString(const char *raw, char *escaped);
uint32_t calcEscapedLength(const char *raw);
void beginObject(const char *name = nullptr);
void endObject();
void beginArray(const char *name = nullptr);
void endArray();
void appendElem(const char *name = nullptr);
void addElem(const char* val);
void addElem(float fval);
void addElem(int8_t nval);
void addElem(uint8_t nval);
/*
void addElem(int32_t nval);
void addElem(int16_t nval);
void addElem(uint16_t nval);
void addElem(unsigned int nval);
*/
void addElem(int32_t lval);
void addElem(uint32_t lval);
void addElem(bool bval);
void addElem(const char* name, float fval);
void addElem(const char* name, int8_t nval);
void addElem(const char* name, uint8_t nval);
/*
void addElem(const char* name, int nval);
void addElem(const char* name, int16_t nval);
void addElem(const char* name, uint16_t nval);
void addElem(const char* name, unsigned int nval);
*/
void addElem(const char* name, int32_t lval);
void addElem(const char* name, uint32_t lval);
void addElem(const char* name, bool bval);
void addElem(const char *name, const char *val);
void addElem(const char* name, uint64_t lval);
};
class BufferedJsonFormatter : public JsonFormatter {
public:
void setBuffer(char *b, size_t sz);
size_t length() const;
};
class AsyncJsonResp : public JsonFormatter {
protected:
void _safecat(const char *val, bool escape = false) override;
@ -70,6 +17,7 @@ class AsyncJsonResp : public JsonFormatter {
class JsonSockEvent : public JsonFormatter {
protected:
bool _closed = false;
const char *_evt = "";
void _safecat(const char *val, bool escape = false) override;
public:
AsyncWebSocket *server = nullptr;

View file

@ -14,6 +14,7 @@
#include "MQTT.h"
#include "GitOTA.h"
#include "ESPNetwork.h"
#include "Timing.h"
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
@ -31,6 +32,8 @@ extern ESPNetwork net;
//#define WEB_MAX_RESPONSE 34768
#define WEB_MAX_RESPONSE 4096
static char g_async_content[WEB_MAX_RESPONSE];
// /discovery logs where its time went when it takes at least this long.
#define DISCOVERY_SLOW_MS 50
// General responses
@ -45,14 +48,62 @@ static const char _encoding_json[] = "application/json";
static const char *TAG = "Web";
static QueueHandle_t webCmdQueue = nullptr;
static SemaphoreHandle_t webCmdDone = nullptr;
static void sendQueueFull(AsyncWebServerRequest *request);
static void sendRadioUnavailable(AsyncWebServerRequest *request);
// Hands a command that transmits to the main loop. When it can't be the
// request is answered with the reason and false is returned.
static bool queueRadioCommand(AsyncWebServerRequest *request, const web_command_t &cmd) {
// Nothing can go on air during a frequency scan so tell the client now rather
// than dropping the command once it reaches the radio.
if(somfy.transceiver.txSuspended()) { sendRadioUnavailable(request); return false; }
if(!webServer.queueCommand(cmd)) { sendQueueFull(request); return false; }
return true;
}
// Radio work for these handlers runs on the main loop via the command queue.
static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) {
web_command_t cmd = {};
cmd.type = web_cmd_t::set_my_position;
cmd.shadeId = shade->getShadeId();
cmd.position = pos;
cmd.tiltPosition = tilt;
return queueRadioCommand(request, cmd);
}
static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) {
web_command_t cmd = {};
cmd.type = web_cmd_t::unpair_shade;
cmd.shadeId = shade->getShadeId();
cmd.wasPaired = shade->paired;
if(!queueRadioCommand(request, cmd)) return false;
// Reflect the change in the response. The main loop saves it once the Prog
// is queued or puts it back if it can't be.
shade->paired = false;
return true;
}
// The radio and MQTT are shut down once an upload starts and only a reboot
// brings them back. The completion handler schedules it but never runs if the
// client goes away mid-upload, so make sure the device reboots then too.
static void onUploadDisconnect() {
if(webServer.uploadSuccess || rebootDelay.reboot) return;
ESP_LOGW(TAG, "Upload abandoned, rebooting");
if(Update.isRunning()) Update.abort();
rebootDelay.reboot = true;
rebootDelay.rebootTime = millis() + 500;
}
// Sent when a command can't be handed to the main loop.
static void sendQueueFull(AsyncWebServerRequest *request) {
request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Command queue full\"}"));
}
// Sent when the radio can't take a frame (suspended or queue full).
static void sendRadioUnavailable(AsyncWebServerRequest *request) {
request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Radio TX unavailable\"}"));
}
AsyncWebServer asyncServer(80);
AsyncWebServer asyncApiServer(8081);
void Web::startup() {
ESP_LOGI(TAG, "Launching web server...");
if(!webCmdQueue) webCmdQueue = xQueueCreate(WEB_CMD_QUEUE_SIZE, sizeof(web_command_t));
if(!webCmdDone) webCmdDone = xSemaphoreCreateBinary();
asyncServer.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) {
AsyncJsonResponse *response = new AsyncJsonResponse();
@ -74,36 +125,36 @@ void Web::loop() {
delay(1);
}
bool Web::queueCommand(const web_command_t &cmd) {
if(!webCmdQueue || !webCmdDone) return false;
// Clear any stale signal
xSemaphoreTake(webCmdDone, 0);
if(xQueueSend(webCmdQueue, &cmd, pdMS_TO_TICKS(100)) != pdTRUE) {
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;
}
// Wait for main loop to process it
if(xSemaphoreTake(webCmdDone, pdMS_TO_TICKS(WEB_CMD_TIMEOUT_MS)) != pdTRUE) {
ESP_LOGW(TAG, "Command queue timeout waiting for processing");
return false;
}
return true;
}
void Web::processQueue() {
if(!webCmdQueue || !webCmdDone) return;
if(!webCmdQueue) return;
web_command_t cmd;
while(xQueueReceive(webCmdQueue, &cmd, 0) == pdTRUE) {
// 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) shade->moveToTarget(shade->transformPosition(cmd.target));
else shade->sendCommand(cmd.command, cmd.repeat > 0 ? cmd.repeat : shade->repeats, cmd.stepSize);
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) group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize);
if(group) sent = group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize);
break;
}
case web_cmd_t::tilt_command: {
@ -118,16 +169,16 @@ void Web::processQueue() {
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)) shade->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : shade->repeats, cmd.stepSize);
else shade->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : shade->repeats);
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)) group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize);
else group->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : group->repeats);
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;
}
@ -143,7 +194,7 @@ void Web::processQueue() {
case web_cmd_t::shade_sensor: {
SomfyShade *shade = somfy.getShadeById(cmd.shadeId);
if(shade) {
shade->sendSensorCommand(cmd.windy, cmd.sunny, cmd.repeat >= 0 ? (uint8_t)cmd.repeat : shade->repeats);
sent = shade->sendSensorCommand(cmd.windy, cmd.sunny, cmd.repeat >= 0 ? (uint8_t)cmd.repeat : shade->repeats);
shade->emitState();
}
break;
@ -151,13 +202,34 @@ void Web::processQueue() {
case web_cmd_t::group_sensor: {
SomfyGroup *group = somfy.getGroupById(cmd.groupId);
if(group) {
group->sendSensorCommand(cmd.windy, cmd.sunny, cmd.repeat >= 0 ? (uint8_t)cmd.repeat : group->repeats);
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) 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();
}
else {
// The motor is still paired so undo what the response showed.
shade->paired = cmd.wasPaired;
shade->emitState();
}
}
break;
}
}
xSemaphoreGive(webCmdDone);
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) {
@ -238,7 +310,7 @@ static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) {
json.addElem("paired", shade->paired);
json.addElem("shadeType", static_cast<uint8_t>(shade->shadeType));
json.addElem("flipCommands", shade->flipCommands);
json.addElem("flipPosition", shade->flipCommands);
json.addElem("flipPosition", shade->flipPosition);
json.addElem("bitLength", shade->bitLength);
json.addElem("proto", static_cast<uint8_t>(shade->proto));
json.addElem("flags", shade->flags);
@ -461,8 +533,23 @@ private:
fmt.setBuffer(unit + offset, sizeof(unit) - offset);
}
// An item too big for the unit buffer is left out so the JSON stays valid.
// Skipping one produces nothing, so go on to the next until something is
// produced or the document is done.
void produceNext() {
consumed = 0;
do produceUnit(); while(unitLen == 0 && section != S_DONE);
}
// True when the unit just formatted didn't fit. It is logged and skipped.
bool overflowed(const char *what, uint8_t id) {
if(!fmt.overflowed()) return false;
ESP_LOGE(TAG, "/controller: %s %u doesn't fit in %u bytes, leaving it out", what, id, (unsigned)sizeof(unit));
unitLen = 0;
return true;
}
void produceUnit() {
unitLen = 0;
switch(section) {
case S_HEADER: {
@ -483,7 +570,9 @@ private:
serializeGitVersion(fmt);
fmt.endObject();
fmt.beginArray("rooms");
unitLen = strlen(unit);
// Only fixed size values so this can't happen short of a bug.
if(fmt.overflowed()) ESP_LOGE(TAG, "/controller: header doesn't fit in %u bytes", (unsigned)sizeof(unit));
unitLen = fmt.length();
section = S_ROOMS;
idx = 0;
firstInArray = true;
@ -505,7 +594,8 @@ private:
fmt.beginObject();
serializeRoom(&somfy.rooms[idx], fmt);
fmt.endObject();
unitLen = pos + strlen(unit + pos);
if(overflowed("room", somfy.rooms[idx].roomId)) { idx++; return; }
unitLen = pos + fmt.length();
idx++;
firstInArray = false;
return;
@ -527,7 +617,8 @@ private:
fmt.beginObject();
serializeShade(&somfy.shades[idx], fmt);
fmt.endObject();
unitLen = pos + strlen(unit + pos);
if(overflowed("shade", somfy.shades[idx].getShadeId())) { idx++; return; }
unitLen = pos + fmt.length();
idx++;
firstInArray = false;
return;
@ -550,7 +641,8 @@ private:
fmt.beginObject();
serializeGroupRef(g, fmt);
fmt.beginArray("linkedShades");
unitLen = pos + strlen(unit + pos);
if(overflowed("group", g->getGroupId())) { idx++; return; }
unitLen = pos + fmt.length();
lsIdx = 0;
firstInArray = false;
firstInLSArray = true;
@ -578,7 +670,8 @@ private:
fmt.beginObject();
serializeShadeRef(lshade, fmt);
fmt.endObject();
unitLen = pos + strlen(unit + pos);
if(overflowed("linked shade", lshade->getShadeId())) { lsIdx++; return; }
unitLen = pos + fmt.length();
lsIdx++;
firstInLSArray = false;
return;
@ -614,6 +707,10 @@ void Web::handleDiscovery(AsyncWebServerRequest *request) {
char connType[10] = "Unknown";
if(net.connType == conn_types_t::ethernet) strcpy(connType, "Ethernet");
else if(net.connType == conn_types_t::wifi) strcpy(connType, "Wifi");
// This handler takes about 200ms on the async_tcp task for no obvious
// reason, so time each part to find out where it goes.
ElapsedTimer total, step;
uint32_t usHead, usMem, usRooms, usShades, usGroups, usSend;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -627,6 +724,7 @@ void Web::handleDiscovery(AsyncWebServerRequest *request) {
resp.addElem("chipModel", settings.chipModel);
resp.addElem("connType", connType);
resp.addElem("checkForUpdate", settings.checkForUpdate);
usHead = step.us(); step.restart();
resp.beginObject("memory");
resp.addElem("max", ESP.getMaxAllocHeap());
resp.addElem("free", ESP.getFreeHeap());
@ -634,18 +732,27 @@ void Web::handleDiscovery(AsyncWebServerRequest *request) {
resp.addElem("total", ESP.getHeapSize());
resp.addElem("uptime", (uint64_t)millis());
resp.endObject();
usMem = step.us(); step.restart();
resp.beginArray("rooms");
serializeRooms(resp);
resp.endArray();
usRooms = step.us(); step.restart();
resp.beginArray("shades");
serializeShades(resp);
resp.endArray();
usShades = step.us(); step.restart();
resp.beginArray("groups");
serializeGroups(resp);
resp.endArray();
resp.endObject();
usGroups = step.us(); step.restart();
resp.endResponse();
usSend = step.us();
net.needsBroadcast = true;
if(total.us() >= DISCOVERY_SLOW_MS * 1000) {
ESP_LOGW(TAG, "discovery: head %.1f mem %.1f rooms %.1f shades %.1f groups %.1f send %.1f ms",
usHead / 1000.0f, usMem / 1000.0f, usRooms / 1000.0f, usShades / 1000.0f, usGroups / 1000.0f, usSend / 1000.0f);
}
}
else
request->send(500, _encoding_text, "Invalid http method");
@ -787,7 +894,7 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json)
cmd.command = command;
cmd.repeat = repeat;
cmd.stepSize = stepSize;
this->queueCommand(cmd);
if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -828,7 +935,7 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json)
cmd.command = command;
cmd.repeat = repeat;
cmd.stepSize = stepSize;
this->queueCommand(cmd);
if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -865,7 +972,7 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
cmd.shadeId = shadeId;
cmd.target = target;
cmd.command = command;
this->queueCommand(cmd);
if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -906,7 +1013,7 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json)
cmd.command = command;
cmd.repeat = repeat;
cmd.stepSize = stepSize;
this->queueCommand(cmd);
if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginArray();
@ -924,7 +1031,7 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json)
cmd.command = command;
cmd.repeat = repeat;
cmd.stepSize = stepSize;
this->queueCommand(cmd);
if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1018,7 +1125,9 @@ void Web::handleSetPositions(AsyncWebServerRequest *request, JsonVariant &json)
cmd.shadeId = shadeId;
cmd.position = pos;
cmd.tiltPosition = tiltPos;
this->queueCommand(cmd);
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;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1061,7 +1170,9 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) {
cmd.sunny = sunny;
cmd.windy = windy;
cmd.repeat = repeat;
this->queueCommand(cmd);
if(!queueRadioCommand(request, cmd)) 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;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1080,7 +1191,8 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) {
cmd.sunny = sunny;
cmd.windy = windy;
cmd.repeat = repeat;
this->queueCommand(cmd);
if(!queueRadioCommand(request, cmd)) return;
// As above, this is the state before the command and groupState follows.
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1171,8 +1283,39 @@ void Web::handleNotFound(AsyncWebServerRequest *request) {
request->send(404, _encoding_text, g_async_content);
}
// Logs how long a request takes to handle and, once the connection closes, how
// long the whole exchange took. The total includes chunked bodies like
// /controller that are built while they are sent, after the handler returns.
// The server name must be a literal as the disconnect handler keeps it.
static void timeRequest(AsyncWebServerRequest *request, ArMiddlewareNext next, const char *server) {
ElapsedTimer timer;
// Taken now as the client may already be gone when the disconnect runs.
IPAddress ip = request->client() ? request->client()->remoteIP() : IPAddress();
// Uploads set their own disconnect handler while the body arrives, before
// this runs, and replacing it would stop the reboot after an update.
if(!request->multipart()) {
request->onDisconnect([request, timer, server, ip]() {
const uint32_t us = timer.us();
if(!worthLogging(us)) return;
char what[112];
snprintf(what, sizeof(what), "%s %s %s from %u.%u.%u.%u total", server, request->methodToString(), request->url().c_str(),
ip[0], ip[1], ip[2], ip[3]);
logElapsed(TAG, what, us);
});
}
next();
const uint32_t us = timer.us();
if(!worthLogging(us)) return;
char what[112];
snprintf(what, sizeof(what), "%s %s %s from %u.%u.%u.%u handler", server, request->methodToString(), request->url().c_str(),
ip[0], ip[1], ip[2], ip[3]);
logElapsed(TAG, what, us);
}
void Web::begin() {
ESP_LOGI(TAG, "Creating Web MicroServices...");
asyncApiServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next, "api"); });
asyncServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next, "web"); });
// Async API Server (port 8081)
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS");
@ -1601,7 +1744,7 @@ void Web::begin() {
if(tilt < 0) tilt = shade->myPos;
if(shade->tiltType == tilt_types::none) tilt = -1;
if(pos >= 0 && pos <= 100)
shade->setMyPosition(shade->transformPosition(pos), shade->transformPosition(tilt));
if(!queueSetMyPosition(request, shade, shade->transformPosition(pos), shade->transformPosition(tilt))) return;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1628,7 +1771,7 @@ void Web::begin() {
if(tilt < 0) tilt = shade->myPos;
if(shade->tiltType == tilt_types::none) tilt = -1;
if(pos >= 0 && pos <= 100)
shade->setMyPosition(shade->transformPosition(pos), shade->transformPosition(tilt));
if(!queueSetMyPosition(request, shade, shade->transformPosition(pos), shade->transformPosition(tilt))) return;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1753,12 +1896,7 @@ void Web::begin() {
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}"));
}
else {
if(shade->bitLength == 56)
shade->sendCommand(somfy_commands::Prog, 7);
else
shade->sendCommand(somfy_commands::Prog, 1);
shade->paired = false;
shade->save();
if(!queueUnpairShade(request, shade)) return;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1777,12 +1915,7 @@ void Web::begin() {
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}"));
}
else {
if(shade->bitLength == 56)
shade->sendCommand(somfy_commands::Prog, 7);
else
shade->sendCommand(somfy_commands::Prog, 1);
shade->paired = false;
shade->save();
if(!queueUnpairShade(request, shade)) return;
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -2117,6 +2250,7 @@ void Web::begin() {
else {
somfy.transceiver.end();
mqtt.end();
request->onDisconnect(onUploadDisconnect);
}
}
if(len > 0) {
@ -2182,6 +2316,7 @@ void Web::begin() {
else {
somfy.transceiver.end();
mqtt.end();
request->onDisconnect(onUploadDisconnect);
}
}
if(len > 0) {
@ -2275,7 +2410,6 @@ void Web::begin() {
}
JsonObject obj = json.as<JsonObject>();
somfy.transceiver.fromJSON(obj);
somfy.transceiver.save();
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -2312,8 +2446,10 @@ void Web::begin() {
frame.cmd = translateSomfyCommand(scmd.c_str());
}
if(frame.remoteAddress > 0 && frame.rollingCode > 0) {
somfy.sendFrame(frame, repeats);
request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}"));
if(somfy.sendFrame(frame, repeats))
request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}"));
else
sendRadioUnavailable(request);
}
else
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}"));
@ -2329,8 +2465,10 @@ void Web::begin() {
if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str());
}
if(frame.remoteAddress > 0 && frame.rollingCode > 0) {
somfy.sendFrame(frame, repeats);
request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}"));
if(somfy.sendFrame(frame, repeats))
request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}"));
else
sendRadioUnavailable(request);
}
else
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}"));

View file

@ -3,37 +3,10 @@
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include "Somfy.h"
#include "WebCommand.h"
#ifndef webserver_h
#define webserver_h
#define WEB_CMD_QUEUE_SIZE 8
#define WEB_CMD_TIMEOUT_MS 3000
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
};
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
};
class Web {
public:
bool uploadSuccess = false;
@ -67,8 +40,8 @@ class Web {
void handleBackup(AsyncWebServerRequest *request);
void handleReboot(AsyncWebServerRequest *request);
void handleNotFound(AsyncWebServerRequest *request);
bool queueCommand(const web_command_t &cmd);
private:
void processQueue();
bool queueCommand(const web_command_t &cmd);
};
#endif

51
src/WebCommand.h Normal file
View file

@ -0,0 +1,51 @@
#ifndef WEB_COMMAND_H
#define WEB_COMMAND_H
#include <stdint.h>
#include "SomfyFrame.h"
// Commands the web servers hand to the main loop (src/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
};
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
bool wasPaired; // for unpair, restored if the Prog can't be sent
};
// Only setting positions never goes on air.
inline bool webCommandUsesRadio(web_cmd_t type) { return type != web_cmd_t::set_positions; }
// 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.
inline bool holdWebCommand(web_cmd_t type, bool txSuspended, bool txHasRoom) {
return webCommandUsesRadio(type) && !txSuspended && !txHasRoom;
}
#endif

48
test/stubs/Arduino.h Normal file
View file

@ -0,0 +1,48 @@
// Minimal stand-in for the Arduino core so firmware sources can be built for
// the native test env (pio test -e native). Only add what tested code uses.
#ifndef arduino_stub_h
#define arduino_stub_h
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <string>
#include <type_traits>
typedef uint8_t byte;
// Fixed so anything formatted from it is deterministic.
inline unsigned long millis() { return 0; }
inline unsigned long micros() { return 0; }
inline bool getLocalTime(struct tm *info, uint32_t ms = 5000) {
(void)ms;
time_t now = time(nullptr);
return localtime_r(&now, info) != nullptr;
}
// The parts of Arduino's String the firmware code under test uses.
class String {
std::string _s;
public:
String() {}
String(const char *s) : _s(s ? s : "") {}
String(const std::string &s) : _s(s) {}
// Arduino formats integers in decimal.
template<typename T, typename std::enable_if<std::is_integral<T>::value, int>::type = 0>
explicit String(T val) : _s(std::to_string(val)) {}
const char *c_str() const { return _s.c_str(); }
unsigned int length() const { return (unsigned int)_s.length(); }
bool equalsIgnoreCase(const String &other) const {
if(_s.length() != other._s.length()) return false;
for(size_t i = 0; i < _s.length(); i++)
if(tolower((unsigned char)_s[i]) != tolower((unsigned char)other._s[i])) return false;
return true;
}
bool startsWith(const String &prefix) const { return _s.compare(0, prefix._s.length(), prefix._s) == 0; }
friend String operator+(const String &a, const String &b) { return String(a._s + b._s); }
friend bool operator==(const String &a, const String &b) { return a._s == b._s; }
};
#endif

10
test/stubs/esp_log.h Normal file
View file

@ -0,0 +1,10 @@
// Logging stand-in for the native test env. The format arguments are never
// evaluated so tests stay quiet and logging can't affect what they check.
#ifndef esp_log_stub_h
#define esp_log_stub_h
#define ESP_LOGE(tag, ...) ((void)(tag))
#define ESP_LOGW(tag, ...) ((void)(tag))
#define ESP_LOGI(tag, ...) ((void)(tag))
#define ESP_LOGD(tag, ...) ((void)(tag))
#define ESP_LOGV(tag, ...) ((void)(tag))
#endif

View file

@ -0,0 +1,266 @@
#include <unity.h>
#include "SomfyFrame.h"
#define ASSERT_CMD(expected, actual) TEST_ASSERT_EQUAL_HEX8(static_cast<uint8_t>(expected), static_cast<uint8_t>(actual))
static const uint32_t ADDR = 0x123456;
// A frame as SomfyRemote::sendCommand builds it.
static somfy_frame_t makeFrame(somfy_commands cmd, uint32_t address, uint16_t rollingCode,
uint8_t bitLength = 56, radio_proto proto = radio_proto::RTS) {
somfy_frame_t f;
f.cmd = cmd;
f.remoteAddress = address;
f.rollingCode = rollingCode;
f.bitLength = bitLength;
f.proto = proto;
f.encKey = 0xA0 | (rollingCode & 0x0F);
return f;
}
// What a receiver makes of the bytes, given it heard the same frame length.
static somfy_frame_t decode(const byte *payload, uint8_t bitLength) {
byte buf[10];
memcpy(buf, payload, sizeof(buf));
somfy_frame_t d;
d.bitLength = bitLength;
d.decodeFrame(buf);
return d;
}
static somfy_frame_t roundTrip(somfy_frame_t f) {
byte buf[10];
f.encodeFrame(buf);
return decode(buf, f.bitLength);
}
void setUp() {}
void tearDown() {}
/*********************************************************************
* Encoding
********************************************************************/
static void test_encode_56bit_golden_frame() {
// Pins exactly what goes on air. If this changes, find out why before
// updating the bytes: motors reject frames that are encoded differently.
somfy_frame_t f = makeFrame(somfy_commands::Up, ADDR, 0x0102);
f.encKey = 0xA7;
byte buf[10];
f.encodeFrame(buf);
const byte expected[10] = {0xA7, 0x8C, 0x8D, 0x8F, 0x9D, 0xA9, 0xFF, 0x84, 0x00, 0x1D};
TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, buf, 10);
somfy_frame_t d = decode(expected, 56);
TEST_ASSERT_TRUE(d.valid);
ASSERT_CMD(somfy_commands::Up, d.cmd);
TEST_ASSERT_EQUAL_HEX32(ADDR, d.remoteAddress);
TEST_ASSERT_EQUAL_HEX16(0x0102, d.rollingCode);
TEST_ASSERT_EQUAL_HEX8(0xA7, d.encKey);
}
static void test_round_trip_56bit_commands() {
const somfy_commands cmds[] = {
somfy_commands::My, somfy_commands::Up, somfy_commands::MyUp, somfy_commands::Down,
somfy_commands::MyDown, somfy_commands::UpDown, somfy_commands::MyUpDown, somfy_commands::Prog,
somfy_commands::SunFlag, somfy_commands::Flag, somfy_commands::Toggle, somfy_commands::Sensor
};
for(somfy_commands cmd : cmds) {
somfy_frame_t f = makeFrame(cmd, ADDR, 0x1234);
somfy_frame_t d = roundTrip(f);
TEST_ASSERT_TRUE_MESSAGE(d.valid, translateSomfyCommand(cmd).c_str());
ASSERT_CMD(cmd, d.cmd);
TEST_ASSERT_EQUAL_HEX32(ADDR, d.remoteAddress);
TEST_ASSERT_EQUAL_HEX16(0x1234, d.rollingCode);
TEST_ASSERT_EQUAL_HEX8(f.encKey, d.encKey);
TEST_ASSERT_EQUAL(radio_proto::RTS, d.proto);
}
}
static void test_round_trip_80bit_commands_and_repeats() {
const somfy_commands cmds[] = {somfy_commands::Up, somfy_commands::Down, somfy_commands::My};
for(somfy_commands cmd : cmds) {
for(uint8_t repeats : {0, 5}) {
somfy_frame_t f = makeFrame(cmd, ADDR, 0x0042, 80);
f.repeats = repeats;
byte buf[10];
f.encodeFrame(buf);
// The repeat counter is carried in byte 7.
TEST_ASSERT_EQUAL_UINT8(196 + repeats * 4, buf[7]);
somfy_frame_t d = decode(buf, 80);
TEST_ASSERT_TRUE_MESSAGE(d.valid, translateSomfyCommand(cmd).c_str());
ASSERT_CMD(cmd, d.cmd);
TEST_ASSERT_EQUAL_HEX16(0x0042, d.rollingCode);
}
}
}
static void test_round_trip_80bit_favorite_and_stop() {
// Sent as My with the real command in an extension nibble.
for(somfy_commands cmd : {somfy_commands::Favorite, somfy_commands::Stop}) {
somfy_frame_t d = roundTrip(makeFrame(cmd, ADDR, 7, 80));
TEST_ASSERT_TRUE(d.valid);
ASSERT_CMD(cmd, d.cmd);
}
}
static void test_round_trip_80bit_steps() {
for(somfy_commands cmd : {somfy_commands::StepUp, somfy_commands::StepDown}) {
for(uint8_t step : {1, 100}) {
somfy_frame_t f = makeFrame(cmd, ADDR, 7, 80);
f.stepSize = step;
somfy_frame_t d = roundTrip(f);
TEST_ASSERT_TRUE(d.valid);
ASSERT_CMD(cmd, d.cmd);
TEST_ASSERT_EQUAL_UINT8(step, d.stepSize);
}
}
}
static void test_round_trip_80bit_toggle() {
somfy_frame_t d = roundTrip(makeFrame(somfy_commands::Toggle, ADDR, 7, 80));
TEST_ASSERT_TRUE(d.valid);
ASSERT_CMD(somfy_commands::Toggle, d.cmd);
TEST_ASSERT_EQUAL_UINT8(164, d.encKey);
}
static void test_round_trip_rtw_and_rtv() {
// These protocols carry the command in the key byte.
for(radio_proto proto : {radio_proto::RTW, radio_proto::RTV}) {
for(somfy_commands cmd : {somfy_commands::Up, somfy_commands::Down}) {
somfy_frame_t d = roundTrip(makeFrame(cmd, ADDR, 9, 56, proto));
TEST_ASSERT_TRUE(d.valid);
ASSERT_CMD(cmd, d.cmd);
TEST_ASSERT_EQUAL(proto, d.proto);
}
}
}
/*********************************************************************
* Rejected frames
********************************************************************/
static void test_rejects_bad_checksum() {
byte buf[10];
makeFrame(somfy_commands::Up, ADDR, 0x1234).encodeFrame(buf);
// Byte 6 is the last obfuscated byte so the flip changes only its decoded value.
buf[6] ^= 0x01;
TEST_ASSERT_FALSE(decode(buf, 56).valid);
}
static void test_rejects_out_of_range_address() {
TEST_ASSERT_FALSE(roundTrip(makeFrame(somfy_commands::Up, 0, 0x1234)).valid);
TEST_ASSERT_FALSE(roundTrip(makeFrame(somfy_commands::Up, 0xFFFFFF, 0x1234)).valid);
}
static void test_rejects_zero_rolling_code_except_sensor() {
TEST_ASSERT_FALSE(roundTrip(makeFrame(somfy_commands::Up, ADDR, 0)).valid);
// Sensor frames carry flags in the rolling code so 0 is fine.
TEST_ASSERT_TRUE(roundTrip(makeFrame(somfy_commands::Sensor, ADDR, 0)).valid);
}
static void test_rejects_zero_key() {
somfy_frame_t f = makeFrame(somfy_commands::Up, ADDR, 0x1234);
f.encKey = 0;
TEST_ASSERT_FALSE(roundTrip(f).valid);
}
static void test_rejects_bad_80bit_parity() {
byte buf[10];
makeFrame(somfy_commands::Up, ADDR, 0x1234, 80).encodeFrame(buf);
buf[9] ^= 0x01;
TEST_ASSERT_FALSE(decode(buf, 80).valid);
}
/*********************************************************************
* Helpers
********************************************************************/
static void test_encode80Byte7_wraps() {
somfy_frame_t f;
TEST_ASSERT_EQUAL_UINT8(196, f.encode80Byte7(196, 0));
TEST_ASSERT_EQUAL_UINT8(252, f.encode80Byte7(196, 14));
// Past 255 the counter steps back by 15 repeats.
TEST_ASSERT_EQUAL_UINT8(196, f.encode80Byte7(196, 15));
TEST_ASSERT_EQUAL_UINT8(216, f.encode80Byte7(196, 20));
}
static void test_copy_counts_repeats_and_marks_synonyms() {
somfy_frame_t last = makeFrame(somfy_commands::Up, ADDR, 10);
last.processed = true;
somfy_frame_t repeat = makeFrame(somfy_commands::Up, ADDR, 10);
last.copy(repeat);
TEST_ASSERT_EQUAL_UINT8(1, last.repeats);
// Same press reported as another command: keep what was already processed.
somfy_frame_t synonym = makeFrame(somfy_commands::My, ADDR, 10);
last.copy(synonym);
TEST_ASSERT_TRUE(last.synonym);
TEST_ASSERT_TRUE(last.processed);
ASSERT_CMD(somfy_commands::My, last.cmd);
// A new press replaces it.
somfy_frame_t next = makeFrame(somfy_commands::Down, ADDR, 11);
last.copy(next);
TEST_ASSERT_FALSE(last.synonym);
TEST_ASSERT_FALSE(last.processed);
TEST_ASSERT_EQUAL_UINT16(11, last.rollingCode);
TEST_ASSERT_EQUAL_UINT8(0, last.repeats);
}
/*********************************************************************
* Command names
********************************************************************/
static void test_parse_command_names() {
// What the web UI and the Home Assistant integration send.
ASSERT_CMD(somfy_commands::Up, translateSomfyCommand(String("up")));
ASSERT_CMD(somfy_commands::Down, translateSomfyCommand(String("DOWN")));
ASSERT_CMD(somfy_commands::My, translateSomfyCommand(String("my")));
ASSERT_CMD(somfy_commands::Toggle, translateSomfyCommand(String("toggle")));
ASSERT_CMD(somfy_commands::Flag, translateSomfyCommand(String("flag")));
ASSERT_CMD(somfy_commands::SunFlag, translateSomfyCommand(String("sunflag")));
ASSERT_CMD(somfy_commands::StepUp, translateSomfyCommand(String("StepUp")));
ASSERT_CMD(somfy_commands::StepDown, translateSomfyCommand(String("StepDown")));
ASSERT_CMD(somfy_commands::Prog, translateSomfyCommand(String("Prog")));
// Short forms.
ASSERT_CMD(somfy_commands::Favorite, translateSomfyCommand(String("fav")));
ASSERT_CMD(somfy_commands::MyUpDown, translateSomfyCommand(String("mud")));
// A single hex digit is the raw command.
ASSERT_CMD(somfy_commands::Down, translateSomfyCommand(String("4")));
// Anything else falls back to My.
ASSERT_CMD(somfy_commands::My, translateSomfyCommand(String("xyz")));
ASSERT_CMD(somfy_commands::My, translateSomfyCommand(String("")));
}
static void test_command_display_names() {
TEST_ASSERT_EQUAL_STRING("Up", translateSomfyCommand(somfy_commands::Up).c_str());
TEST_ASSERT_EQUAL_STRING("My+Up", translateSomfyCommand(somfy_commands::MyUp).c_str());
TEST_ASSERT_EQUAL_STRING("Sun Flag", translateSomfyCommand(somfy_commands::SunFlag).c_str());
TEST_ASSERT_EQUAL_STRING("Step Up", translateSomfyCommand(somfy_commands::StepUp).c_str());
TEST_ASSERT_EQUAL_STRING("Favorite", translateSomfyCommand(somfy_commands::Favorite).c_str());
TEST_ASSERT_EQUAL_STRING("Unknown(13)", translateSomfyCommand(somfy_commands::UnknownD).c_str());
}
static void test_command_names_round_trip() {
// The names in shadeCommand events (and Home Assistant events) must parse
// back to the same command.
const somfy_commands cmds[] = {
somfy_commands::My, somfy_commands::Up, somfy_commands::MyUp, somfy_commands::Down,
somfy_commands::MyDown, somfy_commands::UpDown, somfy_commands::MyUpDown, somfy_commands::Prog,
somfy_commands::SunFlag, somfy_commands::Flag, somfy_commands::StepUp, somfy_commands::StepDown,
somfy_commands::Sensor, somfy_commands::Toggle, somfy_commands::Favorite, somfy_commands::Stop
};
std::string failed;
for(somfy_commands cmd : cmds) {
String name = translateSomfyCommand(cmd);
if(translateSomfyCommand(name) != cmd) failed += std::string(failed.empty() ? "" : ", ") + name.c_str();
}
TEST_ASSERT_EQUAL_STRING_MESSAGE("", failed.c_str(), "These names don't parse back to their command");
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_encode_56bit_golden_frame);
RUN_TEST(test_round_trip_56bit_commands);
RUN_TEST(test_round_trip_80bit_commands_and_repeats);
RUN_TEST(test_round_trip_80bit_favorite_and_stop);
RUN_TEST(test_round_trip_80bit_steps);
RUN_TEST(test_round_trip_80bit_toggle);
RUN_TEST(test_round_trip_rtw_and_rtv);
RUN_TEST(test_rejects_bad_checksum);
RUN_TEST(test_rejects_out_of_range_address);
RUN_TEST(test_rejects_zero_rolling_code_except_sensor);
RUN_TEST(test_rejects_zero_key);
RUN_TEST(test_rejects_bad_80bit_parity);
RUN_TEST(test_encode80Byte7_wraps);
RUN_TEST(test_copy_counts_repeats_and_marks_synonyms);
RUN_TEST(test_parse_command_names);
RUN_TEST(test_command_display_names);
RUN_TEST(test_command_names_round_trip);
return UNITY_END();
}

View file

@ -0,0 +1,220 @@
#include <unity.h>
#include "JsonFormatter.h"
// The formatter only gets told about the first BUFF_SIZE bytes. The rest is a
// canary that must never be written.
#define STORAGE_SIZE 160
#define BUFF_SIZE 128
#define CANARY 'X'
static char storage[STORAGE_SIZE];
static BufferedJsonFormatter fmt;
static void useBuffer(size_t size) {
memset(storage, CANARY, sizeof(storage));
fmt.setBuffer(storage, size);
}
static void assertCanary(size_t from) {
for(size_t i = from; i < STORAGE_SIZE; i++) {
char msg[48];
snprintf(msg, sizeof(msg), "wrote past the buffer at byte %u", (unsigned)i);
TEST_ASSERT_EQUAL_HEX8_MESSAGE(CANARY, storage[i], msg);
}
}
// The JSON written for a single top level string value.
static const char *escaped(const char *val) {
useBuffer(BUFF_SIZE);
fmt.addElem(val);
return storage;
}
void setUp() { useBuffer(BUFF_SIZE); }
void tearDown() { assertCanary(BUFF_SIZE); }
/*********************************************************************
* Structure
********************************************************************/
static void test_empty_object() {
fmt.beginObject();
fmt.endObject();
TEST_ASSERT_EQUAL_STRING("{}", storage);
}
static void test_nested_objects_and_arrays() {
fmt.beginObject();
fmt.addElem("a", (uint8_t)1);
fmt.beginObject("b");
fmt.addElem("c", true);
fmt.endObject();
fmt.beginArray("d");
fmt.addElem((uint8_t)1);
fmt.addElem((uint8_t)2);
fmt.beginObject();
fmt.addElem("e", "x");
fmt.endObject();
fmt.endArray();
fmt.endObject();
TEST_ASSERT_EQUAL_STRING("{\"a\":1,\"b\":{\"c\":true},\"d\":[1,2,{\"e\":\"x\"}]}", storage);
}
static void test_array_of_objects() {
fmt.beginArray();
fmt.beginObject();
fmt.endObject();
fmt.beginObject();
fmt.endObject();
fmt.endArray();
TEST_ASSERT_EQUAL_STRING("[{},{}]", storage);
}
static void test_named_object_after_element() {
fmt.beginObject();
fmt.addElem("a", (uint8_t)1);
fmt.beginObject("o");
fmt.endObject();
fmt.endObject();
TEST_ASSERT_EQUAL_STRING("{\"a\":1,\"o\":{}}", storage);
}
/*********************************************************************
* Values
********************************************************************/
static void test_number_and_bool_values() {
fmt.beginArray();
fmt.addElem((int8_t)-5);
fmt.addElem((uint8_t)255);
fmt.addElem((int32_t)-100000);
fmt.addElem((uint32_t)4000000000u);
fmt.addElem(1.5f);
fmt.addElem(true);
fmt.addElem(false);
fmt.endArray();
TEST_ASSERT_EQUAL_STRING("[-5,255,-100000,4000000000,1.5000,true,false]", storage);
}
static void test_named_uint64() {
fmt.beginObject();
fmt.addElem("u", (uint64_t)18446744073709551615ULL);
fmt.endObject();
TEST_ASSERT_EQUAL_STRING("{\"u\":18446744073709551615}", storage);
}
static void test_null_string_is_skipped() {
fmt.beginObject();
fmt.addElem("a", (uint8_t)1);
fmt.addElem("n", (const char *)nullptr);
fmt.addElem("b", (uint8_t)2);
fmt.endObject();
TEST_ASSERT_EQUAL_STRING("{\"a\":1,\"b\":2}", storage);
}
/*********************************************************************
* Escaping
********************************************************************/
static void test_escapes_special_characters() {
TEST_ASSERT_EQUAL_STRING("\"plain\"", escaped("plain"));
TEST_ASSERT_EQUAL_STRING("\"\\\"\"", escaped("\""));
TEST_ASSERT_EQUAL_STRING("\"\\\\\"", escaped("\\"));
TEST_ASSERT_EQUAL_STRING("\"\\/\"", escaped("/"));
TEST_ASSERT_EQUAL_STRING("\"\\n\\t\\r\\b\\f\"", escaped("\n\t\r\b\f"));
TEST_ASSERT_EQUAL_STRING("\"Living \\\"Room\\\"\"", escaped("Living \"Room\""));
}
static void test_escapes_other_control_characters() {
// Raw control characters are not allowed in JSON strings.
TEST_ASSERT_EQUAL_STRING("\"a\\u0001b\"", escaped("a\x01" "b"));
TEST_ASSERT_EQUAL_STRING("\"\\u001f\"", escaped("\x1f"));
}
static void test_escaped_length() {
TEST_ASSERT_EQUAL_UINT32(0, fmt.calcEscapedLength(""));
TEST_ASSERT_EQUAL_UINT32(2, fmt.calcEscapedLength("ab"));
TEST_ASSERT_EQUAL_UINT32(4, fmt.calcEscapedLength("a\"b"));
}
static void test_escaped_length_counts_first_character() {
TEST_ASSERT_EQUAL_UINT32(4, fmt.calcEscapedLength("\"ab"));
TEST_ASSERT_EQUAL_UINT32(2, fmt.calcEscapedLength("/"));
}
/*********************************************************************
* Buffer limits
********************************************************************/
static void test_value_needing_one_byte_too_many_is_refused() {
// "\/abcdefg" with its quotes is 11 characters, so with the terminator it
// needs 12 bytes and must not be written into 11.
useBuffer(11);
fmt.addElem("/abcdefg");
TEST_ASSERT_TRUE(strlen(storage) < 11);
assertCanary(11);
}
static void test_overflow_is_flagged() {
useBuffer(16);
fmt.beginObject();
fmt.addElem("name", "fits");
TEST_ASSERT_FALSE(fmt.overflowed());
fmt.addElem("long", "this value does not fit");
TEST_ASSERT_TRUE(fmt.overflowed());
// What was already written (here up to the comma) stays intact and terminated.
TEST_ASSERT_EQUAL_STRING("{\"name\":\"fits\",", storage);
assertCanary(16);
}
static void test_setBuffer_clears_overflow() {
useBuffer(4);
fmt.addElem("too long");
TEST_ASSERT_TRUE(fmt.overflowed());
useBuffer(BUFF_SIZE);
TEST_ASSERT_FALSE(fmt.overflowed());
}
/*********************************************************************
* Tracked length
********************************************************************/
static void test_length_matches_what_was_written() {
fmt.beginObject();
fmt.addElem("name", "Living \"Room\"\n");
fmt.addElem("pos", 41.5f);
fmt.beginArray("ids");
fmt.addElem((uint8_t)1);
fmt.addElem((int32_t)-2);
fmt.endArray();
fmt.addElem("on", true);
fmt.endObject();
TEST_ASSERT_EQUAL_UINT32(strlen(storage), fmt.length());
TEST_ASSERT_EQUAL_STRING("{\"name\":\"Living \\\"Room\\\"\\n\",\"pos\":41.5000,\"ids\":[1,-2],\"on\":true}", storage);
}
static void test_refused_append_leaves_length_alone() {
useBuffer(16);
fmt.beginObject();
fmt.addElem("name", "fits");
const size_t before = fmt.length();
fmt.addElem("x", "this value does not fit");
TEST_ASSERT_TRUE(fmt.overflowed());
// The comma and key still fit; the value was refused whole.
TEST_ASSERT_EQUAL_UINT32(strlen(storage), fmt.length());
TEST_ASSERT_TRUE(fmt.length() >= before);
TEST_ASSERT_TRUE(fmt.length() < 16);
}
static void test_setBuffer_resets_length() {
fmt.addElem("something");
TEST_ASSERT_TRUE(fmt.length() > 0);
useBuffer(BUFF_SIZE);
TEST_ASSERT_EQUAL_UINT32(0, fmt.length());
TEST_ASSERT_EQUAL_STRING("", storage);
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_empty_object);
RUN_TEST(test_nested_objects_and_arrays);
RUN_TEST(test_array_of_objects);
RUN_TEST(test_named_object_after_element);
RUN_TEST(test_number_and_bool_values);
RUN_TEST(test_named_uint64);
RUN_TEST(test_null_string_is_skipped);
RUN_TEST(test_escapes_special_characters);
RUN_TEST(test_escapes_other_control_characters);
RUN_TEST(test_escaped_length);
RUN_TEST(test_escaped_length_counts_first_character);
RUN_TEST(test_value_needing_one_byte_too_many_is_refused);
RUN_TEST(test_overflow_is_flagged);
RUN_TEST(test_setBuffer_clears_overflow);
RUN_TEST(test_length_matches_what_was_written);
RUN_TEST(test_refused_append_leaves_length_alone);
RUN_TEST(test_setBuffer_resets_length);
return UNITY_END();
}

View file

@ -0,0 +1,55 @@
#include <unity.h>
#include "LogLockPolicy.h"
#include "Timing.h"
void setUp() {}
void tearDown() {}
/*********************************************************************
* keepWaitingForLogLock
********************************************************************/
static void assertWaitsUntilMax(LogHolder holder) {
TEST_ASSERT_TRUE(keepWaitingForLogLock(holder, 0, 20));
TEST_ASSERT_TRUE(keepWaitingForLogLock(holder, 19, 20));
TEST_ASSERT_FALSE(keepWaitingForLogLock(holder, 20, 20));
TEST_ASSERT_FALSE(keepWaitingForLogLock(holder, 1000, 20));
}
static void test_logLock_waits_for_a_running_holder() { assertWaitsUntilMax(LogHolder::running); }
static void test_logLock_waits_for_a_holder_just_taking_it() { assertWaitsUntilMax(LogHolder::unknown); }
static void test_logLock_waits_for_a_holder_preempted_here() { assertWaitsUntilMax(LogHolder::preemptedHere); }
static void test_logLock_gives_up_on_a_holder_preempted_elsewhere() {
// loop() preempted by the radio task can't let go until the job is done.
TEST_ASSERT_FALSE(keepWaitingForLogLock(LogHolder::preemptedElsewhere, 0, 20));
TEST_ASSERT_FALSE(keepWaitingForLogLock(LogHolder::preemptedElsewhere, 1, 20));
}
static void test_logLock_no_wait_allowed() {
TEST_ASSERT_FALSE(keepWaitingForLogLock(LogHolder::running, 0, 0));
}
/*********************************************************************
* worthLogging
********************************************************************/
static void test_worthLogging_fast_timings_are_skipped() {
TEST_ASSERT_FALSE(worthLogging(0));
TEST_ASSERT_FALSE(worthLogging(999));
TEST_ASSERT_FALSE(worthLogging(TIMING_LOG_MIN_MS * 1000 - 1));
}
static void test_worthLogging_from_the_threshold() {
TEST_ASSERT_TRUE(worthLogging(TIMING_LOG_MIN_MS * 1000));
TEST_ASSERT_TRUE(worthLogging(TIMING_SLOW_MS * 1000));
TEST_ASSERT_TRUE(worthLogging(UINT32_MAX));
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_logLock_waits_for_a_running_holder);
RUN_TEST(test_logLock_waits_for_a_holder_just_taking_it);
RUN_TEST(test_logLock_waits_for_a_holder_preempted_here);
RUN_TEST(test_logLock_gives_up_on_a_holder_preempted_elsewhere);
RUN_TEST(test_logLock_no_wait_allowed);
RUN_TEST(test_worthLogging_fast_timings_are_skipped);
RUN_TEST(test_worthLogging_from_the_threshold);
return UNITY_END();
}

View file

@ -0,0 +1,206 @@
#include <unity.h>
#include "ShadeMotion.h"
#include "SomfyFrame.h"
#define ASSERT_POS(expected, actual) TEST_ASSERT_FLOAT_WITHIN(0.01f, (expected), (actual))
#define ASSERT_CMD(expected, actual) TEST_ASSERT_EQUAL_HEX8(static_cast<uint8_t>(expected), static_cast<uint8_t>(actual))
static const int8_t DOWN = 1;
static const int8_t UP = -1;
void setUp() {}
void tearDown() {}
/*********************************************************************
* travelPosition
********************************************************************/
static void test_down_part_way() {
ASSERT_POS(50.0f, travelPosition(0, DOWN, 5000, 10000));
// From 30% the first 3s of travel are already done.
ASSERT_POS(50.0f, travelPosition(30, DOWN, 2000, 10000));
}
static void test_up_part_way() {
ASSERT_POS(75.0f, travelPosition(100, UP, 2500, 10000));
ASSERT_POS(25.0f, travelPosition(50, UP, 5000, 20000));
}
static void test_no_time_elapsed_stays_put() {
// The start is counted in whole milliseconds so allow for that.
TEST_ASSERT_FLOAT_WITHIN(0.02f, 50.5f, travelPosition(50.5f, DOWN, 0, 10000));
TEST_ASSERT_FLOAT_WITHIN(0.02f, 50.5f, travelPosition(50.5f, UP, 0, 10000));
}
static void test_stops_at_the_ends() {
ASSERT_POS(100.0f, travelPosition(0, DOWN, 10000, 10000));
ASSERT_POS(100.0f, travelPosition(0, DOWN, 20000, 10000));
ASSERT_POS(0.0f, travelPosition(100, UP, 10000, 10000));
ASSERT_POS(0.0f, travelPosition(100, UP, 20000, 10000));
}
static void test_starting_at_the_end() {
ASSERT_POS(100.0f, travelPosition(100, DOWN, 0, 10000));
ASSERT_POS(100.0f, travelPosition(100, DOWN, 3000, 10000));
ASSERT_POS(0.0f, travelPosition(0, UP, 0, 10000));
ASSERT_POS(0.0f, travelPosition(0, UP, 3000, 10000));
}
static void test_always_within_range() {
const float starts[] = {0.0f, 12.5f, 50.0f, 99.9f, 100.0f};
for(float start : starts) {
for(uint32_t elapsed = 0; elapsed <= 30000; elapsed += 250) {
for(int8_t dir : {DOWN, UP}) {
float pos = travelPosition(start, dir, elapsed, 10000);
TEST_ASSERT_TRUE(pos >= 0.0f && pos <= 100.0f);
}
}
}
}
static void test_up_and_down_use_their_own_times() {
// 2s of a 10s travel down covers 20%, 2s of a 20s travel up only 10%.
ASSERT_POS(20.0f, travelPosition(0, DOWN, 2000, 10000));
ASSERT_POS(90.0f, travelPosition(100, UP, 2000, 20000));
}
static void test_zero_travel_time_is_instant() {
ASSERT_POS(100.0f, travelPosition(0, DOWN, 0, 0));
ASSERT_POS(100.0f, travelPosition(42, DOWN, 0, 0));
ASSERT_POS(0.0f, travelPosition(100, UP, 0, 0));
ASSERT_POS(0.0f, travelPosition(42, UP, 0, 0));
}
static void test_moves_steadily() {
// Each step of time moves the shade further, never back.
float last = 0.0f;
for(uint32_t elapsed = 0; elapsed <= 10000; elapsed += 100) {
float pos = travelPosition(0, DOWN, elapsed, 10000);
TEST_ASSERT_TRUE(pos >= last);
last = pos;
}
last = 100.0f;
for(uint32_t elapsed = 0; elapsed <= 10000; elapsed += 100) {
float pos = travelPosition(100, UP, elapsed, 10000);
TEST_ASSERT_TRUE(pos <= last);
last = pos;
}
}
/*********************************************************************
* displayPosition
********************************************************************/
static void test_displayPosition() {
TEST_ASSERT_EQUAL_INT8(-1, displayPosition(-1, false));
TEST_ASSERT_EQUAL_INT8(-1, displayPosition(-1, true));
TEST_ASSERT_EQUAL_INT8(37, displayPosition(37.9f, false));
TEST_ASSERT_EQUAL_INT8(62, displayPosition(37.9f, true));
TEST_ASSERT_EQUAL_INT8(100, displayPosition(0, true));
TEST_ASSERT_EQUAL_INT8(0, displayPosition(100, true));
TEST_ASSERT_EQUAL_INT8(100, displayPosition(100, false));
}
/*********************************************************************
* flipCommand
********************************************************************/
static void test_flipCommand_swaps_directions() {
ASSERT_CMD(somfy_commands::Down, flipCommand(somfy_commands::Up));
ASSERT_CMD(somfy_commands::Up, flipCommand(somfy_commands::Down));
ASSERT_CMD(somfy_commands::MyDown, flipCommand(somfy_commands::MyUp));
ASSERT_CMD(somfy_commands::MyUp, flipCommand(somfy_commands::MyDown));
ASSERT_CMD(somfy_commands::StepDown, flipCommand(somfy_commands::StepUp));
ASSERT_CMD(somfy_commands::StepUp, flipCommand(somfy_commands::StepDown));
}
static void test_flipCommand_keeps_the_rest() {
const somfy_commands same[] = {
somfy_commands::My, somfy_commands::Prog, somfy_commands::Toggle,
somfy_commands::UpDown, somfy_commands::MyUpDown, somfy_commands::Favorite
};
for(somfy_commands cmd : same) ASSERT_CMD(cmd, flipCommand(cmd));
}
/*********************************************************************
* TxHold
********************************************************************/
static void test_txhold_idle_shade_is_held() {
TxHold hold;
hold.mark(7, false, 1000);
TEST_ASSERT_TRUE(hold.held());
TEST_ASSERT_EQUAL_UINT32(7, hold.seq);
TEST_ASSERT_EQUAL_UINT32(1000, hold.since);
}
static void test_txhold_moving_shade_is_not_held() {
TxHold hold;
hold.mark(7, true, 1000);
TEST_ASSERT_FALSE(hold.held());
}
static void test_txhold_ignores_seq_zero() {
TxHold hold;
hold.mark(0, false, 1000);
TEST_ASSERT_FALSE(hold.held());
hold.mark(7, false, 1000);
TEST_ASSERT_FALSE(hold.release(0));
TEST_ASSERT_TRUE(hold.held());
}
static void test_txhold_first_job_wins() {
// simMy Favorite: the Up/Down queued from inside processFrame (N+1) is
// marked before the outer Favorite (N), and the hold stays on the Up/Down.
TxHold hold;
hold.mark(11, false, 1000);
hold.mark(10, false, 1005);
TEST_ASSERT_EQUAL_UINT32(11, hold.seq);
TEST_ASSERT_EQUAL_UINT32(1000, hold.since);
TEST_ASSERT_FALSE(hold.release(10));
TEST_ASSERT_TRUE(hold.held());
TEST_ASSERT_TRUE(hold.release(11));
TEST_ASSERT_FALSE(hold.held());
}
static void test_txhold_release_only_matches_the_held_job() {
TxHold hold;
hold.mark(5, false, 0);
TEST_ASSERT_FALSE(hold.release(4));
TEST_ASSERT_FALSE(hold.release(6));
TEST_ASSERT_TRUE(hold.held());
TEST_ASSERT_TRUE(hold.release(5));
TEST_ASSERT_FALSE(hold.release(5));
}
static void test_txhold_new_hold_after_release() {
TxHold hold;
hold.mark(5, false, 0);
hold.release(5);
hold.mark(6, false, 200);
TEST_ASSERT_EQUAL_UINT32(6, hold.seq);
TEST_ASSERT_EQUAL_UINT32(200, hold.since);
}
static void test_txhold_times_out() {
TxHold hold;
hold.mark(5, false, 1000);
TEST_ASSERT_FALSE(hold.timedOut(1000, 30000));
TEST_ASSERT_FALSE(hold.timedOut(30999, 30000));
TEST_ASSERT_TRUE(hold.timedOut(31000, 30000));
}
static void test_txhold_times_out_across_millis_wrap() {
TxHold hold;
hold.mark(5, false, 0xFFFFF000u);
TEST_ASSERT_FALSE(hold.timedOut(0x00001000u, 30000)); // 8192ms later
TEST_ASSERT_FALSE(hold.timedOut(0x00006000u, 30000)); // 28672ms later
TEST_ASSERT_TRUE(hold.timedOut(0x00007000u, 30000)); // 32768ms later
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_down_part_way);
RUN_TEST(test_up_part_way);
RUN_TEST(test_no_time_elapsed_stays_put);
RUN_TEST(test_stops_at_the_ends);
RUN_TEST(test_starting_at_the_end);
RUN_TEST(test_always_within_range);
RUN_TEST(test_up_and_down_use_their_own_times);
RUN_TEST(test_zero_travel_time_is_instant);
RUN_TEST(test_moves_steadily);
RUN_TEST(test_displayPosition);
RUN_TEST(test_flipCommand_swaps_directions);
RUN_TEST(test_flipCommand_keeps_the_rest);
RUN_TEST(test_txhold_idle_shade_is_held);
RUN_TEST(test_txhold_moving_shade_is_not_held);
RUN_TEST(test_txhold_ignores_seq_zero);
RUN_TEST(test_txhold_first_job_wins);
RUN_TEST(test_txhold_release_only_matches_the_held_job);
RUN_TEST(test_txhold_new_hold_after_release);
RUN_TEST(test_txhold_times_out);
RUN_TEST(test_txhold_times_out_across_millis_wrap);
return UNITY_END();
}

View file

@ -0,0 +1,417 @@
#include <unity.h>
#include "RadioScheduler.h"
static const uint32_t REMOTE_A = 0x111111;
static const uint32_t REMOTE_B = 0x222222;
void setUp() {}
void tearDown() {}
static somfy_tx_job_t makeJob(uint32_t remoteAddress, somfy_commands cmd = somfy_commands::Up) {
somfy_tx_job_t job;
job.frame.remoteAddress = remoteAddress;
job.frame.cmd = cmd;
job.repeats = 2;
return job;
}
static uint32_t submit(RadioScheduler &s, uint32_t remoteAddress, uint32_t now = 0, bool priority = false, uint16_t delayMs = 0) {
somfy_tx_job_t job = makeJob(remoteAddress, priority ? somfy_commands::My : somfy_commands::Up);
return s.submit(job, priority, delayMs, now);
}
// Takes the next job and sends all of it, as the radio task does. Returns its
// ticket, 0 when none is due.
static uint32_t runNext(RadioScheduler &s, uint32_t now = 0) {
somfy_tx_job_t job;
uint32_t waitMs;
if(!s.next(now, job, waitMs)) return 0;
if(!s.start(job.seq, now)) return job.seq;
s.ended(job.seq, true, now);
return job.seq;
}
static void assertEvent(RadioScheduler &s, tx_event_t type, uint32_t ticket) {
somfy_tx_event_t evt;
TEST_ASSERT_TRUE_MESSAGE(s.popEvent(evt), "expected another event");
TEST_ASSERT_EQUAL_UINT8((uint8_t)type, (uint8_t)evt.type);
TEST_ASSERT_EQUAL_UINT32(ticket, evt.seq);
}
static void assertNoEvents(RadioScheduler &s) {
somfy_tx_event_t evt;
TEST_ASSERT_FALSE(s.popEvent(evt));
}
static void discardEvents(RadioScheduler &s) {
somfy_tx_event_t evt;
while(s.popEvent(evt)) {}
}
/*********************************************************************
* Order
********************************************************************/
static void test_jobs_go_out_in_the_order_they_came() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A);
const uint32_t b = submit(s, REMOTE_B);
const uint32_t c = submit(s, REMOTE_A);
TEST_ASSERT_NOT_EQUAL(0, a);
TEST_ASSERT_EQUAL_UINT32(a, runNext(s));
TEST_ASSERT_EQUAL_UINT32(b, runNext(s));
TEST_ASSERT_EQUAL_UINT32(c, runNext(s));
TEST_ASSERT_EQUAL_UINT32(0, runNext(s));
}
static void test_tickets_are_distinct_and_set_in_the_job() {
RadioScheduler s;
somfy_tx_job_t job = makeJob(REMOTE_A);
const uint32_t a = s.submit(job, false, 0, 0);
TEST_ASSERT_EQUAL_UINT32(a, job.seq);
TEST_ASSERT_NOT_EQUAL(a, submit(s, REMOTE_A));
}
static void test_nothing_waiting_sleeps_until_woken() {
RadioScheduler s;
somfy_tx_job_t job;
uint32_t waitMs = 0;
TEST_ASSERT_FALSE(s.next(0, job, waitMs));
TEST_ASSERT_EQUAL_UINT32(RADIO_WAIT_FOREVER, waitMs);
}
/*********************************************************************
* Stops (priority jobs)
********************************************************************/
static void test_stop_goes_ahead_of_other_remotes() {
RadioScheduler s;
const uint32_t b1 = submit(s, REMOTE_B);
const uint32_t b2 = submit(s, REMOTE_B);
const uint32_t stop = submit(s, REMOTE_A, 0, true);
TEST_ASSERT_EQUAL_UINT32(stop, runNext(s));
TEST_ASSERT_EQUAL_UINT32(b1, runNext(s));
TEST_ASSERT_EQUAL_UINT32(b2, runNext(s));
}
static void test_stop_queues_behind_its_own_remote() {
// Overtaking the remote's waiting frame would send the rolling codes out of
// order and the motor would reject the older one.
RadioScheduler s;
const uint32_t up = submit(s, REMOTE_A);
const uint32_t b = submit(s, REMOTE_B);
const uint32_t stop = submit(s, REMOTE_A, 0, true);
TEST_ASSERT_EQUAL_UINT32(up, runNext(s));
TEST_ASSERT_EQUAL_UINT32(b, runNext(s));
TEST_ASSERT_EQUAL_UINT32(stop, runNext(s));
}
static void test_stop_goes_ahead_once_its_remote_is_on_air() {
RadioScheduler s;
const uint32_t up = submit(s, REMOTE_A);
const uint32_t b = submit(s, REMOTE_B);
somfy_tx_job_t job;
uint32_t waitMs;
TEST_ASSERT_TRUE(s.next(0, job, waitMs));
TEST_ASSERT_EQUAL_UINT32(up, job.seq);
// The remote's frame is already going out so the stop can't overtake it.
const uint32_t stop = submit(s, REMOTE_A, 0, true);
s.start(up, 0);
s.ended(up, true, 0);
TEST_ASSERT_EQUAL_UINT32(stop, runNext(s));
TEST_ASSERT_EQUAL_UINT32(b, runNext(s));
}
static void test_stops_go_out_in_the_order_they_came() {
RadioScheduler s;
const uint32_t back = submit(s, REMOTE_B);
const uint32_t stopA = submit(s, REMOTE_A, 0, true);
const uint32_t stopC = submit(s, 0x333333, 0, true);
TEST_ASSERT_EQUAL_UINT32(stopA, runNext(s));
TEST_ASSERT_EQUAL_UINT32(stopC, runNext(s));
TEST_ASSERT_EQUAL_UINT32(back, runNext(s));
}
static void test_second_stop_of_a_remote_stays_behind_the_first() {
RadioScheduler s;
const uint32_t back = submit(s, REMOTE_B);
const uint32_t first = submit(s, REMOTE_A, 0, true);
const uint32_t second = submit(s, REMOTE_A, 0, true);
TEST_ASSERT_EQUAL_UINT32(first, runNext(s));
TEST_ASSERT_EQUAL_UINT32(back, runNext(s));
TEST_ASSERT_EQUAL_UINT32(second, runNext(s));
}
/*********************************************************************
* Room
********************************************************************/
static void test_normal_jobs_leave_the_reserved_slots() {
RadioScheduler s;
for(uint8_t i = 0; i < TX_JOB_QUEUE_SIZE - TX_RESERVED_SLOTS; i++)
TEST_ASSERT_NOT_EQUAL(0, submit(s, REMOTE_B));
TEST_ASSERT_FALSE(s.hasRoom(false));
TEST_ASSERT_TRUE(s.hasRoom(true));
TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_B));
}
static void test_stops_use_the_reserved_slots() {
RadioScheduler s;
for(uint8_t i = 0; i < TX_JOB_QUEUE_SIZE - TX_RESERVED_SLOTS; i++) submit(s, REMOTE_B);
for(uint8_t i = 0; i < TX_RESERVED_SLOTS; i++)
TEST_ASSERT_NOT_EQUAL(0, submit(s, REMOTE_A, 0, true));
TEST_ASSERT_FALSE(s.hasRoom(true));
TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_A, 0, true));
}
static void test_a_refused_job_has_no_ticket() {
RadioScheduler s;
for(uint8_t i = 0; i < TX_JOB_QUEUE_SIZE - TX_RESERVED_SLOTS; i++) submit(s, REMOTE_B);
somfy_tx_job_t job = makeJob(REMOTE_A);
job.seq = 99;
TEST_ASSERT_EQUAL_UINT32(0, s.submit(job, false, 0, 0));
TEST_ASSERT_EQUAL_UINT32(0, job.seq);
}
static void test_taking_a_job_frees_its_slot() {
RadioScheduler s;
for(uint8_t i = 0; i < TX_JOB_QUEUE_SIZE - TX_RESERVED_SLOTS; i++) submit(s, REMOTE_B);
runNext(s);
TEST_ASSERT_TRUE(s.hasRoom(false));
}
/*********************************************************************
* Delayed jobs
********************************************************************/
static void test_delayed_job_waits_until_due() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A, 1000, false, 500);
somfy_tx_job_t job;
uint32_t waitMs;
TEST_ASSERT_FALSE(s.next(1000, job, waitMs));
TEST_ASSERT_EQUAL_UINT32(500, waitMs);
TEST_ASSERT_FALSE(s.next(1400, job, waitMs));
TEST_ASSERT_EQUAL_UINT32(100, waitMs);
TEST_ASSERT_TRUE(s.next(1500, job, waitMs));
TEST_ASSERT_EQUAL_UINT32(a, job.seq);
}
static void test_delayed_job_holds_up_the_jobs_behind_it() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A, 0, false, 500);
const uint32_t b = submit(s, REMOTE_B);
TEST_ASSERT_EQUAL_UINT32(0, runNext(s, 100));
TEST_ASSERT_EQUAL_UINT32(a, runNext(s, 500));
TEST_ASSERT_EQUAL_UINT32(b, runNext(s, 500));
}
static void test_stop_overtakes_a_delayed_job_of_another_remote() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A, 0, false, 500);
const uint32_t stop = submit(s, REMOTE_B, 100, true);
TEST_ASSERT_EQUAL_UINT32(stop, runNext(s, 100));
TEST_ASSERT_EQUAL_UINT32(0, runNext(s, 200));
TEST_ASSERT_EQUAL_UINT32(a, runNext(s, 500));
}
static void test_stop_waits_behind_its_remotes_delayed_job() {
// The favorite's long press holds a rolling code, so the stop can't go first.
RadioScheduler s;
const uint32_t setMy = submit(s, REMOTE_A, 0, false, 500);
const uint32_t stop = submit(s, REMOTE_A, 100, true);
TEST_ASSERT_EQUAL_UINT32(0, runNext(s, 100));
TEST_ASSERT_EQUAL_UINT32(setMy, runNext(s, 500));
TEST_ASSERT_EQUAL_UINT32(stop, runNext(s, 500));
}
static void test_delay_across_millis_wrap() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A, 0xFFFFFF00u, false, 0x200);
somfy_tx_job_t job;
uint32_t waitMs;
TEST_ASSERT_FALSE(s.next(0xFFFFFF00u, job, waitMs));
TEST_ASSERT_EQUAL_UINT32(0x200, waitMs);
TEST_ASSERT_FALSE(s.next(0x50, job, waitMs));
TEST_ASSERT_EQUAL_UINT32(0xB0, waitMs);
TEST_ASSERT_TRUE(s.next(0x100, job, waitMs));
TEST_ASSERT_EQUAL_UINT32(a, job.seq);
}
/*********************************************************************
* Outcomes
********************************************************************/
static void test_job_reports_started_then_finished() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A);
somfy_tx_job_t job;
uint32_t waitMs;
s.next(10, job, waitMs);
TEST_ASSERT_TRUE(s.start(a, 20));
TEST_ASSERT_TRUE(s.keepSending(a));
s.ended(a, true, 300);
somfy_tx_event_t evt;
TEST_ASSERT_TRUE(s.popEvent(evt));
TEST_ASSERT_EQUAL_UINT8((uint8_t)tx_event_t::started, (uint8_t)evt.type);
TEST_ASSERT_EQUAL_UINT32(a, evt.seq);
TEST_ASSERT_EQUAL_UINT32(20, evt.at);
TEST_ASSERT_EQUAL_UINT32(REMOTE_A, evt.remoteAddress);
TEST_ASSERT_EQUAL_UINT8((uint8_t)somfy_commands::Up, (uint8_t)evt.cmd);
TEST_ASSERT_TRUE(s.popEvent(evt));
TEST_ASSERT_EQUAL_UINT8((uint8_t)tx_event_t::finished, (uint8_t)evt.type);
TEST_ASSERT_EQUAL_UINT32(300, evt.at);
assertNoEvents(s);
}
static void test_cut_short_job_reports_aborted() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A);
somfy_tx_job_t job;
uint32_t waitMs;
s.next(0, job, waitMs);
s.start(a, 0);
s.ended(a, false, 0);
assertEvent(s, tx_event_t::started, a);
assertEvent(s, tx_event_t::aborted, a);
assertNoEvents(s);
}
static void test_flush_drops_every_waiting_job_once() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A);
const uint32_t stop = submit(s, REMOTE_B, 0, true);
const uint32_t delayed = submit(s, REMOTE_A, 0, false, 5000);
s.flush(0);
assertEvent(s, tx_event_t::dropped, stop);
assertEvent(s, tx_event_t::dropped, a);
assertEvent(s, tx_event_t::dropped, delayed);
assertNoEvents(s);
TEST_ASSERT_EQUAL_UINT32(0, runNext(s, 5000));
}
static void test_flush_cuts_short_the_job_on_air() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A);
somfy_tx_job_t job;
uint32_t waitMs;
s.next(0, job, waitMs);
s.start(a, 0);
s.flush(0);
TEST_ASSERT_FALSE(s.keepSending(a));
s.ended(a, false, 0);
assertEvent(s, tx_event_t::started, a);
assertEvent(s, tx_event_t::aborted, a);
assertNoEvents(s);
}
static void test_job_flushed_before_it_starts_is_dropped() {
// The radio task took it off the queue, then a flush came before it got the
// radio.
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A);
somfy_tx_job_t job;
uint32_t waitMs;
s.next(0, job, waitMs);
s.flush(0);
TEST_ASSERT_FALSE(s.start(a, 0));
assertEvent(s, tx_event_t::dropped, a);
assertNoEvents(s);
}
static void test_every_job_gets_one_outcome_across_a_flush() {
RadioScheduler s;
uint32_t tickets[6];
for(uint8_t i = 0; i < 3; i++) tickets[i] = submit(s, REMOTE_A + i);
somfy_tx_job_t job;
uint32_t waitMs;
s.next(0, job, waitMs);
s.start(job.seq, 0);
s.flush(0);
// Jobs queued after the flush are unaffected by it.
for(uint8_t i = 3; i < 6; i++) tickets[i] = submit(s, REMOTE_B + i);
s.ended(job.seq, s.keepSending(job.seq), 0);
while(runNext(s)) {}
uint8_t outcomes[6] = {};
somfy_tx_event_t evt;
while(s.popEvent(evt)) {
if(evt.type == tx_event_t::started) continue;
for(uint8_t i = 0; i < 6; i++)
if(tickets[i] == evt.seq) outcomes[i]++;
}
for(uint8_t i = 0; i < 6; i++) TEST_ASSERT_EQUAL_UINT8(1, outcomes[i]);
}
static void test_unknown_ticket_is_ignored() {
RadioScheduler s;
TEST_ASSERT_FALSE(s.keepSending(0));
TEST_ASSERT_FALSE(s.keepSending(42));
TEST_ASSERT_FALSE(s.start(42, 0));
s.ended(42, true, 0);
assertNoEvents(s);
}
/*********************************************************************
* Suspend
********************************************************************/
static void test_suspend_flushes_and_refuses_jobs() {
RadioScheduler s;
const uint32_t a = submit(s, REMOTE_A);
s.suspend(TX_SUSPEND_SCAN, 0);
TEST_ASSERT_TRUE(s.suspended());
assertEvent(s, tx_event_t::dropped, a);
TEST_ASSERT_FALSE(s.hasRoom(true));
TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_A));
TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_A, 0, true));
assertNoEvents(s);
}
static void test_resume_needs_every_reason_cleared() {
RadioScheduler s;
s.suspend(TX_SUSPEND_SCAN, 0);
s.suspend(TX_SUSPEND_OTA, 0);
s.resume(TX_SUSPEND_SCAN);
TEST_ASSERT_TRUE(s.suspended());
TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_A));
s.resume(TX_SUSPEND_OTA);
TEST_ASSERT_FALSE(s.suspended());
TEST_ASSERT_NOT_EQUAL(0, submit(s, REMOTE_A));
}
/*********************************************************************
* Repeater frames
********************************************************************/
static void test_repeater_frames_report_nothing() {
RadioScheduler s;
somfy_tx_job_t job = makeJob(REMOTE_A);
job.raw = true;
const uint32_t r = s.submit(job, false, 0, 0);
TEST_ASSERT_NOT_EQUAL(0, r);
TEST_ASSERT_EQUAL_UINT32(r, runNext(s));
submit(s, REMOTE_B);
job = makeJob(REMOTE_A);
job.raw = true;
s.submit(job, false, 0, 0);
s.flush(0);
somfy_tx_event_t evt;
TEST_ASSERT_TRUE(s.popEvent(evt));
TEST_ASSERT_EQUAL_UINT32(REMOTE_B, evt.remoteAddress);
assertNoEvents(s);
}
static void test_repeater_frame_doesnt_hold_back_a_stop() {
// It isn't one of our remotes so it has no rolling code of ours to keep in
// order.
RadioScheduler s;
somfy_tx_job_t job = makeJob(REMOTE_A);
job.raw = true;
const uint32_t r = s.submit(job, false, 0, 0);
const uint32_t stop = submit(s, REMOTE_A, 0, true);
TEST_ASSERT_EQUAL_UINT32(stop, runNext(s));
TEST_ASSERT_EQUAL_UINT32(r, runNext(s));
discardEvents(s);
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_jobs_go_out_in_the_order_they_came);
RUN_TEST(test_tickets_are_distinct_and_set_in_the_job);
RUN_TEST(test_nothing_waiting_sleeps_until_woken);
RUN_TEST(test_stop_goes_ahead_of_other_remotes);
RUN_TEST(test_stop_queues_behind_its_own_remote);
RUN_TEST(test_stop_goes_ahead_once_its_remote_is_on_air);
RUN_TEST(test_stops_go_out_in_the_order_they_came);
RUN_TEST(test_second_stop_of_a_remote_stays_behind_the_first);
RUN_TEST(test_normal_jobs_leave_the_reserved_slots);
RUN_TEST(test_stops_use_the_reserved_slots);
RUN_TEST(test_a_refused_job_has_no_ticket);
RUN_TEST(test_taking_a_job_frees_its_slot);
RUN_TEST(test_delayed_job_waits_until_due);
RUN_TEST(test_delayed_job_holds_up_the_jobs_behind_it);
RUN_TEST(test_stop_overtakes_a_delayed_job_of_another_remote);
RUN_TEST(test_stop_waits_behind_its_remotes_delayed_job);
RUN_TEST(test_delay_across_millis_wrap);
RUN_TEST(test_job_reports_started_then_finished);
RUN_TEST(test_cut_short_job_reports_aborted);
RUN_TEST(test_flush_drops_every_waiting_job_once);
RUN_TEST(test_flush_cuts_short_the_job_on_air);
RUN_TEST(test_job_flushed_before_it_starts_is_dropped);
RUN_TEST(test_every_job_gets_one_outcome_across_a_flush);
RUN_TEST(test_unknown_ticket_is_ignored);
RUN_TEST(test_suspend_flushes_and_refuses_jobs);
RUN_TEST(test_resume_needs_every_reason_cleared);
RUN_TEST(test_repeater_frames_report_nothing);
RUN_TEST(test_repeater_frame_doesnt_hold_back_a_stop);
return UNITY_END();
}

View file

@ -0,0 +1,59 @@
#include <unity.h>
#include "RollingCode.h"
// Stands in for NVS and counts how often it is read.
struct FakeStore {
uint16_t code = 0;
int reads = 0;
uint16_t operator()() { this->reads++; return this->code; }
};
// Passes the store by reference so the test sees the read count.
static uint16_t next(bool synced, uint16_t last, FakeStore &store) {
return nextRollingCode(synced, last, [&store]() { return store(); });
}
void setUp() {}
void tearDown() {}
static void test_unsynced_reads_the_store() {
FakeStore store;
store.code = 41;
TEST_ASSERT_EQUAL_UINT16(42, next(false, 7, store));
TEST_ASSERT_EQUAL_INT(1, store.reads);
}
static void test_synced_never_reads_the_store() {
FakeStore store;
store.code = 41;
TEST_ASSERT_EQUAL_UINT16(8, next(true, 7, store));
TEST_ASSERT_EQUAL_INT(0, store.reads);
}
static void test_reading_does_not_use_up_a_code() {
// A command the radio refuses must leave the remote on the same code.
FakeStore store;
store.code = 41;
TEST_ASSERT_EQUAL_UINT16(42, next(false, 0, store));
TEST_ASSERT_EQUAL_UINT16(42, next(false, 0, store));
TEST_ASSERT_EQUAL_UINT16(8, next(true, 7, store));
TEST_ASSERT_EQUAL_UINT16(8, next(true, 7, store));
}
static void test_wraps_to_zero_after_0xFFFF() {
// Current behaviour, kept on purpose for now: the code after 0xFFFF is 0.
// Our own decoder rejects a zero rolling code (see
// test_rejects_zero_rolling_code_except_sensor in test_frame), so a remote
// that gets this far sends one frame other receivers may ignore.
FakeStore store;
store.code = 0xFFFF;
TEST_ASSERT_EQUAL_UINT16(0, next(false, 0, store));
TEST_ASSERT_EQUAL_UINT16(0, next(true, 0xFFFF, store));
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_unsynced_reads_the_store);
RUN_TEST(test_synced_never_reads_the_store);
RUN_TEST(test_reading_does_not_use_up_a_code);
RUN_TEST(test_wraps_to_zero_after_0xFFFF);
return UNITY_END();
}

View file

@ -0,0 +1,245 @@
#include <unity.h>
#include "ShadeMotion.h"
#define ASSERT_POS(expected, actual) TEST_ASSERT_FLOAT_WITHIN(0.01f, (expected), (actual))
static const int8_t DOWN = 1;
static const int8_t UP = -1;
static const bool IDLE = false;
static const bool MOVING = true;
static somfy_tx_event_t evt(tx_event_t type, uint32_t seq, uint32_t at = 0) {
somfy_tx_event_t e;
e.type = type;
e.seq = seq;
e.at = at;
return e;
}
// A shade whose idle motor was set moving by radio job seq.
static ShadeTxState heldBy(uint32_t seq) {
ShadeTxState tx;
tx.hold.mark(seq, false, 0);
return tx;
}
void setUp() {}
void tearDown() {}
/*********************************************************************
* The job that set the shade moving
********************************************************************/
static void test_held_job_started_starts_the_movement() {
ShadeTxState tx = heldBy(7);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::started, 7, 1234), MOVING);
TEST_ASSERT_TRUE(act.startMovement);
TEST_ASSERT_EQUAL_UINT32(1234, act.startAt);
TEST_ASSERT_FALSE(act.cancelMovement);
TEST_ASSERT_FALSE(tx.hold.held());
}
static void test_held_job_finished_after_start_does_nothing() {
ShadeTxState tx = heldBy(7);
tx.onEvent(evt(tx_event_t::started, 7, 1234), MOVING);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::finished, 7, 2000), MOVING);
TEST_ASSERT_FALSE(act.any());
}
static void test_held_job_dropped_cancels_the_movement() {
ShadeTxState tx = heldBy(7);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 7), MOVING);
TEST_ASSERT_TRUE(act.cancelMovement);
TEST_ASSERT_FALSE(act.startMovement);
TEST_ASSERT_FALSE(tx.hold.held());
}
static void test_held_job_aborted_before_start_cancels_the_movement() {
ShadeTxState tx = heldBy(7);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::aborted, 7), MOVING);
TEST_ASSERT_TRUE(act.cancelMovement);
}
/*********************************************************************
* The favorite (long press My)
********************************************************************/
static void test_favorite_started_waits_for_the_end() {
ShadeTxState tx;
tx.setMySent(9, 40.0f, 10.0f);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::started, 9), IDLE);
TEST_ASSERT_FALSE(act.any());
TEST_ASSERT_EQUAL_UINT32(9, tx.mySeq);
}
static void test_favorite_finished_is_committed() {
ShadeTxState tx;
tx.setMySent(9, 40.0f, 10.0f);
tx.onEvent(evt(tx_event_t::started, 9), IDLE);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::finished, 9), IDLE);
TEST_ASSERT_TRUE(act.commitMy);
TEST_ASSERT_FALSE(act.restoreMy);
TEST_ASSERT_EQUAL_UINT32(0, tx.mySeq);
}
static void test_favorite_aborted_restores_the_previous_one() {
// The motor only stores the favorite when it gets the whole long press.
ShadeTxState tx;
tx.setMySent(9, 40.0f, 10.0f);
tx.onEvent(evt(tx_event_t::started, 9), IDLE);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::aborted, 9), IDLE);
TEST_ASSERT_TRUE(act.restoreMy);
TEST_ASSERT_FALSE(act.commitMy);
ASSERT_POS(40.0f, act.myOldPos);
ASSERT_POS(10.0f, act.myOldTiltPos);
TEST_ASSERT_EQUAL_UINT32(0, tx.mySeq);
}
static void test_favorite_dropped_restores_the_previous_one() {
ShadeTxState tx;
tx.setMySent(9, -1.0f, -1.0f);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 9), IDLE);
TEST_ASSERT_TRUE(act.restoreMy);
ASSERT_POS(-1.0f, act.myOldPos);
ASSERT_POS(-1.0f, act.myOldTiltPos);
}
/*********************************************************************
* The stop sent at the target
********************************************************************/
static void test_stop_dropped_runs_on_to_the_limit() {
ShadeTxState tx;
tx.stopSent(12, 5000, DOWN, false);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 12, 5300), IDLE);
TEST_ASSERT_TRUE(act.runToLimit);
TEST_ASSERT_EQUAL_INT8(DOWN, act.limitDir);
TEST_ASSERT_FALSE(act.limitTilt);
// From when the motor should have stopped, not when the drop was reported.
TEST_ASSERT_EQUAL_UINT32(5000, act.limitSince);
TEST_ASSERT_EQUAL_UINT32(0, tx.stopSeq);
}
static void test_tilt_stop_dropped_runs_the_tilt_on() {
ShadeTxState tx;
tx.stopSent(12, 5000, UP, true);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 12), IDLE);
TEST_ASSERT_TRUE(act.runToLimit);
TEST_ASSERT_EQUAL_INT8(UP, act.limitDir);
TEST_ASSERT_TRUE(act.limitTilt);
}
static void test_stop_dropped_after_moving_again_does_nothing() {
// Something set the shade moving since, so that command decides where the
// motor goes.
ShadeTxState tx;
tx.stopSent(12, 5000, DOWN, false);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 12), MOVING);
TEST_ASSERT_FALSE(act.any());
TEST_ASSERT_EQUAL_UINT32(0, tx.stopSeq);
}
static void test_stop_on_air_does_nothing() {
ShadeTxState tx;
tx.stopSent(12, 5000, DOWN, false);
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::started, 12), IDLE).any());
TEST_ASSERT_EQUAL_UINT32(0, tx.stopSeq);
// Nothing is waiting on it any more.
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::dropped, 12), IDLE).any());
}
static void test_stop_aborted_does_not_run_on() {
// Part of the stop went on air, which is enough for the motor.
ShadeTxState tx;
tx.stopSent(12, 5000, DOWN, false);
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::aborted, 12), IDLE).any());
}
/*********************************************************************
* Edge cases
********************************************************************/
static void test_one_event_for_the_held_job_and_the_favorite() {
// The delayed My that stores the favorite also sets an idle shade moving,
// so the same job is held and awaited as the favorite.
ShadeTxState tx = heldBy(9);
tx.setMySent(9, 40.0f, 10.0f);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 9), MOVING);
TEST_ASSERT_TRUE(act.cancelMovement);
TEST_ASSERT_TRUE(act.restoreMy);
TEST_ASSERT_FALSE(tx.hold.held());
TEST_ASSERT_EQUAL_UINT32(0, tx.mySeq);
}
static void test_seq_zero_is_ignored() {
ShadeTxState tx;
tx.setMySent(0, 40.0f, 10.0f);
tx.stopSent(0, 5000, DOWN, false);
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::dropped, 0), IDLE).any());
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::finished, 0), IDLE).any());
}
static void test_other_jobs_are_ignored() {
ShadeTxState tx = heldBy(7);
tx.setMySent(9, 40.0f, 10.0f);
tx.stopSent(12, 5000, DOWN, false);
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::dropped, 8), IDLE).any());
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::finished, 13), IDLE).any());
TEST_ASSERT_EQUAL_UINT32(7, tx.hold.seq);
TEST_ASSERT_EQUAL_UINT32(9, tx.mySeq);
TEST_ASSERT_EQUAL_UINT32(12, tx.stopSeq);
}
/*********************************************************************
* limitTarget
********************************************************************/
static void test_limitTarget() {
ASSERT_POS(100.0f, limitTarget(DOWN));
ASSERT_POS(0.0f, limitTarget(UP));
}
/*********************************************************************
* TxHold::check
********************************************************************/
static void test_check_nothing_held() {
TxHold hold;
TEST_ASSERT_TRUE(hold.check(true, 1000, 30000) == TxHoldCheck::none);
}
static void test_check_idle_shade_drops_the_hold() {
// The command didn't set the shade moving so there is nothing to hold.
TxHold hold;
hold.mark(5, false, 1000);
TEST_ASSERT_TRUE(hold.check(false, 1000, 30000) == TxHoldCheck::none);
TEST_ASSERT_FALSE(hold.held());
}
static void test_check_holds_a_moving_shade() {
TxHold hold;
hold.mark(5, false, 1000);
TEST_ASSERT_TRUE(hold.check(true, 30999, 30000) == TxHoldCheck::holding);
TEST_ASSERT_TRUE(hold.held());
}
static void test_check_expires() {
TxHold hold;
hold.mark(5, false, 1000);
TEST_ASSERT_TRUE(hold.check(true, 31000, 30000) == TxHoldCheck::expired);
TEST_ASSERT_FALSE(hold.held());
TEST_ASSERT_TRUE(hold.check(true, 31001, 30000) == TxHoldCheck::none);
}
static void test_check_across_millis_wrap() {
TxHold hold;
hold.mark(5, false, 0xFFFFF000u);
TEST_ASSERT_TRUE(hold.check(true, 0x00006000u, 30000) == TxHoldCheck::holding);
TEST_ASSERT_TRUE(hold.check(true, 0x00007000u, 30000) == TxHoldCheck::expired);
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_held_job_started_starts_the_movement);
RUN_TEST(test_held_job_finished_after_start_does_nothing);
RUN_TEST(test_held_job_dropped_cancels_the_movement);
RUN_TEST(test_held_job_aborted_before_start_cancels_the_movement);
RUN_TEST(test_favorite_started_waits_for_the_end);
RUN_TEST(test_favorite_finished_is_committed);
RUN_TEST(test_favorite_aborted_restores_the_previous_one);
RUN_TEST(test_favorite_dropped_restores_the_previous_one);
RUN_TEST(test_stop_dropped_runs_on_to_the_limit);
RUN_TEST(test_tilt_stop_dropped_runs_the_tilt_on);
RUN_TEST(test_stop_dropped_after_moving_again_does_nothing);
RUN_TEST(test_stop_on_air_does_nothing);
RUN_TEST(test_stop_aborted_does_not_run_on);
RUN_TEST(test_one_event_for_the_held_job_and_the_favorite);
RUN_TEST(test_seq_zero_is_ignored);
RUN_TEST(test_other_jobs_are_ignored);
RUN_TEST(test_limitTarget);
RUN_TEST(test_check_nothing_held);
RUN_TEST(test_check_idle_shade_drops_the_hold);
RUN_TEST(test_check_holds_a_moving_shade);
RUN_TEST(test_check_expires);
RUN_TEST(test_check_across_millis_wrap);
return UNITY_END();
}

View file

@ -0,0 +1,114 @@
#include <unity.h>
#include "TxBuffer.h"
void setUp() {}
void tearDown() {}
// Pushes a frame whose payload is all `tag` so frames can be told apart.
static void pushTagged(somfy_tx_queue_t &q, uint8_t tag, uint8_t bitLength = 56) {
uint8_t payload[10];
memset(payload, tag, sizeof(payload));
q.push(bitLength == 56 ? 2 : 12, payload, bitLength);
}
static void assertTagged(const somfy_tx_t &tx, uint8_t tag) {
uint8_t expected[10];
memset(expected, tag, sizeof(expected));
TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, tx.payload, sizeof(expected));
}
static void test_push_then_pop_returns_the_frame() {
somfy_tx_queue_t q;
pushTagged(q, 0x11, 80);
TEST_ASSERT_EQUAL_UINT8(1, q.length);
somfy_tx_t tx;
TEST_ASSERT_TRUE(q.pop(&tx));
TEST_ASSERT_EQUAL_UINT8(12, tx.hwsync);
TEST_ASSERT_EQUAL_UINT8(80, tx.bit_length);
assertTagged(tx, 0x11);
TEST_ASSERT_EQUAL_UINT8(0, q.length);
}
static void test_frames_come_out_oldest_first() {
somfy_tx_queue_t q;
pushTagged(q, 1);
pushTagged(q, 2);
pushTagged(q, 3);
somfy_tx_t tx;
for(uint8_t tag = 1; tag <= 3; tag++) {
TEST_ASSERT_TRUE(q.pop(&tx));
assertTagged(tx, tag);
}
TEST_ASSERT_FALSE(q.pop(&tx));
}
static void test_peek_leaves_the_frame_queued() {
somfy_tx_queue_t q;
pushTagged(q, 1);
pushTagged(q, 2);
somfy_tx_t tx;
TEST_ASSERT_TRUE(q.peek(&tx));
assertTagged(tx, 1);
TEST_ASSERT_TRUE(q.peek(&tx));
assertTagged(tx, 1);
TEST_ASSERT_EQUAL_UINT8(2, q.length);
TEST_ASSERT_TRUE(q.pop(&tx));
assertTagged(tx, 1);
TEST_ASSERT_TRUE(q.peek(&tx));
assertTagged(tx, 2);
}
static void test_empty_buffer_has_nothing() {
somfy_tx_queue_t q;
somfy_tx_t tx;
TEST_ASSERT_FALSE(q.peek(&tx));
TEST_ASSERT_FALSE(q.pop(&tx));
TEST_ASSERT_EQUAL_UINT8(0, q.length);
}
static void test_overflow_drops_the_oldest() {
somfy_tx_queue_t q;
for(uint8_t tag = 1; tag <= MAX_TX_BUFFER + 2; tag++) pushTagged(q, tag);
TEST_ASSERT_EQUAL_UINT8(MAX_TX_BUFFER, q.length);
somfy_tx_t tx;
// The first two fell off.
for(uint8_t tag = 3; tag <= MAX_TX_BUFFER + 2; tag++) {
TEST_ASSERT_TRUE(q.pop(&tx));
assertTagged(tx, tag);
}
TEST_ASSERT_FALSE(q.pop(&tx));
}
static void test_slots_are_reused_after_pop() {
somfy_tx_queue_t q;
somfy_tx_t tx;
// Keep two frames in flight so freed slots are filled again many times over.
for(uint8_t tag = 1; tag <= 4 * MAX_TX_BUFFER; tag++) {
pushTagged(q, tag);
if(tag >= 2) {
TEST_ASSERT_TRUE(q.pop(&tx));
assertTagged(tx, tag - 1);
TEST_ASSERT_EQUAL_UINT8(1, q.length);
}
}
}
static void test_clear_empties_it() {
somfy_tx_queue_t q;
pushTagged(q, 1);
pushTagged(q, 2);
q.clear();
TEST_ASSERT_EQUAL_UINT8(0, q.length);
somfy_tx_t tx;
TEST_ASSERT_FALSE(q.peek(&tx));
pushTagged(q, 3);
TEST_ASSERT_TRUE(q.pop(&tx));
assertTagged(tx, 3);
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_push_then_pop_returns_the_frame);
RUN_TEST(test_frames_come_out_oldest_first);
RUN_TEST(test_peek_leaves_the_frame_queued);
RUN_TEST(test_empty_buffer_has_nothing);
RUN_TEST(test_overflow_drops_the_oldest);
RUN_TEST(test_slots_are_reused_after_pop);
RUN_TEST(test_clear_empties_it);
return UNITY_END();
}

View file

@ -0,0 +1,117 @@
#include <unity.h>
#include "TxBuffer.h"
static const uint32_t ADDR = 0x123456;
// A frame as SomfyRemote::sendCommandAfter builds it.
static somfy_frame_t makeFrame(somfy_commands cmd, uint8_t bitLength = 56) {
somfy_frame_t f;
f.cmd = cmd;
f.remoteAddress = ADDR;
f.rollingCode = 0x0102;
f.encKey = 0xA2;
f.bitLength = bitLength;
f.valid = true;
return f;
}
void setUp() {}
void tearDown() {}
/*********************************************************************
* encodeTxJob
********************************************************************/
static void test_encode_56bit_job() {
somfy_frame_t f = makeFrame(somfy_commands::Up);
somfy_tx_job_t job;
encodeTxJob(job, f, 3);
byte expected[10] = {};
somfy_frame_t copy = makeFrame(somfy_commands::Up);
copy.encodeFrame(expected);
TEST_ASSERT_EQUAL_UINT8(2, job.tx.hwsync);
TEST_ASSERT_EQUAL_UINT8(56, job.tx.bit_length);
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, job.tx.payload, sizeof(expected));
TEST_ASSERT_EQUAL_UINT8(3, job.repeats);
}
static void test_encode_80bit_job() {
somfy_frame_t f = makeFrame(somfy_commands::Down, 80);
somfy_tx_job_t job;
encodeTxJob(job, f, 1);
byte expected[10] = {};
somfy_frame_t copy = makeFrame(somfy_commands::Down, 80);
copy.encodeFrame(expected);
TEST_ASSERT_EQUAL_UINT8(12, job.tx.hwsync);
TEST_ASSERT_EQUAL_UINT8(80, job.tx.bit_length);
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, job.tx.payload, sizeof(expected));
}
static void test_encode_job_copies_the_frame() {
somfy_frame_t f = makeFrame(somfy_commands::My);
somfy_tx_job_t job;
encodeTxJob(job, f, 1);
TEST_ASSERT_EQUAL_UINT32(ADDR, job.frame.remoteAddress);
TEST_ASSERT_EQUAL_UINT16(0x0102, job.frame.rollingCode);
// The radio task uses its own copy, so later changes to the remote's frame
// don't reach it.
f.rollingCode = 0x0200;
TEST_ASSERT_EQUAL_UINT16(0x0102, job.frame.rollingCode);
}
static void test_encode_job_limits_repeats() {
somfy_frame_t f = makeFrame(somfy_commands::My);
somfy_tx_job_t job;
encodeTxJob(job, f, TX_MAX_REPEATS);
TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats);
encodeTxJob(job, f, TX_MAX_REPEATS + 1);
TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats);
encodeTxJob(job, f, 255);
TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats);
encodeTxJob(job, f, 0);
TEST_ASSERT_EQUAL_UINT8(0, job.repeats);
}
/*********************************************************************
* Repeat jobs
********************************************************************/
static void test_repeat_job_counts_on_from_the_frame() {
somfy_frame_t f = makeFrame(somfy_commands::Up, 80);
f.repeats = 4;
somfy_tx_job_t job;
encodeRepeatJob(job, f, 2);
TEST_ASSERT_EQUAL_UINT8(6, job.firstRepeat);
TEST_ASSERT_EQUAL_UINT8(2, job.repeats);
// Nothing moves until the radio takes the job.
TEST_ASSERT_EQUAL_UINT8(4, f.repeats);
}
static void test_repeat_queued_advances_the_counter() {
somfy_frame_t f = makeFrame(somfy_commands::Up, 80);
f.repeats = 4;
somfy_tx_job_t job;
encodeRepeatJob(job, f, 2);
noteRepeatQueued(f, job);
// The first frame plus two repeats went out.
TEST_ASSERT_EQUAL_UINT8(7, f.repeats);
// The next repeat job picks up where this one stops.
somfy_tx_job_t next;
encodeRepeatJob(next, f, 1);
TEST_ASSERT_EQUAL_UINT8(9, next.firstRepeat);
}
static void test_repeat_queued_uses_the_limited_repeats() {
somfy_frame_t f = makeFrame(somfy_commands::Up);
somfy_tx_job_t job;
encodeRepeatJob(job, f, 200);
noteRepeatQueued(f, job);
TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS + 1, f.repeats);
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_encode_56bit_job);
RUN_TEST(test_encode_80bit_job);
RUN_TEST(test_encode_job_copies_the_frame);
RUN_TEST(test_encode_job_limits_repeats);
RUN_TEST(test_repeat_job_counts_on_from_the_frame);
RUN_TEST(test_repeat_queued_advances_the_counter);
RUN_TEST(test_repeat_queued_uses_the_limited_repeats);
return UNITY_END();
}

View file

@ -0,0 +1,280 @@
#include <unity.h>
#include "Utils.h"
// The C runtime only picks up TZ after it is re-read.
static void setTZ(const char *tz) {
#ifdef _WIN32
_putenv_s("TZ", tz);
_tzset();
#else
setenv("TZ", tz, 1);
tzset();
#endif
}
// 2024-03-05T07:08:09Z
static const time_t SAMPLE_UTC = 1709622489;
void setUp() { setTZ("UTC0"); }
void tearDown() {}
/*********************************************************************
* _trim
********************************************************************/
static void test_trim_strips_whitespace_and_quotes() {
char str[] = " \t\"Living Room\"\r\n";
_trim(str);
TEST_ASSERT_EQUAL_STRING("Living Room", str);
}
static void test_trim_keeps_clean_string() {
char str[] = "Kitchen";
_trim(str);
TEST_ASSERT_EQUAL_STRING("Kitchen", str);
}
static void test_trim_all_whitespace_becomes_empty() {
char str[] = " \t\r\n\" ";
_trim(str);
TEST_ASSERT_EQUAL_STRING("", str);
}
static void test_trim_empty_string() {
char str[] = "";
_trim(str);
TEST_ASSERT_EQUAL_STRING("", str);
}
/*********************************************************************
* toBoolean
********************************************************************/
static void test_toBoolean_missing_uses_default() {
TEST_ASSERT_TRUE(toBoolean(nullptr, true));
TEST_ASSERT_FALSE(toBoolean(nullptr, false));
TEST_ASSERT_TRUE(toBoolean("", true));
TEST_ASSERT_FALSE(toBoolean("", false));
}
static void test_toBoolean_true_values() {
TEST_ASSERT_TRUE(toBoolean("true", false));
TEST_ASSERT_TRUE(toBoolean("True", false));
TEST_ASSERT_TRUE(toBoolean("1", false));
}
static void test_toBoolean_false_values() {
TEST_ASSERT_FALSE(toBoolean("false", true));
TEST_ASSERT_FALSE(toBoolean("False", true));
TEST_ASSERT_FALSE(toBoolean("0", true));
}
static void test_toBoolean_unknown_uses_default() {
TEST_ASSERT_TRUE(toBoolean("yes", true));
TEST_ASSERT_FALSE(toBoolean("yes", false));
}
/*********************************************************************
* SETCHARPROP
********************************************************************/
static void test_setcharprop_copies_short_value() {
char prop[8];
SETCHARPROP(prop, "abc", sizeof(prop));
TEST_ASSERT_EQUAL_STRING("abc", prop);
}
static void test_setcharprop_truncates_long_value() {
char prop[5];
SETCHARPROP(prop, "abcdefgh", sizeof(prop));
TEST_ASSERT_EQUAL_STRING("abcd", prop);
}
static void test_setcharprop_unterminated_source() {
// A coredump's app_elf_sha256 can be full of hex digits with no terminator.
// The copy must stop inside the array and come out as a comparable string.
const char crashed[17] = {'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f','9'};
char sha[sizeof(crashed)];
SETCHARPROP(sha, crashed, sizeof(sha));
TEST_ASSERT_EQUAL_STRING("0123456789abcdef", sha);
TEST_ASSERT_EQUAL_INT(0, strcmp(sha, "0123456789abcdef"));
}
/*********************************************************************
* sortArray / sortArrayReverse
********************************************************************/
static bool absLarger(int a, int b) { return abs(a) > abs(b); }
static void test_sortArray_ascending() {
int arr[] = {5, -1, 3, 0, 3};
const int expected[] = {-1, 0, 3, 3, 5};
sortArray(arr, 5);
TEST_ASSERT_EQUAL_INT_ARRAY(expected, arr, 5);
}
static void test_sortArrayReverse_descending() {
int arr[] = {5, -1, 3, 0, 3};
const int expected[] = {5, 3, 3, 0, -1};
sortArrayReverse(arr, 5);
TEST_ASSERT_EQUAL_INT_ARRAY(expected, arr, 5);
}
static void test_sortArray_custom_comparator() {
int arr[] = {-4, 1, 3, -2};
const int expected[] = {1, -2, 3, -4};
sortArray(arr, 4, absLarger);
TEST_ASSERT_EQUAL_INT_ARRAY(expected, arr, 4);
const int reversed[] = {-4, 3, -2, 1};
sortArrayReverse(arr, 4, absLarger);
TEST_ASSERT_EQUAL_INT_ARRAY(reversed, arr, 4);
}
static void test_sortArray_empty_and_single() {
int one[] = {42};
sortArray(one, 1);
TEST_ASSERT_EQUAL_INT(42, one[0]);
int none[] = {7};
sortArray(none, 0);
TEST_ASSERT_EQUAL_INT(7, none[0]);
}
static void test_sortArray_moves_free_ids_last() {
// Free shade id slots are 255 and must end up after the used ones.
uint8_t ids[] = {3, 255, 1, 255, 2};
const uint8_t expected[] = {1, 2, 3, 255, 255};
sortArray<uint8_t>(ids, sizeof(ids));
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, ids, sizeof(ids));
}
/*********************************************************************
* Timestamp
********************************************************************/
static struct tm sampleTm() {
struct tm dt = {};
dt.tm_year = 2024 - 1900;
dt.tm_mon = 2;
dt.tm_mday = 5;
dt.tm_hour = 7;
dt.tm_min = 8;
dt.tm_sec = 9;
return dt;
}
static void test_formatISO_offsets() {
Timestamp ts;
struct tm dt = sampleTm();
TEST_ASSERT_EQUAL_STRING("2024-03-05T07:08:09.000+0000", ts.formatISO(&dt, 0));
TEST_ASSERT_EQUAL_STRING("2024-03-05T07:08:09.000-0500", ts.formatISO(&dt, -500));
TEST_ASSERT_EQUAL_STRING("2024-03-05T07:08:09.000+0530", ts.formatISO(&dt, 530));
}
static void test_calcTZOffset_whole_hours() {
time_t t = SAMPLE_UTC;
setTZ("UTC0");
TEST_ASSERT_EQUAL_INT(0, Timestamp::calcTZOffset(&t));
setTZ("EST5");
TEST_ASSERT_EQUAL_INT(-500, Timestamp::calcTZOffset(&t));
}
static void test_calcTZOffset_half_hour_east() {
time_t t = SAMPLE_UTC;
setTZ("IST-5:30");
TEST_ASSERT_EQUAL_INT(530, Timestamp::calcTZOffset(&t));
}
static void test_calcTZOffset_half_hour_west() {
time_t t = SAMPLE_UTC;
setTZ("NST3:30");
TEST_ASSERT_EQUAL_INT(-330, Timestamp::calcTZOffset(&t));
}
static void test_parseUTCTime_in_utc() {
setTZ("UTC0");
TEST_ASSERT_EQUAL_INT64((int64_t)SAMPLE_UTC, (int64_t)Timestamp::parseUTCTime("2024-03-05T07:08:09Z"));
}
static void test_parseUTCTime_ignores_local_zone() {
setTZ("IST-5:30");
TEST_ASSERT_EQUAL_INT64((int64_t)SAMPLE_UTC, (int64_t)Timestamp::parseUTCTime("2024-03-05T07:08:09Z"));
}
/*********************************************************************
* rssiNeedsEmit
********************************************************************/
static void test_rssiNeedsEmit_channel_change_always_emits() {
TEST_ASSERT_TRUE(rssiNeedsEmit(-70, -70, 6, 11, 0, 3, 10000));
}
static void test_rssiNeedsEmit_small_change_is_ignored() {
TEST_ASSERT_FALSE(rssiNeedsEmit(-72, -70, 6, 6, 60000, 3, 10000));
}
static void test_rssiNeedsEmit_real_change_after_interval_emits() {
TEST_ASSERT_TRUE(rssiNeedsEmit(-73, -70, 6, 6, 10000, 3, 10000));
}
static void test_rssiNeedsEmit_real_change_too_soon_waits() {
TEST_ASSERT_FALSE(rssiNeedsEmit(-73, -70, 6, 6, 5000, 3, 10000));
}
static void test_rssiNeedsEmit_either_direction() {
TEST_ASSERT_TRUE(rssiNeedsEmit(-67, -70, 6, 6, 10000, 3, 10000));
TEST_ASSERT_TRUE(rssiNeedsEmit(-73, -70, 6, 6, 10000, 3, 10000));
}
/*********************************************************************
* RssiBroadcast
********************************************************************/
static RssiBroadcast broadcastAt(int rssi, int channel, uint32_t at) {
RssiBroadcast b;
b.sent(true, rssi, channel, at);
return b;
}
static void test_rssiBroadcast_client_emit_keeps_baseline() {
// A new client gets the current RSSI right as it drops 5 dBm. The clients
// already connected must still get the change.
RssiBroadcast b = broadcastAt(-60, 6, 0);
b.sent(false, -65, 6, 20000);
TEST_ASSERT_EQUAL_INT(-60, b.rssi);
TEST_ASSERT_EQUAL_UINT32(0, b.at);
TEST_ASSERT_TRUE(b.due(-65, 6, 20000, 3, 10000));
}
static void test_rssiBroadcast_broadcast_moves_baseline() {
RssiBroadcast b = broadcastAt(-60, 6, 0);
b.sent(true, -65, 6, 20000);
TEST_ASSERT_EQUAL_INT(-65, b.rssi);
TEST_ASSERT_EQUAL_UINT32(20000, b.at);
// Measured from the new baseline and too soon after it.
TEST_ASSERT_FALSE(b.due(-66, 6, 40000, 3, 10000));
TEST_ASSERT_FALSE(b.due(-70, 6, 25000, 3, 10000));
TEST_ASSERT_TRUE(b.due(-70, 6, 30000, 3, 10000));
}
static void test_rssiBroadcast_channel_change_is_due() {
RssiBroadcast b = broadcastAt(-60, 6, 1000);
TEST_ASSERT_TRUE(b.due(-60, 11, 1000, 3, 10000));
}
static void test_rssiBroadcast_client_emit_on_disconnect_keeps_baseline() {
RssiBroadcast b = broadcastAt(-60, 6, 0);
b.sent(false, -100, -1, 5000);
TEST_ASSERT_EQUAL_INT(6, b.channel);
TEST_ASSERT_FALSE(b.due(-60, 6, 5000, 3, 10000));
}
static void test_rssiBroadcast_hold_off_across_millis_wrap() {
RssiBroadcast b = broadcastAt(-60, 6, 0xFFFFF000u);
TEST_ASSERT_FALSE(b.due(-70, 6, 0x00001000u, 3, 10000)); // 8192ms later
TEST_ASSERT_TRUE(b.due(-70, 6, 0x00002000u, 3, 10000)); // 12288ms later
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_trim_strips_whitespace_and_quotes);
RUN_TEST(test_trim_keeps_clean_string);
RUN_TEST(test_trim_all_whitespace_becomes_empty);
RUN_TEST(test_trim_empty_string);
RUN_TEST(test_toBoolean_missing_uses_default);
RUN_TEST(test_toBoolean_true_values);
RUN_TEST(test_toBoolean_false_values);
RUN_TEST(test_toBoolean_unknown_uses_default);
RUN_TEST(test_setcharprop_copies_short_value);
RUN_TEST(test_setcharprop_truncates_long_value);
RUN_TEST(test_setcharprop_unterminated_source);
RUN_TEST(test_sortArray_ascending);
RUN_TEST(test_sortArrayReverse_descending);
RUN_TEST(test_sortArray_custom_comparator);
RUN_TEST(test_sortArray_empty_and_single);
RUN_TEST(test_sortArray_moves_free_ids_last);
RUN_TEST(test_formatISO_offsets);
RUN_TEST(test_calcTZOffset_whole_hours);
RUN_TEST(test_calcTZOffset_half_hour_east);
RUN_TEST(test_calcTZOffset_half_hour_west);
RUN_TEST(test_parseUTCTime_in_utc);
RUN_TEST(test_parseUTCTime_ignores_local_zone);
RUN_TEST(test_rssiNeedsEmit_channel_change_always_emits);
RUN_TEST(test_rssiNeedsEmit_small_change_is_ignored);
RUN_TEST(test_rssiNeedsEmit_real_change_after_interval_emits);
RUN_TEST(test_rssiNeedsEmit_real_change_too_soon_waits);
RUN_TEST(test_rssiNeedsEmit_either_direction);
RUN_TEST(test_rssiBroadcast_client_emit_keeps_baseline);
RUN_TEST(test_rssiBroadcast_broadcast_moves_baseline);
RUN_TEST(test_rssiBroadcast_channel_change_is_due);
RUN_TEST(test_rssiBroadcast_client_emit_on_disconnect_keeps_baseline);
RUN_TEST(test_rssiBroadcast_hold_off_across_millis_wrap);
return UNITY_END();
}

View file

@ -0,0 +1,48 @@
#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 void test_only_set_positions_stays_off_the_radio() {
int offRadio = 0;
for(uint8_t t = 0; t <= static_cast<uint8_t>(web_cmd_t::unpair_shade); t++) {
if(!webCommandUsesRadio(static_cast<web_cmd_t>(t))) offRadio++;
}
TEST_ASSERT_EQUAL_INT(1, offRadio);
TEST_ASSERT_FALSE(webCommandUsesRadio(web_cmd_t::set_positions));
}
static void test_set_positions_is_never_held() {
TEST_ASSERT_FALSE(holdWebCommand(web_cmd_t::set_positions, false, false));
TEST_ASSERT_FALSE(holdWebCommand(web_cmd_t::set_positions, 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_nothing_is_held_while_suspended() {
// Nothing goes on air during a frequency scan or OTA anyway.
for(web_cmd_t t : RADIO_COMMANDS) {
TEST_ASSERT_FALSE(holdWebCommand(t, true, false));
TEST_ASSERT_FALSE(holdWebCommand(t, true, true));
}
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_only_set_positions_stays_off_the_radio);
RUN_TEST(test_set_positions_is_never_held);
RUN_TEST(test_radio_command_waits_for_room);
RUN_TEST(test_nothing_is_held_while_suspended);
return UNITY_END();
}