diff --git a/src/RollingCode.h b/src/RollingCode.h new file mode 100644 index 0000000..67fba39 --- /dev/null +++ b/src/RollingCode.h @@ -0,0 +1,16 @@ +#ifndef ROLLING_CODE_H +#define ROLLING_CODE_H +#include + +// 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 +inline uint16_t nextRollingCode(bool synced, uint16_t last, LoadStored loadStored) { + return (uint16_t)((synced ? last : loadStored()) + 1); +} +#endif diff --git a/src/ShadeMotion.cpp b/src/ShadeMotion.cpp index da5328b..b1b2e9c 100644 --- a/src/ShadeMotion.cpp +++ b/src/ShadeMotion.cpp @@ -29,6 +29,7 @@ int8_t displayPosition(float pos, bool flip) { if(pos < 0) return -1; return static_cast(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; @@ -39,3 +40,58 @@ bool TxHold::release(uint32_t jobSeq) { 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; +} diff --git a/src/ShadeMotion.h b/src/ShadeMotion.h index d45dc0e..dff2c14 100644 --- a/src/ShadeMotion.h +++ b/src/ShadeMotion.h @@ -1,6 +1,7 @@ #ifndef SHADE_MOTION_H #define SHADE_MOTION_H #include +#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). @@ -12,7 +13,15 @@ float travelPosition(float startPos, int8_t dir, uint32_t elapsedMs, int32_t tra // 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 { @@ -30,5 +39,51 @@ struct TxHold { 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 diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 5deb257..e7437ab 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -12,6 +12,8 @@ #include "ConfigSettings.h" #include "Somfy.h" #include "ShadeMotion.h" +#include "TxQueuePolicy.h" +#include "RollingCode.h" #include "Sockets.h" #include "MQTT.h" #include "ConfigFile.h" @@ -39,10 +41,6 @@ uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to #define SETMY_REPEATS 35 #define TILT_REPEATS 15 -// 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 #define TX_QUEUE_DELAY 100 #define TX_TASK_PRIORITY 5 #define TX_TASK_STACK 4096 @@ -75,7 +73,7 @@ static std::atomic txBackTaken{0}; // The radio task and a flush both move txBackTaken. Never let it go backwards. static void advanceBackTaken(uint32_t seq) { uint32_t cur = txBackTaken; - while((int32_t)(seq - cur) > 0 && !txBackTaken.compare_exchange_weak(cur, seq)) {} + while(txSeqAfter(seq, cur) && !txBackTaken.compare_exchange_weak(cur, seq)) {} } // How long a shade's simulated movement waits for its command to go on air // before it starts anyway in case the event was lost. @@ -107,20 +105,6 @@ static void postTxEvent(tx_event_t type, const somfy_tx_job_t &job, uint32_t at) evt.at = at; if(xQueueSend(txEventQueue, &evt, 0) != pdTRUE) ESP_LOGW(TAG, "Radio TX event queue full"); } -// 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. -static 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; -} // Commands that can set a shade moving. static bool startsMovement(somfy_commands cmd) { switch(cmd) { @@ -762,20 +746,18 @@ void SomfyShade::checkMovement() { uint8_t currPos = floor(this->currentPos); uint8_t currTiltPos = floor(this->currentTiltPos); if(this->direction != 0) this->lastMovement = this->direction; - if(this->txHold.held() && this->direction == 0 && this->tiltDirection == 0) { - // The command didn't set the shade moving so there is nothing to hold. - this->txHold.clear(); - } - if(this->txHold.held()) { - // The command that set us moving hasn't gone on air yet so the motor hasn't - // started. Hold the simulated position until it does. - if(!this->txHold.timedOut((uint32_t)curTime, TX_PENDING_TIMEOUT)) { + switch(this->tx.hold.check(this->direction != 0 || this->tiltDirection != 0, (uint32_t)curTime, TX_PENDING_TIMEOUT)) { + case TxHoldCheck::holding: + // The command that set us moving hasn't gone on air yet so the motor + // hasn't started. Hold the simulated position until it does. this->moveStart = this->tiltStart = curTime; if(currDir != this->direction || currTiltDir != this->tiltDirection) this->emitState(); return; - } - ESP_LOGW(TAG, "[Shade %u] Timed out waiting for the radio, moving anyway", this->shadeId); - this->txHold.clear(); + case TxHoldCheck::expired: + ESP_LOGW(TAG, "[Shade %u] Timed out waiting for the radio, moving anyway", this->shadeId); + break; + case TxHoldCheck::none: + break; } if (sunFlag) { if (isSunny && !isWindy) { // It is sunny and there is no wind so we should be extended @@ -2339,54 +2321,40 @@ void SomfyShade::markTxPending(uint32_t seq) { // whether the shade was moving. A reversal is simulated as soon as it is // queued even though the motor turns once it is on air. See TxHold::mark // for which of several queued commands is held. - this->txHold.mark(seq, this->direction != 0 || this->tiltDirection != 0, millis()); + this->tx.hold.mark(seq, this->direction != 0 || this->tiltDirection != 0, millis()); } void SomfyShade::processTxEvent(const somfy_tx_event_t &evt) { - if(evt.seq == 0) return; - if(this->txHold.release(evt.seq)) { - if(evt.type == tx_event_t::started) { - // The motor starts now so the simulated movement does too. - this->moveStart = this->tiltStart = evt.at; - } - else { - // The motor never got the command so it hasn't moved. - ESP_LOGW(TAG, "[Shade %u] Command dropped by the radio, staying at %.2f%%", this->shadeId, this->currentPos); - this->settingPos = this->settingTiltPos = this->settingMyPos = false; - this->p_target(this->currentPos); - this->p_tiltTarget(this->currentTiltPos); - this->emitState(); - } + const ShadeTxActions act = this->tx.onEvent(evt, this->direction != 0 || this->tiltDirection != 0); + if(act.startMovement) { + // The motor starts now so the simulated movement does too. + this->moveStart = this->tiltStart = act.startAt; } - if(this->txMySeq == evt.seq && evt.type != tx_event_t::started) { - this->txMySeq = 0; - if(evt.type == tx_event_t::finished) this->commitMyPosition(); - else { - // The motor only stores the favorite when it gets the whole long press. - ESP_LOGW(TAG, "[Shade %u] Favorite not sent in full, keeping the previous one", this->shadeId); - this->p_myPos(this->txMyOldPos); - this->p_myTiltPos(this->txMyOldTiltPos); - this->emitState(); - } + if(act.cancelMovement) { + // The motor never got the command so it hasn't moved. + ESP_LOGW(TAG, "[Shade %u] Command dropped by the radio, staying at %.2f%%", this->shadeId, this->currentPos); + this->settingPos = this->settingTiltPos = this->settingMyPos = false; + this->p_target(this->currentPos); + this->p_tiltTarget(this->currentTiltPos); + this->emitState(); } - if(this->txStopSeq == evt.seq) { - this->txStopSeq = 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 && this->direction == 0 && this->tiltDirection == 0) { - ESP_LOGW(TAG, "[Shade %u] Stop dropped by the radio, running on to the limit", this->shadeId); - this->runToLimit(this->txStopDir, this->txStopTilt, this->txStopAt); - this->emitState(); - } + if(act.commitMy) this->commitMyPosition(); + if(act.restoreMy) { + ESP_LOGW(TAG, "[Shade %u] Favorite not sent in full, keeping the previous one", this->shadeId); + this->p_myPos(act.myOldPos); + this->p_myTiltPos(act.myOldTiltPos); + this->emitState(); + } + if(act.runToLimit) { + ESP_LOGW(TAG, "[Shade %u] Stop dropped by the radio, running on to the limit", this->shadeId); + this->runToLimit(act.limitDir, act.limitTilt, act.limitSince); + this->emitState(); } } bool SomfyShade::sendStop(int8_t dir, bool tilt) { const uint32_t now = millis(); // Stops jump the radio queue so the motor stops as close to the target as it can. if(SomfyRemote::sendCommandAfter(somfy_commands::My, this->repeats, 0, 0, true)) { - this->txStopSeq = this->lastTxSeq; - this->txStopAt = now; - this->txStopDir = dir; - this->txStopTilt = tilt; + this->tx.stopSent(this->lastTxSeq, now, dir, tilt); return true; } ESP_LOGW(TAG, "[Shade %u] Stop not sent, running on to the limit", this->shadeId); @@ -2397,11 +2365,9 @@ bool SomfyShade::sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos) { // The new favorite is already in myPos/myTiltPos. It is committed once the // whole long press is on air, see processTxEvent. if(SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, delayMs)) { - this->txMySeq = this->lastTxSeq; - this->txMyOldPos = oldPos; - this->txMyOldTiltPos = oldTiltPos; + this->tx.setMySent(this->lastTxSeq, oldPos, oldTiltPos); // Without the radio (GPIO remotes) there is nothing to wait for. - if(this->txMySeq == 0) this->commitMyPosition(); + if(this->lastTxSeq == 0) this->commitMyPosition(); return true; } // The motor never learned it so keep the favorite it has. @@ -2416,13 +2382,13 @@ void SomfyShade::runToLimit(int8_t dir, bool tilt, uint32_t since) { this->startTiltPos = this->currentTiltPos; this->tiltStart = since; this->settingTiltPos = false; - this->p_tiltTarget(dir > 0 ? 100.0f : 0.0f); + this->p_tiltTarget(limitTarget(dir)); } else { this->startPos = this->currentPos; this->moveStart = since; this->settingPos = false; - this->p_target(dir > 0 ? 100.0f : 0.0f); + this->p_target(limitTarget(dir)); } // The favorite can't be set where the shade won't stop. this->settingMyPos = false; @@ -3551,10 +3517,7 @@ bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t s ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d REPEAT:%d", translateSomfyCommand(frame.cmd).c_str(), frame.remoteAddress, frame.rollingCode, repeat); - // A stop jumps the queue unless an earlier frame of this remote is still - // waiting. Overtaking it would put the rolling codes out of order and the - // motor would reject the older one. - const bool front = priority && !somfy.transceiver.isQueued(this->lastBackTxSeq); + const bool front = stopGoesToFront(priority, somfy.transceiver.isQueued(this->lastBackTxSeq)); // Only simulate the command if it is actually going on air. if(!somfy.sendFrame(frame, repeat, delayMs, priority, front, &seq)) return false; if(!front) this->lastBackTxSeq = seq; @@ -3582,15 +3545,11 @@ bool SomfyRemote::repeatFrame(uint8_t repeat) { this->triggerGPIOs(this->lastFrame); return true; } - // The first frame is encoded with the current repeat counter and the radio - // task counts up from there for each repeat frame. somfy_tx_job_t job; - encodeTxJob(job, this->lastFrame, repeat); - job.firstRepeat = this->lastFrame.repeats + 2; - // Only advance the counter for frames that will go on air. + encodeRepeatJob(job, this->lastFrame, repeat); if(!somfy.transceiver.queueTx(job)) return false; this->lastBackTxSeq = job.seq; - this->lastFrame.repeats += job.repeats + 1; + noteRepeatQueued(this->lastFrame, job); //somfy.processFrame(this->lastFrame, true); return true; } @@ -3669,17 +3628,17 @@ bool SomfyShadeController::deleteGroup(uint8_t groupId) { } bool SomfyShadeController::loadShadesFile(const char *filename) { return ShadeConfigFile::load(this, filename); } -// The next rolling code is only read here. It is consumed by commitRollingCode -// once the frame using it has been queued. NVS is only read until a code for -// this address has been written, after that lastRollingCode holds the same. +// The next rolling code is consumed by commitRollingCode once the frame using +// it has been queued. After that lastRollingCode holds what NVS does. uint16_t SomfyRemote::peekNextRollingCode() { - if(this->m_rollingCodeSynced) return (uint16_t)(this->lastRollingCode + 1); - if(!pref.begin("ShadeCodes")) { - ESP_LOGE(TAG, "peekNextRollingCode() pref.begin(ShadeCodes) FAILED"); - } - uint16_t code = pref.getUShort(this->m_remotePrefId, 0); - pref.end(); - return (uint16_t)(code + 1); + return nextRollingCode(this->m_rollingCodeSynced, this->lastRollingCode, [this]() { + if(!pref.begin("ShadeCodes")) { + ESP_LOGE(TAG, "peekNextRollingCode() pref.begin(ShadeCodes) FAILED"); + } + uint16_t code = pref.getUShort(this->m_remotePrefId, 0); + pref.end(); + return code; + }); } void SomfyRemote::commitRollingCode(uint16_t code) { if(!pref.begin("ShadeCodes")) { @@ -4717,13 +4676,12 @@ bool Transceiver::queueTx(somfy_tx_job_t &job) { ESP_LOGW(TAG, "Radio TX suspended, dropping transmission"); return false; } - // Normal jobs leave the reserved slots free for stops. - if(uxQueueSpacesAvailable(txJobQueue) <= (job.priority ? 0 : TX_RESERVED_SLOTS)) { + if(!txAdmits(uxQueueSpacesAvailable(txJobQueue), job.priority)) { ESP_LOGE(TAG, "Radio TX queue full, dropping transmission"); return false; } job.gen = txGen; - if(++txSeq == 0) ++txSeq; // 0 means no job. + txSeq = nextTxSeq(txSeq); job.seq = txSeq; // We are the only producer while holding the lock so the space check holds. if(job.front) { @@ -4736,13 +4694,13 @@ bool Transceiver::queueTx(somfy_tx_job_t &job) { } bool Transceiver::isQueued(uint32_t seq) { // Only meaningful for jobs queued at the back. - return seq != 0 && (int32_t)(seq - txBackTaken) > 0; + return txSeqQueued(seq, txBackTaken); } bool Transceiver::txSuspended() { return txSuspendMask != 0; } bool Transceiver::txHasRoom() { // Without a radio task queueTx() fails straight away so there is no point waiting. if(!txJobQueue || !txTaskHandle) return true; - return uxQueueSpacesAvailable(txJobQueue) > TX_RESERVED_SLOTS; + return txAdmits(uxQueueSpacesAvailable(txJobQueue), false); } void Transceiver::flushTx() { TxQueueLock lock; @@ -4810,7 +4768,7 @@ void Transceiver::runJob(somfy_tx_job_t &job) { void Transceiver::waitUntilDue(const somfy_tx_job_t &job) { for(;;) { if(txSuspendMask || job.gen != txGen) return; - const int32_t wait = (int32_t)(job.notBefore - millis()); + const int32_t wait = txDueInMs(job.notBefore, millis()); if(wait <= 0) return; // A stop only goes to the front when its remote has nothing waiting, and // this job still counts as waiting, so any front job is free to go first. diff --git a/src/Somfy.h b/src/Somfy.h index e67d215..d7ead1a 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -115,41 +115,9 @@ struct somfy_relay_t { uint8_t sync = 0; byte frame[10] = {0}; }; -#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 // Reasons the radio must not transmit. TX resumes once every reason is cleared. #define TX_SUSPEND_SCAN 0x01 #define TX_SUSPEND_OTA 0x02 -// 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. - bool priority = false; // May use the reserved slots. - bool front = false; // Goes to the front of the queue. Only for priority jobs. - uint32_t gen = 0; // Flush generation when queued. Stale jobs are dropped. - uint32_t seq = 0; // Assigned when queued so the job's events can be matched up. - 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. -}; class SomfyRoom { public: @@ -240,20 +208,8 @@ class SomfyShade : public SomfyRemote { bool settingPos = false; bool settingTiltPos = false; uint32_t awaitMy = 0; - // Radio job that set the shade moving. The simulated position is held - // until it goes on air. - TxHold txHold; - // Stop sent when the simulated target was reached. If it never goes on - // air the motor runs on to its end limit. 0 once it is on air. - uint32_t txStopSeq = 0; - uint32_t txStopAt = 0; - int8_t txStopDir = 0; - bool txStopTilt = 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 txMySeq = 0; - float txMyOldPos = -1.0f; - float txMyOldTiltPos = -1.0f; + // 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); diff --git a/src/TxBuffer.cpp b/src/TxBuffer.cpp index 8d19362..46784e1 100644 --- a/src/TxBuffer.cpp +++ b/src/TxBuffer.cpp @@ -1,4 +1,8 @@ #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. @@ -53,3 +57,22 @@ void somfy_tx_queue_t::push(uint8_t hwsync, uint8_t *payload, uint8_t bit_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; +} diff --git a/src/TxBuffer.h b/src/TxBuffer.h index 940f995..9289d95 100644 --- a/src/TxBuffer.h +++ b/src/TxBuffer.h @@ -1,11 +1,20 @@ #ifndef TX_BUFFER_H #define TX_BUFFER_H #include +#include "SomfyFrame.h" -// Repeater frames waiting to be handed to the radio task. Kept free of the -// radio and timers so it can be unit tested on the PC (pio test -e native). +// 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() { @@ -40,4 +49,43 @@ struct somfy_tx_queue_t { // 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. + bool priority = false; // May use the reserved slots. + bool front = false; // Goes to the front of the queue. Only for priority jobs. + uint32_t gen = 0; // Flush generation when queued. Stale jobs are dropped. + uint32_t seq = 0; // Assigned when queued so the job's events can be matched up. + 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 diff --git a/src/TxQueuePolicy.h b/src/TxQueuePolicy.h new file mode 100644 index 0000000..2f31963 --- /dev/null +++ b/src/TxQueuePolicy.h @@ -0,0 +1,31 @@ +#ifndef TX_QUEUE_POLICY_H +#define TX_QUEUE_POLICY_H +#include +#include "TxBuffer.h" + +// The rules the radio TX queue follows (src/Somfy.cpp). Kept free of FreeRTOS +// so they can be unit tested on the PC (pio test -e native). + +// Whether a job fits with spaces slots free. Normal jobs leave the reserved +// slots free for stops. +inline bool txAdmits(uint32_t spaces, bool priority) { + return spaces > (priority ? 0u : (uint32_t)TX_RESERVED_SLOTS); +} +// The sequence number after seq. 0 means no job so it is skipped on wrap. +inline uint32_t nextTxSeq(uint32_t seq) { + if(++seq == 0) ++seq; + return seq; +} +// Whether job seq was queued after job cur, allowing for wrap. +inline bool txSeqAfter(uint32_t seq, uint32_t cur) { return (int32_t)(seq - cur) > 0; } +// Whether a job queued at the back is still waiting, given taken is the last +// back job the radio task took or a flush dropped. Those go out in order. +inline bool txSeqQueued(uint32_t seq, uint32_t taken) { return seq != 0 && txSeqAfter(seq, taken); } +// A stop jumps the queue unless an earlier frame of its remote is still +// waiting. Overtaking it would put the rolling codes out of order and the +// motor would reject the older one. +inline bool stopGoesToFront(bool priority, bool lastBackQueued) { return priority && !lastBackQueued; } +// Milliseconds until a delayed job is due, 0 or less once it is. Works +// across a millis() wrap. +inline int32_t txDueInMs(uint32_t notBefore, uint32_t now) { return (int32_t)(notBefore - now); } +#endif diff --git a/src/Web.cpp b/src/Web.cpp index faff038..5e23e76 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -134,17 +134,12 @@ bool Web::queueCommand(const web_command_t &cmd) { } return true; } -// Only setting positions never goes on air. -static bool usesRadio(web_cmd_t type) { return type != web_cmd_t::set_positions; } void Web::processQueue() { if(!webCmdQueue) return; web_command_t cmd; - // The client was already told the command was accepted so leave it queued - // until the radio has room rather than have the radio drop it. While TX is - // suspended nothing gets through anyway so don't hold anything back then. // Commands run in order, so only the one at the head can be held back. while(xQueuePeek(webCmdQueue, &cmd, 0) == pdTRUE) { - if(usesRadio(cmd.type) && !somfy.transceiver.txSuspended() && !somfy.transceiver.txHasRoom()) break; + 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; diff --git a/src/Web.h b/src/Web.h index cba5111..473c471 100644 --- a/src/Web.h +++ b/src/Web.h @@ -3,41 +3,10 @@ #include #include #include "Somfy.h" +#include "WebCommand.h" #ifndef webserver_h #define webserver_h -// 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 -}; - class Web { public: bool uploadSuccess = false; diff --git a/src/WebCommand.h b/src/WebCommand.h new file mode 100644 index 0000000..0928791 --- /dev/null +++ b/src/WebCommand.h @@ -0,0 +1,51 @@ +#ifndef WEB_COMMAND_H +#define WEB_COMMAND_H +#include +#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 diff --git a/test/test_rollingcode/test_rollingcode.cpp b/test/test_rollingcode/test_rollingcode.cpp new file mode 100644 index 0000000..8de99aa --- /dev/null +++ b/test/test_rollingcode/test_rollingcode.cpp @@ -0,0 +1,59 @@ +#include +#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(); +} diff --git a/test/test_shadetx/test_shadetx.cpp b/test/test_shadetx/test_shadetx.cpp new file mode 100644 index 0000000..f227457 --- /dev/null +++ b/test/test_shadetx/test_shadetx.cpp @@ -0,0 +1,245 @@ +#include +#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(); +} diff --git a/test/test_txqueue/test_txqueue.cpp b/test/test_txqueue/test_txqueue.cpp new file mode 100644 index 0000000..667bf04 --- /dev/null +++ b/test/test_txqueue/test_txqueue.cpp @@ -0,0 +1,216 @@ +#include +#include "TxQueuePolicy.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() {} + +/********************************************************************* + * txAdmits + ********************************************************************/ +static void test_normal_job_leaves_the_reserved_slots() { + TEST_ASSERT_FALSE(txAdmits(0, false)); + TEST_ASSERT_FALSE(txAdmits(TX_RESERVED_SLOTS, false)); + TEST_ASSERT_TRUE(txAdmits(TX_RESERVED_SLOTS + 1, false)); + TEST_ASSERT_TRUE(txAdmits(TX_JOB_QUEUE_SIZE, false)); +} +static void test_priority_job_uses_the_reserved_slots() { + TEST_ASSERT_FALSE(txAdmits(0, true)); + TEST_ASSERT_TRUE(txAdmits(1, true)); + TEST_ASSERT_TRUE(txAdmits(TX_RESERVED_SLOTS, true)); +} + +/********************************************************************* + * Sequence numbers + ********************************************************************/ +static void test_nextTxSeq_counts_up() { + TEST_ASSERT_EQUAL_UINT32(1, nextTxSeq(0)); + TEST_ASSERT_EQUAL_UINT32(2, nextTxSeq(1)); +} +static void test_nextTxSeq_skips_zero_on_wrap() { + TEST_ASSERT_EQUAL_UINT32(1, nextTxSeq(0xFFFFFFFFu)); +} +static void test_txSeqQueued_zero_is_never_queued() { + TEST_ASSERT_FALSE(txSeqQueued(0, 0)); + TEST_ASSERT_FALSE(txSeqQueued(0, 10)); + TEST_ASSERT_FALSE(txSeqQueued(0, 0xFFFFFFF0u)); +} +static void test_txSeqQueued_after_taken() { + TEST_ASSERT_FALSE(txSeqQueued(9, 10)); + TEST_ASSERT_FALSE(txSeqQueued(10, 10)); + TEST_ASSERT_TRUE(txSeqQueued(11, 10)); +} +static void test_txSeqQueued_across_wrap() { + // Job 5 was numbered after the counter wrapped, so it is still waiting. + TEST_ASSERT_TRUE(txSeqQueued(5, 0xFFFFFFF0u)); + TEST_ASSERT_TRUE(txSeqQueued(0xFFFFFFFFu, 0xFFFFFFF0u)); + TEST_ASSERT_FALSE(txSeqQueued(0xFFFFFFF0u, 5)); +} +static void test_txSeqAfter_never_moves_back() { + // A flush marks every job up to 20 as taken. The radio task then reports + // job 15, which it took off the queue before the flush. That must not move + // the mark back or jobs 16-20 would look queued again. + uint32_t taken = 20; + if(txSeqAfter(15, taken)) taken = 15; + TEST_ASSERT_EQUAL_UINT32(20, taken); + if(txSeqAfter(21, taken)) taken = 21; + TEST_ASSERT_EQUAL_UINT32(21, taken); + TEST_ASSERT_FALSE(txSeqAfter(21, 21)); +} + +/********************************************************************* + * stopGoesToFront + ********************************************************************/ +static void test_stop_goes_to_front_when_nothing_waits() { + TEST_ASSERT_TRUE(stopGoesToFront(true, false)); +} +static void test_stop_queues_behind_its_remote() { + // Overtaking the remote's waiting frame would send the rolling codes out of + // order. + TEST_ASSERT_FALSE(stopGoesToFront(true, true)); +} +static void test_normal_job_never_goes_to_front() { + TEST_ASSERT_FALSE(stopGoesToFront(false, false)); + TEST_ASSERT_FALSE(stopGoesToFront(false, true)); +} + +/********************************************************************* + * txDueInMs + ********************************************************************/ +static void test_txDueInMs() { + TEST_ASSERT_EQUAL_INT32(500, txDueInMs(1500, 1000)); + TEST_ASSERT_EQUAL_INT32(0, txDueInMs(1000, 1000)); + TEST_ASSERT_EQUAL_INT32(-200, txDueInMs(1000, 1200)); +} +static void test_txDueInMs_across_millis_wrap() { + // Queued 0x100ms before the wrap with a 0x200ms delay. + const uint32_t notBefore = 0xFFFFFF00u + 0x200u; + TEST_ASSERT_EQUAL_INT32(0x200, txDueInMs(notBefore, 0xFFFFFF00u)); + TEST_ASSERT_EQUAL_INT32(0x100, txDueInMs(notBefore, 0)); + TEST_ASSERT_EQUAL_INT32(-0x10, txDueInMs(notBefore, 0x110u)); +} + +/********************************************************************* + * 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_normal_job_leaves_the_reserved_slots); + RUN_TEST(test_priority_job_uses_the_reserved_slots); + RUN_TEST(test_nextTxSeq_counts_up); + RUN_TEST(test_nextTxSeq_skips_zero_on_wrap); + RUN_TEST(test_txSeqQueued_zero_is_never_queued); + RUN_TEST(test_txSeqQueued_after_taken); + RUN_TEST(test_txSeqQueued_across_wrap); + RUN_TEST(test_txSeqAfter_never_moves_back); + RUN_TEST(test_stop_goes_to_front_when_nothing_waits); + RUN_TEST(test_stop_queues_behind_its_remote); + RUN_TEST(test_normal_job_never_goes_to_front); + RUN_TEST(test_txDueInMs); + RUN_TEST(test_txDueInMs_across_millis_wrap); + 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(); +} diff --git a/test/test_webcmd/test_webcmd.cpp b/test/test_webcmd/test_webcmd.cpp new file mode 100644 index 0000000..c6a7514 --- /dev/null +++ b/test/test_webcmd/test_webcmd.cpp @@ -0,0 +1,48 @@ +#include +#include "WebCommand.h" + +static const web_cmd_t RADIO_COMMANDS[] = { + web_cmd_t::shade_command, web_cmd_t::group_command, web_cmd_t::tilt_command, + web_cmd_t::shade_repeat, web_cmd_t::group_repeat, web_cmd_t::shade_sensor, + web_cmd_t::group_sensor, web_cmd_t::set_my_position, web_cmd_t::unpair_shade +}; + +void setUp() {} +void tearDown() {} + +static void test_only_set_positions_stays_off_the_radio() { + int offRadio = 0; + for(uint8_t t = 0; t <= static_cast(web_cmd_t::unpair_shade); t++) { + if(!webCommandUsesRadio(static_cast(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(); +}