Skip to content
8 changes: 8 additions & 0 deletions .changes/wellturn-t330-alt1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
kind: added
pr: null
---
Add WellturnT330Alt1 shocker model

## Release Note
Added support for the Wellturn T330 Alt1 shocker variant, which encodes shock intensity as a 0-15 level with a rolling counter.
4 changes: 4 additions & 0 deletions components/common/host_test/main/test_enums.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ TEST_CASE("ShockerModelType: all accepted spellings", "[common][enums]")
TEST_ASSERT_EQUAL(ShockerModelType::WellturnT330, m);
TEST_ASSERT_TRUE(TryParseShockerModelType(m, "d80"));
TEST_ASSERT_EQUAL(ShockerModelType::D80, m);
TEST_ASSERT_TRUE(TryParseShockerModelType(m, "wellturnt330alt1"));
TEST_ASSERT_EQUAL(ShockerModelType::WellturnT330Alt1, m);
TEST_ASSERT_TRUE(TryParseShockerModelType(m, "t330alt1"));
TEST_ASSERT_EQUAL(ShockerModelType::WellturnT330Alt1, m);
}

TEST_CASE("ShockerModelType: case-insensitive", "[common][enums]")
Expand Down
8 changes: 7 additions & 1 deletion components/common/include/enums/ShockerModelType.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ namespace OpenShock {
Petrainer,
Petrainer998DR,
WellturnT330,
D80
D80,
WellturnT330Alt1
};

inline bool TryParseShockerModelType(ShockerModelType& out, std::string_view str, bool allowTypo = false)
Expand Down Expand Up @@ -51,6 +52,11 @@ namespace OpenShock {
return true;
}

if (StringIEquals(str, "wellturnt330alt1") || StringIEquals(str, "t330alt1")) {
out = ShockerModelType::WellturnT330Alt1;
return true;
}

return false;
}
} // namespace OpenShock
2 changes: 1 addition & 1 deletion components/core/src/serialization/JsonSerial.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ bool JsonSerial::ParseShockerCommand(JSON::JsonView root, JsonSerial::ShockerCom
}
ShockerModelType modelType = ShockerModelType::CaiXianlin;
if (!TryParseShockerModelType(modelType, modelStr)) {
OS_LOGE(TAG, "value at 'model' is not a valid shocker model (caixianlin, petrainer, petrainer998dr, wellturnt330, d80)");
OS_LOGE(TAG, "value at 'model' is not a valid shocker model (caixianlin, petrainer, petrainer998dr, wellturnt330, d80, wellturnt330alt1)");
return false;
}

