Skip to content

feat: Add WellturnT330Alt1 shocker model with rolling shock intensity - #472

Draft
LucHeart wants to merge 9 commits into
developfrom
fix/t330-shock-intensity-encoding
Draft

LucHeart wants to merge 9 commits into
developfrom
fix/t330-shock-intensity-encoding

Conversation

@LucHeart

@LucHeart LucHeart commented May 27, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Some collars sold as the Wellturn T330 are actually a different variant: they read the shock level from the low nibble (0-15) of the intensity byte, and the upper bits carry a toggle bit and a 3-bit rolling counter ([toggle:1][counter:3][level:4]). The stock T330 takes a raw 0-100 value.
  • Adds this protocol as a new WellturnT330Alt1 model ("wellturnt330alt1" / "t330alt1"). The stock WellturnT330 encoder is unchanged from develop.
  • The toggle bit flips every ~1s and the counter steps every ~2s within a transmission. A gap of more than 200ms between frames starts a new transmission, and the base counter goes up by one. Rolling state is kept per shocker ID.
  • Sequence stores the last command and has a new refill(nowMs). RFTransmitter calls it before each payload frame. It does nothing for models without rolling state.
  • The encoder takes the current time as a parameter instead of calling millis(), so the protocols component stays free of a clock dependency and the rolling logic can be host-tested without an esp_timer mock.
  • Shock maps 0-100 to a 0-15 level; like D80, a non-zero intensity never rounds down to level 0.
  • If refill() fails, the sequence is terminated and logged instead of resending the previous frame.
  • Vibrate and sound are encoded the same way as on the stock T330.

Limitations

  • The new model is only reachable through serial and local JSON commands. The flatbuffers ShockerModelType schema has no value for it (D80 has the same gap), so commands from the hub or backend are dropped until schemas, the backend and the frontend are updated.

Known issues to follow up

  • With about 5 or more active sequences, one pass of the transmit loop takes longer than 200ms, so every frame counts as a new transmission.
  • A new transmission uses baseCounter + 1, which can repeat a counter value already sent during a long previous press.
  • The rolling state is an unsynchronised global std::unordered_map that never removes entries.

Test plan

  • Host tests pass (components/protocols, components/common, components/device_control)
  • Shock at various intensities on a T330 Alt1 collar and check it responds at the right levels
  • Hold a shock for more than 2s and check the collar keeps responding
  • Vibrate and sound still work on the Alt1 collar
  • The stock wellturnt330 model behaves exactly as it does on develop

The T330 collar variant reads shock intensity from the lower nibble
(0-15) of the intensity byte, not as a raw 0-100 value. The upper
bits carry a rolling counter and toggle bit.

Map 0-100 intensity to 0-15 level in the lower nibble and maintain
per-shocker rolling state (counter + toggle) in the upper bits.
Vibrate and sound encoding remain unchanged.
@hhvrc
hhvrc force-pushed the fix/t330-shock-intensity-encoding branch from e15d8e2 to 6ae60f4 Compare October 8, 2026 21:55
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The 4-bit level + toggle/counter shock encoding belongs to a different
collar variant, not the stock Wellturn T330. Restore the T330 encoder to
develop's version and port the encoding to a new WellturnT330Alt1 model
in the components/protocols layout.

The encoder takes the current time as a parameter instead of calling
millis(), so the protocols component stays free of a clock dependency and
the rolling state is host-testable. Sequence keeps the last command and
RFTransmitter calls refill(now) before each payload frame; refill is a
no-op for models without rolling state.
uint8_t intensityByte = intensity;
if (type == ShockerCommandType::Shock) {
auto& state = s_shockState[shockerId];
uint8_t level = (intensity * 15) / 100;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this have a floor at 1? 1-6 produce level 0 in the final 4-bit field

we might want similar logic as:

if (intensity > 0) intensity = std::max((intensity * 15) / 100, 1);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in ea68d05. This encoding now lives in its own WellturnT330Alt1 model (the stock T330 is back to develop), and shock uses the same floor as D80: any non-zero intensity is at least level 1. Added test cases for 1, 6 and 14%.


if (timeToLive > 0) {
// Send the command
seq->refill();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're ignoring the return value, not sure it matters since it looks a little edge-casey but if it returns false we might want to log an error or skip the transmit for that round, vs now i think the prior contents of the payload buffer would be transmitted

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ea68d05. If refill() fails, the transmit loop now logs an error and sets the sequence's end to now, so it goes straight to the terminator instead of resending the previous payload. That matches what modifySequence already does when fill() fails.

The in-place T330 rolling-state port (735462a, 6ae60f4) is replaced by
the separate WellturnT330Alt1 model, which takes the time as a parameter
instead of depending on temporal/esp_timer, so its fake-clock test setup
is not needed.
@LucHeart LucHeart changed the title fix: Encode T330 shock intensity as 4-bit level with rolling state feat: Add WellturnT330Alt1 shocker model with rolling shock intensity Oct 24, 2026
- Floor non-zero shock intensities at level 1, like D80, so 1-6% no
  longer encode as level 0.
- Terminate a sequence instead of resending a stale frame when refill()
  fails, and log it.
- Add the namespace comment clang-format wants on T330Alt1Encoder.h.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

3 participants