refactor: move the RX pulse decoder into PulseDecoder

The decoding of the time between received edges into frame bits moves out
of Transceiver::handleReceive unchanged into decodePulse(), with the RTS
timings and the receive buffer type. It stays in IRAM on the ESP32 and is
now covered by native tests that feed it the edges sendFrame() produces
for 56 and 80-bit first frames and repeats, a glitch and a broken pulse.

handleReceive keeps the noise guard, the edge timing and the RX queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-13 21:21:05 +02:00
parent c75b715b3b
commit a176017960
6 changed files with 306 additions and 138 deletions

View file

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

119
src/PulseDecoder.cpp Normal file
View file

@ -0,0 +1,119 @@
#include "PulseDecoder.h"
#if defined(ESP32)
#include <esp_attr.h>
#define PULSE_DECODER_ATTR IRAM_ATTR
#else
#define PULSE_DECODER_ATTR
#endif
// Each timing is accepted within 30%.
#define TOLERANCE_MIN 0.7
#define TOLERANCE_MAX 1.3
static const uint32_t tempo_wakeup_pulse = 9415;
static const uint32_t tempo_wakeup_min = 9415 * TOLERANCE_MIN;
static const uint32_t tempo_wakeup_max = 9415 * TOLERANCE_MAX;
static const uint32_t tempo_wakeup_silence = 89565;
static const uint32_t tempo_wakeup_silence_min = 89565 * TOLERANCE_MIN;
static const uint32_t tempo_wakeup_silence_max = 89565 * TOLERANCE_MAX;
static const uint32_t tempo_synchro_hw_min = SYMBOL * 4 * TOLERANCE_MIN;
static const uint32_t tempo_synchro_hw_max = SYMBOL * 4 * TOLERANCE_MAX;
static const uint32_t tempo_synchro_sw_min = 4850 * TOLERANCE_MIN;
static const uint32_t tempo_synchro_sw_max = 4850 * TOLERANCE_MAX;
static const uint32_t tempo_half_symbol_min = SYMBOL * TOLERANCE_MIN;
static const uint32_t tempo_half_symbol_max = SYMBOL * TOLERANCE_MAX;
static const uint32_t tempo_symbol_min = SYMBOL * 2 * TOLERANCE_MIN;
static const uint32_t tempo_symbol_max = SYMBOL * 2 * TOLERANCE_MAX;
static const uint32_t tempo_if_gap = 30415; // Gap between frames
static int16_t bitMin = SYMBOL * TOLERANCE_MIN;
PULSE_DECODER_ATTR PulseResult decodePulse(somfy_rx_t &rx, uint32_t durationUs) {
const unsigned int duration = durationUs;
if (duration < bitMin) {
// The incoming bit is < 448us so it is probably a glitch so blow it off.
// We need to ignore this bit.
// REMOVE THIS AFTER WE DETERMINE THAT THE out-of-bounds stuff isn't a problem. If there are bits
// from the previous frame then we will capture this data here.
if(rx.pulseCount < MAX_TIMINGS && rx.cpt_synchro_hw > 0) rx.pulses[rx.pulseCount++] = duration;
return PulseResult::glitch;
}
switch (rx.status) {
case waiting_synchro:
if(rx.pulseCount < MAX_TIMINGS) rx.pulses[rx.pulseCount++] = duration;
if (duration > tempo_synchro_hw_min && duration < tempo_synchro_hw_max) {
// We have found a hardware sync bit. There should be at least 4 of these.
++rx.cpt_synchro_hw;
}
else if (duration > tempo_synchro_sw_min && duration < tempo_synchro_sw_max && rx.cpt_synchro_hw >= 4) {
// If we have a full hardware sync then we should look for the software sync. If we have a software sync
// bit and enough hardware sync bits then we should start receiving data. It turns out that a 56 bit packet
// with give 4 or 14 bits of hardware sync. An 80 bit packet gives 12, 13 or 24 bits of hw sync. Early on
// I had some shorter and longer hw syncs but I can no longer repeat this.
memset(rx.payload, 0x00, sizeof(rx.payload));
rx.previous_bit = 0x00;
rx.waiting_half_symbol = false;
rx.cpt_bits = 0;
// Keep an eye on this as it is possible that we might get fewer or more synchro bits.
if (rx.cpt_synchro_hw <= 7) rx.bit_length = 56;
else if (rx.cpt_synchro_hw == 14) rx.bit_length = 56;
else if (rx.cpt_synchro_hw == 13) rx.bit_length = 80; // The RS485 device sends this sync.
else if (rx.cpt_synchro_hw == 12) rx.bit_length = 80;
else if (rx.cpt_synchro_hw > 17) rx.bit_length = 80;
else rx.bit_length = 56;
//rx.bit_length = 80;
rx.status = receiving_data;
}
else {
// Reset and start looking for hardware sync again.
rx.cpt_synchro_hw = 0;
// Try to capture the wakeup pulse.
if(duration > tempo_wakeup_min && duration < tempo_wakeup_max)
{
memset(&rx.payload, 0x00, sizeof(rx.payload));
rx.previous_bit = 0x00;
rx.waiting_half_symbol = false;
rx.cpt_bits = 0;
rx.bit_length = 56;
}
else if((rx.pulseCount > 20 && rx.cpt_synchro_hw == 0) || duration > 250000) {
rx.pulseCount = 0;
}
}
break;
case receiving_data:
if(rx.pulseCount < MAX_TIMINGS) rx.pulses[rx.pulseCount++] = duration;
// We should be receiving data at this point.
if (duration > tempo_symbol_min && duration < tempo_symbol_max && !rx.waiting_half_symbol) {
rx.previous_bit = 1 - rx.previous_bit;
// Bits come in high order bit first.
rx.payload[rx.cpt_bits / 8] += rx.previous_bit << (7 - rx.cpt_bits % 8);
++rx.cpt_bits;
}
else if (duration > tempo_half_symbol_min && duration < tempo_half_symbol_max) {
if (rx.waiting_half_symbol) {
rx.waiting_half_symbol = false;
rx.payload[rx.cpt_bits / 8] += rx.previous_bit << (7 - rx.cpt_bits % 8);
++rx.cpt_bits;
}
else {
rx.waiting_half_symbol = true;
}
}
else {
//++rx.cpt_bits;
// Start over we are not within our parameters for bit timing.
memset(&rx.payload, 0x00, sizeof(rx.payload));
rx.pulseCount = 1;
rx.cpt_synchro_hw = 0;
rx.previous_bit = 0x00;
rx.waiting_half_symbol = false;
rx.cpt_bits = 0;
rx.bit_length = 56;
rx.status = waiting_synchro;
rx.pulses[0] = duration;
}
break;
default:
break;
}
return rx.status == receiving_data && rx.cpt_bits >= rx.bit_length ? PulseResult::frame : PulseResult::more;
}