Expand Down
1 change: 1 addition & 0 deletions components/device_control/host_test/main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ idf_component_register(
"../../../protocols/src/D80Encoder.cpp"
"../../../protocols/src/Petrainer998DREncoder.cpp"
"../../../protocols/src/PetrainerEncoder.cpp"
"../../../protocols/src/T330Alt1Encoder.cpp"
"../../../protocols/src/T330Encoder.cpp"
INCLUDE_DIRS "."
"stubs" # config/Config.h (fake RF/E-Stop settings)
Expand Down
7 changes: 7 additions & 0 deletions components/device_control/src/radio/RFTransmitter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ static void writeSequences(rmt_channel_handle_t channel, rmt_encoder_handle_t en

int64_t timeToLive = seq->transmitEnd() - now;

// Models with rolling state re-encode every frame; if that fails, terminate rather than resend a stale frame.
if (timeToLive > 0 && !seq->refill(now)) {
OS_LOGE(TAG, "Failed to re-encode frame for shocker %hu, terminating it", seq->shockerId());
seq->setTransmitEnd(now);
timeToLive = 0;
}

if (timeToLive > 0) {
// Send the command
transmitSymbols(channel, encoder, seq->payload(), seq->size());
Expand Down
2 changes: 2 additions & 0 deletions components/protocols/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
# radio/rmt/Petrainer998DREncoder.h
# radio/rmt/D80Encoder.h
# radio/rmt/T330Encoder.h
# radio/rmt/T330Alt1Encoder.h

idf_component_register(
SRCS "src/CaiXianlinEncoder.cpp"
"src/D80Encoder.cpp"
"src/Petrainer998DREncoder.cpp"
"src/PetrainerEncoder.cpp"
"src/Sequence.cpp"
"src/T330Alt1Encoder.cpp"
"src/T330Encoder.cpp"
INCLUDE_DIRS "include"
PRIV_INCLUDE_DIRS "src" # radio/rmt/internal/Shared.h (private)
Expand Down
1 change: 1 addition & 0 deletions components/protocols/host_test/main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ idf_component_register(
"../../src/D80Encoder.cpp"
"../../src/Petrainer998DREncoder.cpp"
"../../src/PetrainerEncoder.cpp"
"../../src/T330Alt1Encoder.cpp"
"../../src/T330Encoder.cpp"
"../../src/Sequence.cpp"
INCLUDE_DIRS "."
Expand Down
80 changes: 79 additions & 1 deletion components/protocols/host_test/main/test_encoders.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Frame layout tests for the Petrainer, Petrainer 998DR, Wellturn T330 and D80 encoders. Each test decodes the
// Frame layout tests for the Petrainer, Petrainer 998DR, Wellturn T330 (+Alt1) and D80 encoders. Each test decodes the
// emitted RMT symbols back into bits and checks every field of the documented payload layout, so a refactor of the
// encoders (or of EncodeBits) can't silently change what goes on air.
#include "unity.h"
Expand All @@ -7,6 +7,7 @@
#include "radio/rmt/D80Encoder.h"
#include "radio/rmt/Petrainer998DREncoder.h"
#include "radio/rmt/PetrainerEncoder.h"
#include "radio/rmt/T330Alt1Encoder.h"
#include "radio/rmt/T330Encoder.h"

#include <cstdint>
Expand Down Expand Up @@ -170,6 +171,83 @@ TEST_CASE("Wellturn T330 shock/sound codes, clamping and unsupported types", "[p
TEST_ASSERT_FALSE(Rmt::WellturnT330Encoder::FillBuffer(seq, 1, ShockerCommandType::Light, 10));
}

// ---------------------------------------------------------------------------
// Wellturn T330 Alt1: same frame as the T330, but shock carries [toggle:1][counter:3][level:4] in the intensity byte
// ---------------------------------------------------------------------------
static uint8_t t330Alt1IntensityByte(const rmt_symbol_word_t* seq)
{
return ((decode(seq + 1, 41, t330One) >> 1) >> 8) & 0xFF;
}

TEST_CASE("Wellturn T330 Alt1 frame layout matches T330 for vibrate and sound", "[protocols][t330alt1]")
{
TEST_ASSERT_EQUAL(43, Rmt::WellturnT330Alt1Encoder::GetBufferSize());

rmt_symbol_word_t alt1[43];
rmt_symbol_word_t t330[43];
const ShockerCommandType types[] = {ShockerCommandType::Vibrate, ShockerCommandType::Sound};
for (ShockerCommandType type : types) {
TEST_ASSERT_TRUE(Rmt::WellturnT330Alt1Encoder::FillBuffer(alt1, 0x0F0F, type, 77));
TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(t330, 0x0F0F, type, 77));
TEST_ASSERT_EQUAL_MEMORY(t330, alt1, sizeof(t330));
}

TEST_ASSERT_FALSE(Rmt::WellturnT330Alt1Encoder::FillBuffer(alt1, 1, ShockerCommandType::Light, 10));
}

TEST_CASE("Wellturn T330 Alt1 shock maps 0-100 to a 0-15 level, non-zero never rounds to 0", "[protocols][t330alt1]")
{
rmt_symbol_word_t seq[43];
const struct {
uint8_t in;
uint8_t level;
} cases[] = {
{ 0, 0},
{ 1, 1},
{ 6, 1},
{ 14, 2},
{ 50, 7},
{100, 15},
{150, 15}, // clamped
};
for (const auto& c : cases) {
TEST_ASSERT_TRUE(Rmt::WellturnT330Alt1Encoder::FillBuffer(seq, 1, ShockerCommandType::Shock, c.in));
TEST_ASSERT_EQUAL_UINT8(c.level, t330Alt1IntensityByte(seq)); // no rolling bits without a refill
}
}

TEST_CASE("Wellturn T330 Alt1 shock rolling toggle and counter", "[protocols][t330alt1]")
{
rmt_symbol_word_t seq[43];
const uint16_t id = 0xA1A1; // rolling state is kept per shocker id across calls

auto at = [&](int64_t nowMs) {
TEST_ASSERT_TRUE(Rmt::WellturnT330Alt1Encoder::RefillBuffer(seq, id, ShockerCommandType::Shock, 100, nowMs));
return t330Alt1IntensityByte(seq);
};

// Refill every 50ms like the transmit loop does, up to and including `toMs`.
int64_t now = 1000;
auto runTo = [&](int64_t toMs) {
uint8_t last = at(now);
while (now < toMs) last = at(now += 50);
return last;
};

// One continuous transmission: toggle flips every second, counter steps every two.
TEST_ASSERT_EQUAL_HEX8(0x0F, runTo(1950));
TEST_ASSERT_EQUAL_HEX8(0x8F, runTo(2000));
TEST_ASSERT_EQUAL_HEX8(0x1F, runTo(3000));
TEST_ASSERT_EQUAL_HEX8(0x9F, runTo(4000));

// A gap of more than 200ms starts a new transmission with the base counter advanced by one.
TEST_ASSERT_EQUAL_HEX8(0x1F, at(4500));

// Vibrate never carries rolling bits.
TEST_ASSERT_TRUE(Rmt::WellturnT330Alt1Encoder::RefillBuffer(seq, id, ShockerCommandType::Vibrate, 77, 4550));
TEST_ASSERT_EQUAL_UINT8(77, t330Alt1IntensityByte(seq));
}

// ---------------------------------------------------------------------------
// 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
Expand Down
1 change: 1 addition & 0 deletions components/protocols/host_test/main/test_sequence.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ TEST_CASE("Sequence dispatches every supported model", "[protocols][sequence]")
ShockerModelType::Petrainer998DR,
ShockerModelType::WellturnT330,
ShockerModelType::D80,
ShockerModelType::WellturnT330Alt1,
};

for (ShockerModelType model : models) {
Expand Down
12 changes: 12 additions & 0 deletions components/protocols/include/radio/rmt/Sequence.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ namespace OpenShock::Rmt {
, m_shockerId(0)
, m_shockerModel()
, m_terminatorSent(false)
, m_commandType()
, m_intensity(0)
{
}
Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64_t transmitEnd);
Expand All @@ -30,6 +32,8 @@ namespace OpenShock::Rmt {
, m_shockerId(other.m_shockerId)
, m_shockerModel(other.m_shockerModel)
, m_terminatorSent(other.m_terminatorSent)
, m_commandType(other.m_commandType)
, m_intensity(other.m_intensity)
{
other.reset();
}
Expand All @@ -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 for models whose frames change over time (rolling state); no-op for the rest.
bool refill(int64_t nowMs);

Sequence& operator=(Sequence&& other)
{
Expand All @@ -67,6 +73,8 @@ namespace OpenShock::Rmt {
m_shockerId = other.m_shockerId;
m_shockerModel = other.m_shockerModel;
m_terminatorSent = other.m_terminatorSent;
m_commandType = other.m_commandType;
m_intensity = other.m_intensity;

other.reset();

Expand All @@ -82,6 +90,8 @@ namespace OpenShock::Rmt {
m_shockerId = 0;
m_shockerModel = static_cast<ShockerModelType>(0);
m_terminatorSent = false;
m_commandType = static_cast<ShockerCommandType>(0);
m_intensity = 0;
}

rmt_symbol_word_t* m_data;
Expand All @@ -90,5 +100,7 @@ namespace OpenShock::Rmt {
uint16_t m_shockerId;
ShockerModelType m_shockerModel;
bool m_terminatorSent;
ShockerCommandType m_commandType;
uint8_t m_intensity;
};
} // namespace OpenShock::Rmt
14 changes: 14 additions & 0 deletions components/protocols/include/radio/rmt/T330Alt1Encoder.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#pragma once

#include "enums/ShockerCommandType.h"

#include <driver/rmt_types.h>

#include <cstdint>

namespace OpenShock::Rmt::WellturnT330Alt1Encoder {
size_t GetBufferSize();
bool FillBuffer(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity);
// Re-encodes the frame with the shock rolling state (toggle + counter) for nowMs; call before every frame sent.
bool RefillBuffer(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity, int64_t nowMs);
} // namespace OpenShock::Rmt::WellturnT330Alt1Encoder
17 changes: 17 additions & 0 deletions components/protocols/src/Sequence.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const char* const TAG = "Sequence";
#include "radio/rmt/D80Encoder.h"
#include "radio/rmt/Petrainer998DREncoder.h"
#include "radio/rmt/PetrainerEncoder.h"
#include "radio/rmt/T330Alt1Encoder.h"
#include "radio/rmt/T330Encoder.h"

using namespace OpenShock;
Expand All @@ -16,6 +17,8 @@ namespace {
struct EncoderInfo {
size_t (*bufferSize)();
bool (*fill)(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType commandType, uint8_t intensity);
// Only set for models with rolling state; re-encodes the payload right before each frame goes out.
bool (*refill)(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType commandType, uint8_t intensity, int64_t nowMs);
};

const EncoderInfo* findEncoder(ShockerModelType modelType)
Expand All @@ -28,6 +31,7 @@ namespace {
static constexpr EncoderInfo kPetrainer998DR {Rmt::Petrainer998DREncoder::GetBufferSize, Rmt::Petrainer998DREncoder::FillBuffer};
static constexpr EncoderInfo kWellturnT330 {Rmt::WellturnT330Encoder::GetBufferSize, Rmt::WellturnT330Encoder::FillBuffer};
static constexpr EncoderInfo kD80 {Rmt::D80Encoder::GetBufferSize, Rmt::D80Encoder::FillBuffer};
static constexpr EncoderInfo kWellturnT330Alt1 {Rmt::WellturnT330Alt1Encoder::GetBufferSize, Rmt::WellturnT330Alt1Encoder::FillBuffer, Rmt::WellturnT330Alt1Encoder::RefillBuffer};

switch (modelType) {
case ShockerModelType::CaiXianlin:
Expand All @@ -40,6 +44,8 @@ namespace {
return &kWellturnT330;
case ShockerModelType::D80:
return &kD80;
case ShockerModelType::WellturnT330Alt1:
return &kWellturnT330Alt1;
default:
OS_LOGE(TAG, "Unknown shocker model: %u", static_cast<unsigned>(modelType));
return nullptr;
Expand Down Expand Up @@ -78,7 +84,18 @@ 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(int64_t nowMs)
{
const EncoderInfo* encoder = findEncoder(m_shockerModel);
if (encoder == nullptr || m_data == nullptr) return false;
if (encoder->refill == nullptr) return true;

return encoder->refill(payload(), m_shockerId, m_commandType, m_intensity, nowMs);
}
Loading
Loading