From 735462aff5d80c830f0ea8a06025c52efd2f438f Mon Sep 17 00:00:00 2001 From: LucHeart Date: Thu, 8 Oct 2026 20:41:38 +0200 Subject: [PATCH 1/2] fix: encode T330 shock intensity as 4-bit level with rolling state --- .../src/radio/RFTransmitter.cpp | 2 +- components/protocols/CMakeLists.txt | 1 + .../protocols/include/radio/rmt/Sequence.h | 12 ++++++ components/protocols/src/Sequence.cpp | 16 ++++++++ components/protocols/src/T330Encoder.cpp | 38 ++++++++++++++++++- 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/components/device_control/src/radio/RFTransmitter.cpp b/components/device_control/src/radio/RFTransmitter.cpp index 303f5f34..c3491a0c 100644 --- a/components/device_control/src/radio/RFTransmitter.cpp +++ b/components/device_control/src/radio/RFTransmitter.cpp @@ -234,7 +234,7 @@ static void writeSequences(rmt_channel_handle_t channel, rmt_encoder_handle_t en int64_t timeToLive = seq->transmitEnd() - now; if (timeToLive > 0) { - // Send the command + seq->refill(); transmitSymbols(channel, encoder, seq->payload(), seq->size()); ++seq; continue; diff --git a/components/protocols/CMakeLists.txt b/components/protocols/CMakeLists.txt index e1ccd0c6..fd39e49a 100644 --- a/components/protocols/CMakeLists.txt +++ b/components/protocols/CMakeLists.txt @@ -20,4 +20,5 @@ idf_component_register( REQUIRES common # enums/*, OpenShock.h in public headers; also Checksum.h internally esp_driver_rmt # driver/rmt_types.h (rmt_symbol_word_t) in public headers PRIV_REQUIRES logging # OS_LOG* (internal) + temporal # OpenShock::millis() for T330 rolling state ) diff --git a/components/protocols/include/radio/rmt/Sequence.h b/components/protocols/include/radio/rmt/Sequence.h index 84e65607..88c3f3ce 100644 --- a/components/protocols/include/radio/rmt/Sequence.h +++ b/components/protocols/include/radio/rmt/Sequence.h @@ -19,6 +19,8 @@ namespace OpenShock::Rmt { , m_transmitEnd(0) , m_shockerId(0) , m_shockerModel() + , m_commandType() + , m_intensity(0) , m_terminatorSent(false) { } @@ -29,6 +31,8 @@ namespace OpenShock::Rmt { , m_transmitEnd(other.m_transmitEnd) , m_shockerId(other.m_shockerId) , m_shockerModel(other.m_shockerModel) + , m_commandType(other.m_commandType) + , m_intensity(other.m_intensity) , m_terminatorSent(other.m_terminatorSent) { other.reset(); @@ -54,6 +58,8 @@ namespace OpenShock::Rmt { inline size_t size() const noexcept { return m_size; } bool fill(ShockerCommandType commandType, uint8_t intensity); + // Re-encodes the payload from the last fill() for models whose frames change over time (T330 rolling state). + bool refill(); Sequence& operator=(Sequence&& other) { @@ -66,6 +72,8 @@ namespace OpenShock::Rmt { m_transmitEnd = other.m_transmitEnd; m_shockerId = other.m_shockerId; m_shockerModel = other.m_shockerModel; + m_commandType = other.m_commandType; + m_intensity = other.m_intensity; m_terminatorSent = other.m_terminatorSent; other.reset(); @@ -81,6 +89,8 @@ namespace OpenShock::Rmt { m_transmitEnd = 0; m_shockerId = 0; m_shockerModel = static_cast(0); + m_commandType = static_cast(0); + m_intensity = 0; m_terminatorSent = false; } @@ -89,6 +99,8 @@ namespace OpenShock::Rmt { int64_t m_transmitEnd; uint16_t m_shockerId; ShockerModelType m_shockerModel; + ShockerCommandType m_commandType; + uint8_t m_intensity; bool m_terminatorSent; }; } // namespace OpenShock::Rmt diff --git a/components/protocols/src/Sequence.cpp b/components/protocols/src/Sequence.cpp index 0b497147..8c84b0ac 100644 --- a/components/protocols/src/Sequence.cpp +++ b/components/protocols/src/Sequence.cpp @@ -53,6 +53,8 @@ Rmt::Sequence::Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64 , m_transmitEnd(transmitEnd) , m_shockerId(shockerId) , m_shockerModel(shockerModel) + , m_commandType() + , m_intensity(0) , m_terminatorSent(false) { const EncoderInfo* encoder = findEncoder(shockerModel); @@ -78,7 +80,21 @@ Rmt::Sequence::Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64 bool Rmt::Sequence::fill(ShockerCommandType commandType, uint8_t intensity) { m_terminatorSent = false; // A new payload needs its own terminator again + m_commandType = commandType; + m_intensity = intensity; const EncoderInfo* encoder = findEncoder(m_shockerModel); return encoder != nullptr && m_data != nullptr && encoder->fill(payload(), m_shockerId, commandType, intensity); } + +bool Rmt::Sequence::refill() +{ + if (m_data == nullptr) return false; + + switch (m_shockerModel) { + case ShockerModelType::WellturnT330: + return Rmt::WellturnT330Encoder::FillBuffer(payload(), m_shockerId, m_commandType, m_intensity); + default: + return true; + } +} diff --git a/components/protocols/src/T330Encoder.cpp b/components/protocols/src/T330Encoder.cpp index 8c4481e8..f71ab217 100644 --- a/components/protocols/src/T330Encoder.cpp +++ b/components/protocols/src/T330Encoder.cpp @@ -1,8 +1,10 @@ #include "radio/rmt/T330Encoder.h" #include "radio/rmt/internal/Shared.h" +#include "Temporal.h" #include +#include const rmt_symbol_word_t kRmtPreamble = {960, 1, 790, 0}; const rmt_symbol_word_t kRmtOne = {220, 1, 980, 0}; @@ -11,6 +13,14 @@ const rmt_symbol_word_t kRmtPostamble = {220, 1, 135, 0}; using namespace OpenShock; +struct ShockRollingState { + int64_t lastFillTime = 0; + int64_t transmitStart = 0; + uint8_t baseCounter = 0; +}; + +static std::unordered_map s_shockState; + size_t Rmt::WellturnT330Encoder::GetBufferSize() { return 43; @@ -37,10 +47,36 @@ bool Rmt::WellturnT330Encoder::FillBuffer(rmt_symbol_word_t* sequence, uint16_t return false; // Invalid type } + // Shock intensity byte: [toggle:1][counter:3][level:4] + // Toggle flips every ~1s, counter increments every ~2s (each toggle cycle). + // Computed from wall-clock time so the buffer can be re-filled each send. + uint8_t intensityByte = intensity; + if (type == ShockerCommandType::Shock) { + auto& state = s_shockState[shockerId]; + uint8_t level = (intensity * 15) / 100; + + int64_t now = OpenShock::millis(); + if (state.lastFillTime == 0 || (now - state.lastFillTime) > 200) { + if (state.lastFillTime != 0) { + state.baseCounter = (state.baseCounter + 1) & 0x7; + } + state.transmitStart = now; + } + state.lastFillTime = now; + + int64_t elapsed = now - state.transmitStart; + int64_t periods = elapsed / 1000; + + bool toggle = (periods % 2) != 0; + uint8_t counter = (state.baseCounter + static_cast(periods / 2)) & 0x7; + + intensityByte = (static_cast(toggle) << 7) | ((counter & 0x7) << 4) | (level & 0xF); + } + uint8_t channelId = 0; // CH1 is 0b0000 and CH2 is 0b1110 on my remote but other values probably work. // Payload layout: [channelId:4][typeU:4][transmitterId:16][intensity:8][typeL:4][channelId:4] - uint64_t data = (static_cast(channelId & 0xF) << 36) | (static_cast(typeVal & 0xF0) << 28) | (static_cast(shockerId) << 16) | (static_cast(intensity) << 8) | (static_cast(typeVal & 0xF) << 4) + uint64_t data = (static_cast(channelId & 0xF) << 36) | (static_cast(typeVal & 0xF0) << 28) | (static_cast(shockerId) << 16) | (static_cast(intensityByte) << 8) | (static_cast(typeVal & 0xF) << 4) | static_cast(channelId & 0xF); // Shift the data left by 1 bit to append a zero From 6ae60f41b094fc9bfcc662942e45b74adbf8f3c1 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 8 Oct 2026 20:42:50 +0200 Subject: [PATCH 2/2] test: cover T330 rolling shock intensity encoding --- components/protocols/host_test/CMakeLists.txt | 4 +- .../protocols/host_test/main/CMakeLists.txt | 2 + .../protocols/host_test/main/test_clock.h | 8 ++++ .../host_test/main/test_encoders.cpp | 37 ++++++++++++++++++- .../protocols/host_test/main/test_main.cpp | 13 +++++++ 5 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 components/protocols/host_test/main/test_clock.h diff --git a/components/protocols/host_test/CMakeLists.txt b/components/protocols/host_test/CMakeLists.txt index 7a079865..4f4229c3 100644 --- a/components/protocols/host_test/CMakeLists.txt +++ b/components/protocols/host_test/CMakeLists.txt @@ -6,13 +6,15 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake) # so no firmware component needs to be registered. set(COMPONENTS main) -# linux-target mocks: freertos (unity's IDF integration), and driver, whose include +# linux-target mocks: freertos (unity's IDF integration), esp_timer (Temporal.h's +# millis(), used by the T330 rolling state), and driver, whose include # dirs carry the real / (rmt_symbol_word_t) on # the linux target. Plus the shared host-test stand-ins (Logging.h, openshock_board.h, # CONFIG_OPENSHOCK_*). list(APPEND EXTRA_COMPONENT_DIRS "$ENV{IDF_PATH}/tools/mocks/freertos/" "$ENV{IDF_PATH}/tools/mocks/driver/" + "$ENV{IDF_PATH}/tools/mocks/esp_timer/" "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(protocols_host_test) diff --git a/components/protocols/host_test/main/CMakeLists.txt b/components/protocols/host_test/main/CMakeLists.txt index 2c2847fe..ba02de46 100644 --- a/components/protocols/host_test/main/CMakeLists.txt +++ b/components/protocols/host_test/main/CMakeLists.txt @@ -18,8 +18,10 @@ idf_component_register( "../../include" # radio/rmt/*Encoder.h, Sequence.h "../../src" # radio/rmt/internal/Shared.h (EncodeBits) "../../../common/include" # enums/*, OpenShock.h, Checksum.h + "../../../temporal/include" # Temporal.h (esp_timer_get_time) REQUIRES unity host_support # Logging.h, openshock_board.h (test/host_support) driver # (tools/mocks/driver on linux) + esp_timer # tools/mocks/esp_timer WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped ) diff --git a/components/protocols/host_test/main/test_clock.h b/components/protocols/host_test/main/test_clock.h new file mode 100644 index 00000000..d8116ff3 --- /dev/null +++ b/components/protocols/host_test/main/test_clock.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +// Fake OpenShock::millis() for encoders with time-based state (T330 rolling counter). +namespace TestClock { + extern int64_t NowMs; +} // namespace TestClock diff --git a/components/protocols/host_test/main/test_encoders.cpp b/components/protocols/host_test/main/test_encoders.cpp index eb6c0c3a..191d5b3f 100644 --- a/components/protocols/host_test/main/test_encoders.cpp +++ b/components/protocols/host_test/main/test_encoders.cpp @@ -8,6 +8,7 @@ #include "radio/rmt/Petrainer998DREncoder.h" #include "radio/rmt/PetrainerEncoder.h" #include "radio/rmt/T330Encoder.h" +#include "test_clock.h" #include @@ -159,7 +160,7 @@ TEST_CASE("Wellturn T330 shock/sound codes, clamping and unsupported types", "[p uint64_t data = decode(seq + 1, 41, t330One) >> 1; TEST_ASSERT_EQUAL_UINT8(0x6, (data >> 32) & 0xF); // 0b0110'0001 TEST_ASSERT_EQUAL_UINT8(0x1, (data >> 4) & 0xF); - TEST_ASSERT_EQUAL_UINT8(100, (data >> 8) & 0xFF); // clamped + TEST_ASSERT_EQUAL_UINT8(15, (data >> 8) & 0xF); // clamped to 100, scaled to the 4-bit level TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(seq, 1, ShockerCommandType::Sound, 50)); data = decode(seq + 1, 41, t330One) >> 1; @@ -170,6 +171,40 @@ TEST_CASE("Wellturn T330 shock/sound codes, clamping and unsupported types", "[p TEST_ASSERT_FALSE(Rmt::WellturnT330Encoder::FillBuffer(seq, 1, ShockerCommandType::Light, 10)); } +TEST_CASE("Wellturn T330 shock rolling toggle and counter", "[protocols][t330]") +{ + rmt_symbol_word_t seq[43]; + const uint16_t id = 0x1234; // own rolling state, independent of the other T330 tests + + auto shockByte = [&](uint8_t intensity) { + TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(seq, id, ShockerCommandType::Shock, intensity)); + return static_cast(((decode(seq + 1, 41, t330One) >> 1) >> 8) & 0xFF); + }; + + // Refill every 100ms like RFTransmitter does while a command is live. + auto runUntil = [&](int64_t from, int64_t to) { + for (TestClock::NowMs = from; TestClock::NowMs < to; TestClock::NowMs += 100) shockByte(50); + TestClock::NowMs = to; + }; + + // [toggle:1][counter:3][level:4] + TestClock::NowMs = 10000; + TEST_ASSERT_EQUAL_HEX8(0x07, shockByte(50)); // 50% -> level 7, fresh state + + runUntil(10100, 11000); + TEST_ASSERT_EQUAL_HEX8(0x87, shockByte(50)); // 1s in: toggle set + + runUntil(11100, 12000); + TEST_ASSERT_EQUAL_HEX8(0x17, shockByte(50)); // 2s in: toggle clear, counter 1 + + TestClock::NowMs = 20000; // gap > 200ms starts a new transmission: base counter advances + TEST_ASSERT_EQUAL_HEX8(0x1F, shockByte(100)); + + // Vibrate is not affected + TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(seq, id, ShockerCommandType::Vibrate, 42)); + TEST_ASSERT_EQUAL_UINT8(42, ((decode(seq + 1, 41, t330One) >> 1) >> 8) & 0xFF); +} + // --------------------------------------------------------------------------- // D80: [preamble][0x04:8][shockerId:16][type:2][channel:2][intensity:4][checksum:8][postamble] // intensity is scaled 0-100 -> 0-15 (non-zero never rounds down to 0); channel = 1 diff --git a/components/protocols/host_test/main/test_main.cpp b/components/protocols/host_test/main/test_main.cpp index 7c6c46fc..7e56369f 100644 --- a/components/protocols/host_test/main/test_main.cpp +++ b/components/protocols/host_test/main/test_main.cpp @@ -2,8 +2,21 @@ // FreeRTOS is mocked, so there is no ESP startup - drive Unity directly. #include "unity.h" +#include "Mockesp_timer.h" + +#include "test_clock.h" + +int64_t TestClock::NowMs = 0; + +static int64_t fakeTimerGetTime(int) +{ + return TestClock::NowMs * 1000; +} + extern "C" int main(void) { + esp_timer_get_time_Stub(fakeTimerGetTime); + UNITY_BEGIN(); unity_run_all_tests(); return UNITY_END();