diff --git a/.changes/wellturn-t330-alt1.md b/.changes/wellturn-t330-alt1.md new file mode 100644 index 00000000..13c1fadf --- /dev/null +++ b/.changes/wellturn-t330-alt1.md @@ -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. diff --git a/components/common/host_test/main/test_enums.cpp b/components/common/host_test/main/test_enums.cpp index 9df1d309..1c066f4a 100644 --- a/components/common/host_test/main/test_enums.cpp +++ b/components/common/host_test/main/test_enums.cpp @@ -22,6 +22,8 @@ TEST_CASE("ShockerModelType: all accepted spellings", "[common][enums]") TEST_ASSERT_TRUE(TryParseShockerModelType("wellturnt330") == ShockerModelType::WellturnT330); TEST_ASSERT_TRUE(TryParseShockerModelType("t330") == ShockerModelType::WellturnT330); TEST_ASSERT_TRUE(TryParseShockerModelType("d80") == ShockerModelType::D80); + TEST_ASSERT_TRUE(TryParseShockerModelType("wellturnt330alt1") == ShockerModelType::WellturnT330Alt1); + TEST_ASSERT_TRUE(TryParseShockerModelType("t330alt1") == ShockerModelType::WellturnT330Alt1); } TEST_CASE("ShockerModelType: case-insensitive", "[common][enums]") diff --git a/components/common/include/enums/ShockerModelType.h b/components/common/include/enums/ShockerModelType.h index 64d2253e..f1557de7 100644 --- a/components/common/include/enums/ShockerModelType.h +++ b/components/common/include/enums/ShockerModelType.h @@ -12,7 +12,8 @@ namespace OpenShock { Petrainer, Petrainer998DR, WellturnT330, - D80 + D80, + WellturnT330Alt1 }; [[nodiscard]] inline std::optional TryParseShockerModelType(std::string_view str, bool allowTypo = false) @@ -45,6 +46,10 @@ namespace OpenShock { return ShockerModelType::D80; } + if (StringIEquals(str, "wellturnt330alt1") || StringIEquals(str, "t330alt1")) { + return ShockerModelType::WellturnT330Alt1; + } + return std::nullopt; } } // namespace OpenShock diff --git a/components/core/src/serialization/JsonSerial.cpp b/components/core/src/serialization/JsonSerial.cpp index a2d3bec1..93efba72 100644 --- a/components/core/src/serialization/JsonSerial.cpp +++ b/components/core/src/serialization/JsonSerial.cpp @@ -28,7 +28,7 @@ std::optional JsonSerial::ParseShockerCommand(JSON:: } auto modelType = TryParseShockerModelType(*modelStr); if (!modelType) { - 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 std::nullopt; } diff --git a/components/device_control/host_test/main/CMakeLists.txt b/components/device_control/host_test/main/CMakeLists.txt index eb931c8f..403996d4 100644 --- a/components/device_control/host_test/main/CMakeLists.txt +++ b/components/device_control/host_test/main/CMakeLists.txt @@ -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) diff --git a/components/device_control/src/radio/RFTransmitter.cpp b/components/device_control/src/radio/RFTransmitter.cpp index b7b33697..f253eec4 100644 --- a/components/device_control/src/radio/RFTransmitter.cpp +++ b/components/device_control/src/radio/RFTransmitter.cpp @@ -295,6 +295,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(), failures); diff --git a/components/protocols/CMakeLists.txt b/components/protocols/CMakeLists.txt index e1ccd0c6..0edb337f 100644 --- a/components/protocols/CMakeLists.txt +++ b/components/protocols/CMakeLists.txt @@ -7,6 +7,7 @@ # radio/rmt/Petrainer998DREncoder.h # radio/rmt/D80Encoder.h # radio/rmt/T330Encoder.h +# radio/rmt/T330Alt1Encoder.h idf_component_register( SRCS "src/CaiXianlinEncoder.cpp" @@ -14,6 +15,7 @@ idf_component_register( "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) diff --git a/components/protocols/host_test/main/CMakeLists.txt b/components/protocols/host_test/main/CMakeLists.txt index 2c2847fe..2cf279db 100644 --- a/components/protocols/host_test/main/CMakeLists.txt +++ b/components/protocols/host_test/main/CMakeLists.txt @@ -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 "." diff --git a/components/protocols/host_test/main/test_encoders.cpp b/components/protocols/host_test/main/test_encoders.cpp index eb6c0c3a..0eb629c0 100644 --- a/components/protocols/host_test/main/test_encoders.cpp +++ b/components/protocols/host_test/main/test_encoders.cpp @@ -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" @@ -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 @@ -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 diff --git a/components/protocols/host_test/main/test_sequence.cpp b/components/protocols/host_test/main/test_sequence.cpp index cfbfb83b..bc332d25 100644 --- a/components/protocols/host_test/main/test_sequence.cpp +++ b/components/protocols/host_test/main/test_sequence.cpp @@ -30,6 +30,7 @@ TEST_CASE("Sequence dispatches every supported model", "[protocols][sequence]") ShockerModelType::Petrainer998DR, ShockerModelType::WellturnT330, ShockerModelType::D80, + ShockerModelType::WellturnT330Alt1, }; for (ShockerModelType model : models) { diff --git a/components/protocols/include/radio/rmt/Sequence.h b/components/protocols/include/radio/rmt/Sequence.h index 84e65607..2bede21f 100644 --- a/components/protocols/include/radio/rmt/Sequence.h +++ b/components/protocols/include/radio/rmt/Sequence.h @@ -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); @@ -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(); } @@ -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) { @@ -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(); @@ -82,6 +90,8 @@ namespace OpenShock::Rmt { m_shockerId = 0; m_shockerModel = static_cast(0); m_terminatorSent = false; + m_commandType = static_cast(0); + m_intensity = 0; } rmt_symbol_word_t* m_data; @@ -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 diff --git a/components/protocols/include/radio/rmt/T330Alt1Encoder.h b/components/protocols/include/radio/rmt/T330Alt1Encoder.h new file mode 100644 index 00000000..9f145ce0 --- /dev/null +++ b/components/protocols/include/radio/rmt/T330Alt1Encoder.h @@ -0,0 +1,14 @@ +#pragma once + +#include "enums/ShockerCommandType.h" + +#include + +#include + +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 diff --git a/components/protocols/src/Sequence.cpp b/components/protocols/src/Sequence.cpp index 5cbfeaa3..5f2fdf03 100644 --- a/components/protocols/src/Sequence.cpp +++ b/components/protocols/src/Sequence.cpp @@ -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" #include @@ -18,6 +19,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) @@ -25,11 +28,13 @@ namespace { static constexpr EncoderInfo kCaiXianlin { Rmt::CaiXianlinEncoder::GetBufferSize, [](rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType commandType, uint8_t intensity) { return Rmt::CaiXianlinEncoder::FillBuffer(data, shockerId, 0, commandType, intensity); }, + nullptr, }; - static constexpr EncoderInfo kPetrainer {Rmt::PetrainerEncoder::GetBufferSize, Rmt::PetrainerEncoder::FillBuffer}; - 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 kPetrainer {Rmt::PetrainerEncoder::GetBufferSize, Rmt::PetrainerEncoder::FillBuffer, nullptr}; + static constexpr EncoderInfo kPetrainer998DR {Rmt::Petrainer998DREncoder::GetBufferSize, Rmt::Petrainer998DREncoder::FillBuffer, nullptr}; + static constexpr EncoderInfo kWellturnT330 {Rmt::WellturnT330Encoder::GetBufferSize, Rmt::WellturnT330Encoder::FillBuffer, nullptr}; + static constexpr EncoderInfo kD80 {Rmt::D80Encoder::GetBufferSize, Rmt::D80Encoder::FillBuffer, nullptr}; + static constexpr EncoderInfo kWellturnT330Alt1 {Rmt::WellturnT330Alt1Encoder::GetBufferSize, Rmt::WellturnT330Alt1Encoder::FillBuffer, Rmt::WellturnT330Alt1Encoder::RefillBuffer}; switch (modelType) { case ShockerModelType::CaiXianlin: @@ -42,6 +47,8 @@ namespace { return &kWellturnT330; case ShockerModelType::D80: return &kD80; + case ShockerModelType::WellturnT330Alt1: + return &kWellturnT330Alt1; } // The model arrives from the network/config and may hold any value; never assume it is in range. @@ -81,7 +88,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); +} diff --git a/components/protocols/src/T330Alt1Encoder.cpp b/components/protocols/src/T330Alt1Encoder.cpp new file mode 100644 index 00000000..b5946a6b --- /dev/null +++ b/components/protocols/src/T330Alt1Encoder.cpp @@ -0,0 +1,107 @@ +#include "radio/rmt/T330Alt1Encoder.h" + +#include "radio/rmt/internal/Shared.h" + +#include +#include + +const rmt_symbol_word_t kRmtPreamble = {960, 1, 790, 0}; +const rmt_symbol_word_t kRmtOne = {220, 1, 980, 0}; +const rmt_symbol_word_t kRmtZero = {220, 1, 580, 0}; +const rmt_symbol_word_t kRmtPostamble = {220, 1, 135, 0}; + +using namespace OpenShock; + +namespace { + struct ShockRollingState { + int64_t lastFillTime = 0; + int64_t transmitStart = 0; + uint8_t baseCounter = 0; + }; + + std::unordered_map s_shockState; + + // Shock intensity byte: [toggle:1][counter:3][level:4] + // Toggle flips every ~1s, counter increments every ~2s (each toggle cycle). + uint8_t shockRollingBits(uint16_t shockerId, int64_t now) + { + auto& state = s_shockState[shockerId]; + + 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; + + return (static_cast(toggle) << 7) | (counter << 4); + } + + // Shock reads a 0-15 level from the low nibble; like D80, a non-zero intensity never rounds down to level 0. + uint8_t shockLevel(uint8_t intensity) + { + return intensity > 0 ? std::max((intensity * 15) / 100, 1) : 0; + } + + bool fillImpl(rmt_symbol_word_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity, uint8_t rollingBits) + { + // Intensity must be between 0 and 100 + intensity = std::min(intensity, static_cast(100)); + + uint8_t typeVal = 0; + switch (type) { + case ShockerCommandType::Shock: + typeVal = 0b01100001; + intensity = rollingBits | shockLevel(intensity); + break; + case ShockerCommandType::Vibrate: + typeVal = 0b01110010; + break; + case ShockerCommandType::Sound: + typeVal = 0b10000100; + intensity = 0; // The remote always sends 0, I don't know what happens if you send something else. + break; + default: + return false; // Invalid type + } + + 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) + | static_cast(channelId & 0xF); + + // Shift the data left by 1 bit to append a zero + data <<= 1; + + // Generate the sequence + sequence[0] = kRmtPreamble; + Rmt::Internal::EncodeBits<41>(sequence + 1, data, kRmtOne, kRmtZero); + sequence[42] = kRmtPostamble; + + return true; + } +} // namespace + +size_t Rmt::WellturnT330Alt1Encoder::GetBufferSize() +{ + return 43; +} + +bool Rmt::WellturnT330Alt1Encoder::FillBuffer(rmt_symbol_word_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity) +{ + return fillImpl(sequence, shockerId, type, intensity, 0); +} + +bool Rmt::WellturnT330Alt1Encoder::RefillBuffer(rmt_symbol_word_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity, int64_t nowMs) +{ + uint8_t rollingBits = type == ShockerCommandType::Shock ? shockRollingBits(shockerId, nowMs) : 0; + return fillImpl(sequence, shockerId, type, intensity, rollingBits); +} diff --git a/components/serial_console/src/command_handlers/rftransmit.cpp b/components/serial_console/src/command_handlers/rftransmit.cpp index ceb752dd..72436adf 100644 --- a/components/serial_console/src/command_handlers/rftransmit.cpp +++ b/components/serial_console/src/command_handlers/rftransmit.cpp @@ -40,7 +40,7 @@ OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::RfTr "json"sv, "must be a JSON object with the following fields:"sv, "{\"model\":\"caixianlin\",\"id\":12345,\"type\":\"vibrate\",\"intensity\":99,\"durationMs\":500}"sv, - {"model (string) Model of the shocker (\"caixianlin\", \"petrainer\", \"petrainer998dr\", \"wellturnt330\", \"d80\")"sv, + {"model (string) Model of the shocker (\"caixianlin\", \"petrainer\", \"petrainer998dr\", \"wellturnt330\", \"d80\", \"wellturnt330alt1\")"sv, "id (number) ID of the shocker (0-65535)"sv, "type (string) Type of the command (\"shock\", \"vibrate\", \"sound\", \"light\", \"stop\")"sv, "intensity (number) Intensity of the command (0-255)"sv,