53
src/PulseDecoder.h Normal file
View file

@ -0,0 +1,53 @@
#ifndef PULSE_DECODER_H
#define PULSE_DECODER_H
#include <stdint.h>
#include <string.h>
// Turns the time between edges of a received RTS transmission into frame
// bits. Runs in the receive interrupt, so it stays in IRAM on the ESP32, and
// is kept free of the radio so it can be unit tested on the PC
// (pio test -e native).
// Half an RTS bit in us. A bit is two symbols with an edge in the middle.
#define SYMBOL 640
#define MAX_TIMINGS 300
typedef enum {
waiting_synchro = 0,
receiving_data = 1,
complete = 2
} t_status;
struct somfy_rx_t {
void clear() {
this->status = t_status::waiting_synchro;
this->bit_length = 56;
this->cpt_synchro_hw = 0;
this->cpt_bits = 0;
this->previous_bit = 0;
this->waiting_half_symbol = false;
memset(this->payload, 0, sizeof(this->payload));
memset(this->pulses, 0, sizeof(this->pulses));
this->pulseCount = 0;
}
t_status status;
uint8_t bit_length = 56;
uint8_t cpt_synchro_hw = 0;
uint8_t cpt_bits = 0;
uint8_t previous_bit = 0;
bool waiting_half_symbol;
uint8_t payload[10];
unsigned int pulses[MAX_TIMINGS];
uint16_t pulseCount = 0;
};
enum class PulseResult : uint8_t {
glitch, // Too short to be a symbol. The caller times the next edge from the last one.
more, // Taken, the frame isn't complete yet.
frame // rx holds a complete frame.
};
// Feeds the time since the previous edge to rx.
PulseResult decodePulse(somfy_rx_t &rx, uint32_t durationUs);
#endif

View file

@ -19,7 +19,6 @@ extern SocketEmitter sockEmit;
extern ConfigSettings settings; extern ConfigSettings settings;
uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to ensure there isn't more than one interrupt hooked. uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to ensure there isn't more than one interrupt hooked.
#define SYMBOL 640
#if defined(ESP8266) #if defined(ESP8266)
#define RECEIVE_ATTR ICACHE_RAM_ATTR #define RECEIVE_ATTR ICACHE_RAM_ATTR
#elif defined(ESP32) #elif defined(ESP32)
@ -75,24 +74,6 @@ void somfy_frame_t::decodeFrame(somfy_rx_t *rx) {
this->decodeFrame(rx->payload); this->decodeFrame(rx->payload);
} }
// Transceiver Implementation // Transceiver Implementation
#define TOLERANCE_MIN 0.7
#define TOLERANCE_MAX 1.3
static const uint32_t tempo_wakeup_pulse = 9415;
static const uint32_t tempo_wakeup_min = 9415 * TOLERANCE_MIN;
static const uint32_t tempo_wakeup_max = 9415 * TOLERANCE_MAX;
static const uint32_t tempo_wakeup_silence = 89565;
static const uint32_t tempo_wakeup_silence_min = 89565 * TOLERANCE_MIN;
static const uint32_t tempo_wakeup_silence_max = 89565 * TOLERANCE_MAX;
static const uint32_t tempo_synchro_hw_min = SYMBOL * 4 * TOLERANCE_MIN;
static const uint32_t tempo_synchro_hw_max = SYMBOL * 4 * TOLERANCE_MAX;
static const uint32_t tempo_synchro_sw_min = 4850 * TOLERANCE_MIN;
static const uint32_t tempo_synchro_sw_max = 4850 * TOLERANCE_MAX;
static const uint32_t tempo_half_symbol_min = SYMBOL * TOLERANCE_MIN;
static const uint32_t tempo_half_symbol_max = SYMBOL * TOLERANCE_MAX;
static const uint32_t tempo_symbol_min = SYMBOL * 2 * TOLERANCE_MIN;
static const uint32_t tempo_symbol_max = SYMBOL * 2 * TOLERANCE_MAX;
static const uint32_t tempo_if_gap = 30415; // Gap between frames
static int16_t bitMin = SYMBOL * TOLERANCE_MIN;
static somfy_rx_t somfy_rx; static somfy_rx_t somfy_rx;
static somfy_rx_queue_t rx_queue; static somfy_rx_queue_t rx_queue;
static somfy_tx_queue_t tx_queue; static somfy_tx_queue_t tx_queue;
@ -210,95 +191,10 @@ void RECEIVE_ATTR Transceiver::handleReceive() {
static unsigned long last_time = 0; static unsigned long last_time = 0;
const long time = micros(); const long time = micros();
const unsigned int duration = time - last_time; const unsigned int duration = time - last_time;
if (duration < bitMin) { const PulseResult result = decodePulse(somfy_rx, duration);
// The incoming bit is < 448us so it is probably a glitch so blow it off. if (result == PulseResult::glitch) return;
// We need to ignore this bit.
// REMOVE THIS AFTER WE DETERMINE THAT THE out-of-bounds stuff isn't a problem. If there are bits
// from the previous frame then we will capture this data here.
if(somfy_rx.pulseCount < MAX_TIMINGS && somfy_rx.cpt_synchro_hw > 0) somfy_rx.pulses[somfy_rx.pulseCount++] = duration;
return;
}
last_time = time; last_time = time;
switch (somfy_rx.status) { if (result == PulseResult::frame) {
case waiting_synchro:
if(somfy_rx.pulseCount < MAX_TIMINGS) somfy_rx.pulses[somfy_rx.pulseCount++] = duration;
if (duration > tempo_synchro_hw_min && duration < tempo_synchro_hw_max) {
// We have found a hardware sync bit. There should be at least 4 of these.
++somfy_rx.cpt_synchro_hw;
}
else if (duration > tempo_synchro_sw_min && duration < tempo_synchro_sw_max && somfy_rx.cpt_synchro_hw >= 4) {
// If we have a full hardware sync then we should look for the software sync. If we have a software sync
// bit and enough hardware sync bits then we should start receiving data. It turns out that a 56 bit packet
// with give 4 or 14 bits of hardware sync. An 80 bit packet gives 12, 13 or 24 bits of hw sync. Early on
// I had some shorter and longer hw syncs but I can no longer repeat this.
memset(somfy_rx.payload, 0x00, sizeof(somfy_rx.payload));
somfy_rx.previous_bit = 0x00;
somfy_rx.waiting_half_symbol = false;
somfy_rx.cpt_bits = 0;
// Keep an eye on this as it is possible that we might get fewer or more synchro bits.
if (somfy_rx.cpt_synchro_hw <= 7) somfy_rx.bit_length = 56;
else if (somfy_rx.cpt_synchro_hw == 14) somfy_rx.bit_length = 56;
else if (somfy_rx.cpt_synchro_hw == 13) somfy_rx.bit_length = 80; // The RS485 device sends this sync.
else if (somfy_rx.cpt_synchro_hw == 12) somfy_rx.bit_length = 80;
else if (somfy_rx.cpt_synchro_hw > 17) somfy_rx.bit_length = 80;
else somfy_rx.bit_length = 56;
//somfy_rx.bit_length = 80;
somfy_rx.status = receiving_data;
}
else {
// Reset and start looking for hardware sync again.
somfy_rx.cpt_synchro_hw = 0;
// Try to capture the wakeup pulse.
if(duration > tempo_wakeup_min && duration < tempo_wakeup_max)
{
memset(&somfy_rx.payload, 0x00, sizeof(somfy_rx.payload));
somfy_rx.previous_bit = 0x00;
somfy_rx.waiting_half_symbol = false;
somfy_rx.cpt_bits = 0;
somfy_rx.bit_length = 56;
}
else if((somfy_rx.pulseCount > 20 && somfy_rx.cpt_synchro_hw == 0) || duration > 250000) {
somfy_rx.pulseCount = 0;
}
}
break;
case receiving_data:
if(somfy_rx.pulseCount < MAX_TIMINGS) somfy_rx.pulses[somfy_rx.pulseCount++] = duration;
// We should be receiving data at this point.
if (duration > tempo_symbol_min && duration < tempo_symbol_max && !somfy_rx.waiting_half_symbol) {
somfy_rx.previous_bit = 1 - somfy_rx.previous_bit;
// Bits come in high order bit first.
somfy_rx.payload[somfy_rx.cpt_bits / 8] += somfy_rx.previous_bit << (7 - somfy_rx.cpt_bits % 8);
++somfy_rx.cpt_bits;
}
else if (duration > tempo_half_symbol_min && duration < tempo_half_symbol_max) {
if (somfy_rx.waiting_half_symbol) {
somfy_rx.waiting_half_symbol = false;
somfy_rx.payload[somfy_rx.cpt_bits / 8] += somfy_rx.previous_bit << (7 - somfy_rx.cpt_bits % 8);
++somfy_rx.cpt_bits;
}
else {
somfy_rx.waiting_half_symbol = true;
}
}
else {
//++somfy_rx.cpt_bits;
// Start over we are not within our parameters for bit timing.
memset(&somfy_rx.payload, 0x00, sizeof(somfy_rx.payload));
somfy_rx.pulseCount = 1;
somfy_rx.cpt_synchro_hw = 0;
somfy_rx.previous_bit = 0x00;
somfy_rx.waiting_half_symbol = false;
somfy_rx.cpt_bits = 0;
somfy_rx.bit_length = 56;
somfy_rx.status = waiting_synchro;
somfy_rx.pulses[0] = duration;
}
break;
default:
break;
}
if (somfy_rx.status == receiving_data && somfy_rx.cpt_bits >= somfy_rx.bit_length) {
// Since we are operating within the interrupt all data really needs to be static // Since we are operating within the interrupt all data really needs to be static
// for the handoff to the frame decoder. For this reason we are buffering up to // for the handoff to the frame decoder. For this reason we are buffering up to
// 3 total frames. Althought it may not matter considering the length of a packet // 3 total frames. Althought it may not matter considering the length of a packet

View file

@ -6,41 +6,12 @@
#include "SomfyFrame.h" #include "SomfyFrame.h"
#include "TxBuffer.h" #include "TxBuffer.h"
#include "RadioScheduler.h" #include "RadioScheduler.h"
#include "PulseDecoder.h"
// The CC1101 radio: receive decoding, the radio task that transmits radio // The CC1101 radio: receive decoding, the radio task that transmits radio
// jobs, the frequency scan and the radio config. // jobs, the frequency scan and the radio config.
#define MAX_TIMINGS 300
#define MAX_RX_BUFFER 3 #define MAX_RX_BUFFER 3
typedef enum {
waiting_synchro = 0,
receiving_data = 1,
complete = 2
} t_status;
struct somfy_rx_t {
void clear() {
this->status = t_status::waiting_synchro;
this->bit_length = 56;
this->cpt_synchro_hw = 0;
this->cpt_bits = 0;
this->previous_bit = 0;
this->waiting_half_symbol = false;
memset(this->payload, 0, sizeof(this->payload));
memset(this->pulses, 0, sizeof(this->pulses));
this->pulseCount = 0;
}
t_status status;
uint8_t bit_length = 56;
uint8_t cpt_synchro_hw = 0;
uint8_t cpt_bits = 0;
uint8_t previous_bit = 0;
bool waiting_half_symbol;
uint8_t payload[10];
unsigned int pulses[MAX_TIMINGS];
uint16_t pulseCount = 0;
};
// A simple FIFO queue to hold rx buffers. We are using // A simple FIFO queue to hold rx buffers. We are using
// a byte index to make it so we don't have to reorganize // a byte index to make it so we don't have to reorganize
// the storage each time we push or pop. // the storage each time we push or pop.

View file

@ -0,0 +1,129 @@
#include <unity.h>
#include <vector>
#include "PulseDecoder.h"
void setUp() {}
void tearDown() {}
static const uint8_t FRAME[10] = {0xA7, 0x5C, 0x13, 0xF0, 0x0F, 0x81, 0x42, 0x99, 0x3C, 0xE1};
// The times between edges a receiver sees for a transmitted waveform. Levels
// are added the way Transceiver::sendFrame() drives the TX pin; a run of the
// same level is one pulse.
struct Wave {
std::vector<uint32_t> durations;
int level = 0;
uint32_t run = 100000; // Quiet before the transmission.
void add(int lvl, uint32_t us) {
if(lvl == this->level) { this->run += us; return; }
this->durations.push_back(this->run);
this->level = lvl;
this->run = us;
}
};
// Mirrors Transceiver::sendFrame(): a wakeup pulse on the first frame (sync 2
// or 12), sync hardware sync pulses, the software sync and the Manchester bits.
static Wave transmit(const uint8_t *frame, uint8_t sync, uint8_t bitLength) {
Wave w;
if(sync == 2 || sync == 12) {
w.add(1, 10920);
w.add(0, 7357);
}
for(uint8_t i = 0; i < sync; i++) {
w.add(1, 4 * SYMBOL);
w.add(0, 4 * SYMBOL);
}
w.add(1, 4850);
w.add(0, SYMBOL);
for(uint8_t i = 0; i < bitLength; i++) {
const bool one = (frame[i / 8] >> (7 - (i % 8))) & 1;
w.add(one ? 0 : 1, SYMBOL);
w.add(one ? 1 : 0, SYMBOL);
}
w.add(0, 27434); // Inter-frame silence.
w.add(1, 0);
return w;
}
// Feeds durations to a fresh decoder until it reports a frame.
static bool receive(const std::vector<uint32_t> &durations, somfy_rx_t &rx) {
rx.clear();
for(uint32_t d : durations)
if(decodePulse(rx, d) == PulseResult::frame) return true;
return false;
}
static void assertFrame(uint8_t sync, uint8_t bitLength) {
somfy_rx_t rx;
TEST_ASSERT_TRUE(receive(transmit(FRAME, sync, bitLength).durations, rx));
TEST_ASSERT_EQUAL_UINT8(bitLength, rx.bit_length);
TEST_ASSERT_EQUAL_HEX8_ARRAY(FRAME, rx.payload, bitLength / 8);
TEST_ASSERT_EQUAL_UINT8(sync * 2, rx.cpt_synchro_hw);
}
/*********************************************************************
* whole frames
********************************************************************/
static void test_56_bit_first_frame() { assertFrame(2, 56); }
static void test_56_bit_repeat() { assertFrame(7, 56); }
static void test_80_bit_first_frame() { assertFrame(12, 80); }
static void test_80_bit_repeat() { assertFrame(6, 80); }
static void test_records_the_pulses() {
somfy_rx_t rx;
const Wave w = transmit(FRAME, 7, 56);
TEST_ASSERT_TRUE(receive(w.durations, rx));
// Every pulse from the first hardware sync on is kept for the frame log.
TEST_ASSERT_TRUE(rx.pulseCount > 14 + 56);
TEST_ASSERT_EQUAL_UINT32(4 * SYMBOL, rx.pulses[1]);
}
/*********************************************************************
* noise
********************************************************************/
static void test_glitch_is_reported_and_skipped() {
somfy_rx_t rx;
rx.clear();
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(PulseResult::glitch), static_cast<uint8_t>(decodePulse(rx, 100)));
// The caller times the next edge from the edge before the glitch, so the
// frame still decodes.
const Wave w = transmit(FRAME, 7, 56);
for(uint32_t d : w.durations) {
if(decodePulse(rx, d) == PulseResult::frame) {
TEST_ASSERT_EQUAL_HEX8_ARRAY(FRAME, rx.payload, 7);
return;
}
}
TEST_FAIL_MESSAGE("no frame");
}
static void test_bad_timing_mid_frame_starts_over() {
somfy_rx_t rx;
std::vector<uint32_t> d = transmit(FRAME, 7, 56).durations;
// Corrupt a data pulse: the decoder gives up on this frame.
d[20] = 3 * SYMBOL;
TEST_ASSERT_FALSE(receive(d, rx));
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(waiting_synchro), static_cast<uint8_t>(rx.status));
// The next frame is received.
std::vector<uint32_t> next = transmit(FRAME, 7, 56).durations;
bool got = false;
for(uint32_t p : next) if(decodePulse(rx, p) == PulseResult::frame) { got = true; break; }
TEST_ASSERT_TRUE(got);
TEST_ASSERT_EQUAL_HEX8_ARRAY(FRAME, rx.payload, 7);
}
static void test_too_few_hardware_syncs_is_not_a_frame() {
somfy_rx_t rx;
// One sync pulse is only 2 edges; at least 4 are needed.
TEST_ASSERT_FALSE(receive(transmit(FRAME, 1, 56).durations, rx));
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_56_bit_first_frame);
RUN_TEST(test_56_bit_repeat);
RUN_TEST(test_80_bit_first_frame);
RUN_TEST(test_80_bit_repeat);
RUN_TEST(test_records_the_pulses);
RUN_TEST(test_glitch_is_reported_and_skipped);
RUN_TEST(test_bad_timing_mid_frame_starts_over);
RUN_TEST(test_too_few_hardware_syncs_is_not_a_frame);
return UNITY_END();
}