Infrared Control of 3d Puzzles like ROKR Teacups and Tonecheer Booknook

I wanted to make some battery-powered decorative kits controllable from Home Assistant without killing the batteries. I already had some IR-controlled candles, and it turns out IR control is a pretty good fit for battery projects: the passive current draw can be very low, and the receiver can sit there waiting for a command while the microcontroller mostly sleeps. This circuit should moreless be able to turn anything on/off using infrared that is battery powered.

At some point it dawned on me I could dim the book nook and add candle flicker as a pretty cool upgrade to the original. The trickiest unknown when I started is on the the teacups there's a touch sensor and I wasn't sure I could reliably spoof it. The control board is tiny surface mount suff and hard to tap -- so spoofing did work but was an unknown at the start.

This project is SUPER cool because it shows using IR receiver you could activate just about anything that runs on battery.


I also already had a Bond Bridge, so the idea became:

  • Use the Bond Bridge as a programmable IR transmitter, then put a tiny low-power IR receiver/controller inside each battery-powered puzzle.

This ended up working flawlessly for two different devices:

  • Tonecheer Book Nook: Keep the original PIR/motion behavior, but add IR override, dimming, and candle flicker.
  • ROKR EA04 Tilt-A-Whirl / Teacups: Simulate the capacitive touch button without soldering to the main board, using a foil sleeve around the touch-sensor wire.

The Core Hardware

  • ATtiny85-20PU
  • TSOP39238 38 kHz IR receiver
  • 3xAA or 3xAAA battery power from the original device
  • ZVN4206A MOSFET for the booknook to handle the LED power

Other Components

  • Bond Bridge as IR transmitter (or any really)
  • To program Attiny 85: Tiny AVR Programmer
  • I used ElectroCookie Mini Solderable Breadboards

The TSOP39238 receiver is always powered. It draws about 450 µA typical supply current and works from 2.5 V to 5.5 V, which fits 3-cell AA/AAA battery devices well. The ATtiny85 spends most of its time in sleep and wakes on the IR receiver output. This seems to work without issue.


Why IR codes?

I used simple NEC-style IR commands. A standard NEC command contains:

  • 38 kHz carrier bursts
  • 9 ms leader burst
  • 4.5 ms leader space
  • address byte
  • inverse address byte
  • command byte
  • inverse command byte
  • stop burst

So each device can share one address but use different command bytes. My project uses NEC address: 0x7D.

Book Nook command set:

  • 0x31 = full ON, then sleep
  • 0x32 = release PIR / OFF, then sleep
  • 0x34 = 50% dim, stay awake
  • 0x35 = 25% dim, stay awake
  • 0x36 = full candle flicker, stay awake
  • 0x37 = 50% max candle flicker, stay awake
  • 0x38 = 25% max candle flicker, stay awake

Teacups command set:

  • 0x70 = single tap
  • 0x71 = double tap

Note: The teacups codes start far away from the book nook codes so there is no accidental overlap.



Pictures

[Picture 1: Book nook controller board]. We need to pull the negative side of the LED output to ground (splice into that wire). I pulled power by splicing into positive and negative battery wires.

All that you can see when installed is the TSOP sticking out in the back.

This shows the board inside the booknook-- I justed used velcro to attach.

Here's a board close up of the book nook.

Here's where I mounted the TSOP IR rcvr on the teacups puzzle. The whole inner part and battery box rotates so this was a bit tricky to pack in there. I used shrink wrap on the device. Note the colors are wrong in the pic with etching out it's really left to right unused | v+ | signal out | negative, I didn't take a picture of the board but I used a dremel to cut just the needed 4-5 rows of breadboard to cram it into just the right spot.

To spoof the touch sensor on the teacups I used aluminum duct tape and wrapped it around the insulated green touch wire with a long exposed section of wire (red in pic) so it could impart a bit of capacitance.

Here's the EA04 control board. I had to tap the battery power on the board (solder direct) and then bring the wires out through drilled holes in the cover.

This shows where I cut the board and shoved it into the tiny volume above the battery area with all the wires in place.

Bond Bridge Raw IR Transmit

I used the Bond Local API directly. Bond’s raw signal transmit endpoint is: PUT /v2/signal/tx

The payload I used is:

{
  "freq": 38,
  "modulation": "OOK",
  "encoding": "hex",
  "bps": 40000,
  "reps": 1,
  "data": "HEX_DATA_HERE"
}

Bond’s local API documents hex encoding, bps, and reps; hex encoding is limited to 40000 bps, and reps is the number of times to repeat the data if supplied.

Test the Bond Bridge:

export BOND_IP="192.168.1.xxx"
export BOND_TOKEN="your_local_token_here"

curl -H "BOND-Token: $BOND_TOKEN" \
  "http://$BOND_IP/v2/sys/version"

Transmit raw IR:

curl -X PUT "http://$BOND_IP/v2/signal/tx" \
  -H "BOND-Token: $BOND_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "freq": 38,
    "modulation": "OOK",
    "encoding": "hex",
    "bps": 40000,
    "reps": 1,
    "data": "YOUR_GENERATED_HEX"
  }'

Home Assistant Integration:
I'm still working on this but you should be able to use a REST command like this:

rest_command:
  bond_raw_ir:
    url: "[http://192.168.1.xxx/v2/signal/tx](http://192.168.1.xxx/v2/signal/tx)"
    method: PUT
    headers:
      BOND-Token: "YOUR_LOCAL_TOKEN"
      Content-Type: "application/json"
    payload: >
      {
        "freq": 38,
        "modulation": "OOK",
        "encoding": "hex",
        "bps": 40000,
        "reps": 1,
        "data": "{{ data }}"
      }

Another option is you can push new commands to the device so the bond native integration should work fine. The below would create a saved command under a device called Book Nook On :

curl -i -X POST "http://$BOND_IP/v2/devices/DEVICE_ID/commands" \
  -H "BOND-Token: $BOND_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Book Nook On",
    "action": "TurnOn",
    "icon": "power",
    "category_name": "Book Nook",
    "button_type": "tap",
    "hidden": false
  }'

Then store the IR code on that command

curl -i -X PUT "http://$BOND_IP/v2/devices/DEVICE_ID/commands/$BOOK_ON_COMMAND_ID/signal" \
  -H "BOND-Token: $BOND_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "freq": 38,
    "modulation": "OOK",
    "encoding": "hex",
    "bps": 40000,
    "reps": 1,
    "data": "GENERATED_HEX_HERE"
  }'

Book Nook Hardware

The Tonecheer book nook already had a PIR/motion controller. I wanted to preserve that and add a parallel override.

Measurements showed:

  • Battery pack: ~4.0 V with NiMH cells
  • LED board current: ~57 mA at full brightness
  • LED board voltage: ~4 V when on
  • LED+ is connected to battery+
  • LED− is low-side switched by the factory controller

So the LED path is essentially: Battery+ → LED board → LED− → factory low-side switch → Battery−

That means the remote override can be a second low-side MOSFET in parallel:

  • LED− → MOSFET drain
  • MOSFET source → battery−
  • MOSFET gate → ATtiny PB3 through 100Ω
  • 100k pulldown from gate to battery−

Remote behavior:

  • IR full ON: MOSFET on → LED− pulled to battery− → LEDs forced on
  • IR OFF: MOSFET off → factory PIR resumes control

Important subtlety: OFF does not force darkness. It releases the override back to the PIR board. That is exactly what I wanted.

Book Nook Parts

  • ATtiny85-20PU, DIP-8
  • 8-pin DIP socket
  • Vishay TSOP39238 38 kHz IR receiver
  • ZVN4206A N-channel MOSFET, TO-92
  • 100Ω gate resistor
  • 100kΩ gate pulldown
  • 0.1 µF capacitor across TSOP VCC/GND
  • Optional 4.7–47 µF bulk capacitor across battery rails
  • ElectroCookie mini/micro solderable breadboard
  • Wire/test pads for BAT_POS, BAT_NEG, LED_NEG

Note: If you reverse drain/source, it can sort-of work but badly: I saw dim leakage when “off” and weird partial brightness. Fixing MOSFET orientation fixed that. Double check the spec sheets on TSOP and MOSFET orientations and don't trust my diagrams!

https://www.vishay.com/docs/82778/tsop392.pdf

https://www.mouser.com/datasheet/2/115/zvn4206a-1165266.pdf

Book Nook Firmware Behavior

Full ON and OFF are static latch states:

  • Full ON: PB3 HIGH, MOSFET on, ATtiny goes back to sleep (output pin remains high).
  • OFF: PB3 LOW, MOSFET off, PIR resumes, ATtiny goes back to sleep.

Dimming and flicker are different:

  • 50% / 25% dim: software PWM on PB3 (ATtiny must stay awake).
  • Candle flicker: random-walk PWM brightness (ATtiny must stay awake).

That is a tradeoff. Static ON/OFF has long shelf life. Dimming/flicker costs more current because the ATtiny has to keep running. The realistic candle version is not random jumping; it slowly slews brightness toward random targets with occasional dips.


Teacups / ROKR EA04 Hardware

The teacups puzzle was much harder mechanically. The board has:

  • U1 = custom MCU
  • IC1 = AM01B capacitive touch IC
  • IC2 = HF2035F motor driver

The capacitive touch sensor wire goes to the AM01B. I did not want to cut motor power or solder into the main board if I could avoid it.

The final working method was:

  • ATtiny PB3 → 68k resistor → aluminum foil/copper tape sleeve.
  • Sleeve wraps around the insulated capacitive touch-sensor wire.
  • No direct electrical connection to the touch wire.
    WoodTrick (Electric Series)

Cutebee

Hands Craft

Ugears

Anavrin

Rolife (by Robotime)

Key Discovery: A high-frequency burst above ~20 kHz, applied to foil wrapped around the touch-sensor wire, reliably simulates a touch.

I tried a lot of signals (DC high/low, square waves from 1 Hz up to 60 kHz, PWM ramps). The reliable family was: 20 kHz and above, 125 ms burst duration.

I settled on:

  • Single Tap: 25 kHz burst for 250 ms, floating release afterward.
  • Double Tap: 25 kHz burst for 250 ms → 100 ms floating gap → 25 kHz burst for 250 ms → floating release.

The TTP233D-HA6 (AM01B) capacitive touch sensor detects shifts in electrical capacitance on its sense wire to trigger the motor controller, remaining in its high-responsiveness "Fast mode" for "ABOUT 10 SEC" after any detected activity, which requires the ATtiny to provide a 250 ms burst length to reliably overlap with the chip's slower 125–160 ms low-power sampling window.

The teacups board does not need a MOSFET because the capacitive sleeve is driven directly through a resistor from the ATtiny pin.

Teacups Parts

  • ATtiny85-20PU, DIP-8
  • 8-pin DIP socket
  • Vishay TSOP39238 38 kHz IR receiver
  • 68k resistor from ATtiny PB3 to foil sleeve
  • 0.1 µF capacitor across ATtiny VCC/GND
  • Optional 4.7–47 µF bulk capacitor
  • ElectroCookie mini/micro solderable breadboard (cut down to fit)
  • Aluminum/Copper tape
  • Wires to EA04 battery+, battery−, and foil sleeve


TSOP39238 Pinout Warning

The thing I struggled with most was the TSOP IR receiver orientation. My TSOP39238 part has 4 leads. Label your PCB clearly and verify against your physical part/datasheet before soldering. For my final header I wanted the board order labeled clearly as:
GND | IR_DATA | VCC | GND

There was a lot of confusion because the package/view orientation matters. Do not trust a generic 3-pin TSOP diagram; this part/package was 4-pin.


Programming the ATtiny85 on Linux

I used the Amazon bundle: risingsaplings 2pcs ATtiny85-20PU + Tiny AVR Programmer (a USBtinyISP-style programmer with a DIP-8 socket).

Programming flow:

  1. Install Arduino IDE
  2. Install SpenceKonde ATTinyCore
  3. Install Arduino-IRremote library by Armin Joachimsmeyer
  4. Insert ATtiny85 into the programmer socket, notch aligned correctly

Arduino IDE settings:

  • Board: ATtiny25/45/85
  • Chip: ATtiny85
  • Clock: 8 MHz internal
  • B.O.D.: Disabled
  • Programmer: USBtinyISP

First time only:

  • Tools → Burn Bootloader (Sets fuses/clock. It does not install a normal bootloader).

Upload firmware:

  • Sketch → Upload Using Programmer (Do not use normal Upload).

Important Warning: Do not insert the ATtiny backwards. It gets hot very quickly. Ask me how I know.


Battery Life Estimates

The TSOP39238 dominates standby current:

  • TSOP39238: ~0.45 mA
  • ATtiny asleep: a few µA
  • Total standby: roughly 0.36–0.47 mA

Book Nook (3xAA, assuming 2000–2500 mAh):

  • Standby: ~181 to 226 days (about 6–7.5 months).
  • Full-on LED runtime (57 mA): ~35 to 44 hours.
  • 50% dim runtime (ATtiny awake): ~55–75 hours.
  • 25% dim runtime (ATtiny awake): ~90–130 hours.

Teacups (3xAAA, assuming 900–1100 mAh):

  • Standby: ~81 to 100 days (about 2.5–3.5 months).
  • The teacups burst itself is very short, so active use has negligible impact compared with standby.

If I ran the ATtiny awake all the time, standby would be weeks instead of months. Sleep mode is critical.


Key Lessons

  1. IR is great for battery projects if the receiver current is low enough. The TSOP39238’s ~450 µA current is acceptable for AA/AAA shelf-life.
  2. Sleeping ATtiny + IR decode needs a wake strategy. A dummy wake burst before the real command made decoding reliable.
  3. Bond Bridge raw IR works well. Sent generated NEC waveforms through /v2/signal/tx. Broadlink also worked fine.
  4. MOSFET orientation matters. Reversing drain/source caused dim leakage.
  5. No-solder capacitive injection worked. Wrapped foil around the insulated sensor wire and drove it with a 25 kHz / 125 ms burst.
  6. Static ON/OFF can sleep. Dimming/flicker cannot. PWM effects require the ATtiny to stay awake, costing more battery, but saving overall.
  7. Label TSOP and ATtiny orientation clearly. These were the easiest ways to make mistakes.

These boards should work for:
WoodTrick (Electric Series)
Cutebee
Hands Craft
Ugears
Anavrin
Rolife (by Robotime)

--CONTINUED-- I'll post all the C++ code for the ATtiny and my linux command line tests on the first reply.

Here's the python code for linux to test everything from the command line. The provided Python script allows you to generate and transmit custom NEC-formatted infrared signals on the fly. Because our ATtiny85 microcontroller spends most of its time in a deep power-down sleep state to save battery, it cannot instantly decode a complex IR protocol the moment it detects light. To solve this, the script defaults to a "wake-prefixed" transmission mode, where it first fires a 1-millisecond dummy IR burst just to trigger the ATtiny's hardware interrupt and wake it up. After a brief 30-millisecond pause to let the microcontroller's internal clock stabilize, the script transmits the actual, perfectly timed NEC command frame.

To use the script, simply call it using Python 3, followed by your target command. Because the script defaults to the wake-prefixed mode, you can safely trigger sleeping microcontrollers with a single terminal command.

Available Commands:

  • Book Nook: on, off, dim50, dim25, flicker, flicker50, flicker25
  • Teacups: teacups_single, teacups_double
#!/usr/bin/env python3

import os
import sys
import json
import urllib.request

STEP_US = 25
ADDRESS = 0x7D

COMMANDS = {
    # Book nook
    "on":        0x31,
    "off":       0x32,
    "dim50":     0x34,
    "dim25":     0x35,
    "flicker":   0x36,
    "flicker50": 0x37,
    "flicker25": 0x38,

    # Teacups / EA04
    "teacups_single": 0x70,
    "teacups_double": 0x71,
}


def pulse(us, level):
    return [1 if level else 0] * round(us / STEP_US)


def nec_lsb(byte):
    return [(byte >> i) & 1 for i in range(8)]


def nec_frame(address, command):
    bits = []

    bits += pulse(9000, 1)
    bits += pulse(4500, 0)

    for b in [address, address ^ 0xFF, command, command ^ 0xFF]:
        for bit in nec_lsb(b):
            bits += pulse(560, 1)
            bits += pulse(1690 if bit else 560, 0)

    bits += pulse(560, 1)
    bits += pulse(40000, 0)

    return bits


def wake_prefixed_nec(address, command):
    bits = []

    # Dummy IR wake burst. This only wakes the sleeping ATtiny.
    # It is not intended to decode as NEC.
    bits += pulse(1000, 1)
    bits += pulse(30000, 0)

    # One real NEC command after the ATtiny is awake.
    bits += nec_frame(address, command)

    return bits


def bits_to_hex(bits):
    bits = list(bits)

    while len(bits) % 8:
        bits.append(0)

    out = bytearray()
    for i in range(0, len(bits), 8):
        byte = 0
        for bit in bits[i:i + 8]:
            byte = (byte << 1) | bit
        out.append(byte)

    return out.hex().upper()


def send_bits(bits, reps=1, invert=False):
    if invert:
        bits = [0 if b else 1 for b in bits]

    bond_ip = os.environ["BOND_IP"]
    bond_token = os.environ["BOND_TOKEN"]

    payload = {
        "freq": 38,
        "modulation": "OOK",
        "encoding": "hex",
        "bps": 40000,
        "reps": reps,
        "data": bits_to_hex(bits),
    }

    req = urllib.request.Request(
        f"http://{bond_ip}/v2/signal/tx",
        data=json.dumps(payload).encode("utf-8"),
        method="PUT",
        headers={
            "BOND-Token": bond_token,
            "Content-Type": "application/json",
        },
    )

    with urllib.request.urlopen(req, timeout=5) as r:
        print(r.status, r.read().decode("utf-8"))


def print_usage():
    names = "|".join(COMMANDS.keys())

    print("Usage:")
    print(f"  bond_nec_send.py {names}")
    print(f"  bond_nec_send.py {names} wake")
    print(f"  bond_nec_send.py {names} reps 2")
    print(f"  bond_nec_send.py {names} reps 5")
    print(f"  bond_nec_send.py {names} invert")
    print()
    print("Default mode is wake-prefixed, reps=1.")
    print()
    print("Examples:")
    print("  python3 ~/Arduino/bond_nec_send.py on")
    print("  python3 ~/Arduino/bond_nec_send.py off")
    print("  python3 ~/Arduino/bond_nec_send.py teacups_single")
    print("  python3 ~/Arduino/bond_nec_send.py teacups_double")
    print("  python3 ~/Arduino/bond_nec_send.py on reps 2")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print_usage()
        sys.exit(1)

    name = sys.argv[1].lower()
    args = [arg.lower() for arg in sys.argv[2:]]

    if name not in COMMANDS:
        print(f"Unknown command: {name}")
        print()
        print_usage()
        sys.exit(1)

    invert = "invert" in args
    command = COMMANDS[name]

    # Default: wake-prefixed, one real command, reps=1.
    use_wake_prefix = True
    reps = 1

    if "raw" in args:
        # Raw NEC frame, no wake prefix.
        # Usually only useful for no-sleep firmware testing.
        use_wake_prefix = False

    if "reps" in args:
        idx = args.index("reps")
        if idx + 1 >= len(args):
            raise SystemExit("Missing number after 'reps'")
        reps = int(args[idx + 1])
        use_wake_prefix = False

    if use_wake_prefix:
        bits = wake_prefixed_nec(ADDRESS, command)
        send_bits(bits, reps=1, invert=invert)
    else:
        bits = nec_frame(ADDRESS, command)
        send_bits(bits, reps=reps, invert=invert)


Tonecheer Book Nook Firmware (book_nook.ino)

/*
  Tonecheer book nook IR override - v2.5
  ATtiny85 + TSOP39238 + ZVN4206A

  PB2 / physical pin 7 = TSOP OUT
  PB3 / physical pin 2 = MOSFET gate / board 8

  Board:
    7 = MOSFET drain  = LED_NEG
    8 = MOSFET gate   = ATtiny PB3
    9 = MOSFET source = BAT_NEG / GND

  NEC address: 0x7D

  Commands:
    0x31 = full ON, then sleep
    0x32 = release PIR / OFF, then sleep
    0x34 = 50% dim, stay awake
    0x35 = 25% dim, stay awake
    0x36 = existing candle flicker, stay awake
    0x37 = 50% max candle flicker, stay awake
    0x38 = 25% max candle flicker, stay awake

  Use Bond wake-prefix sender or reps=2.
*/

#include <Arduino.h>
#include <avr/sleep.h>
#include <avr/power.h>
#include <avr/interrupt.h>

#define IR_RECEIVE_PIN 2
#define TRIGGER_PIN    3

#include "TinyIRReceiver.hpp"

const uint16_t IR_ADDRESS = 0x7D;

const uint8_t BOOK_FORCE_ON_CODE       = 0x31;
const uint8_t BOOK_RELEASE_PIR_CODE    = 0x32;
const uint8_t BOOK_DIM_50_CODE         = 0x34;
const uint8_t BOOK_DIM_25_CODE         = 0x35;
const uint8_t BOOK_FLICKER_CODE        = 0x36;
const uint8_t BOOK_FLICKER_50_CODE     = 0x37;
const uint8_t BOOK_FLICKER_25_CODE     = 0x38;

enum OutputMode {
  MODE_RELEASED,
  MODE_FULL_ON,
  MODE_DIM_50,
  MODE_DIM_25,
  MODE_FLICKER,
  MODE_FLICKER_50,
  MODE_FLICKER_25
};

OutputMode mode = MODE_RELEASED;

void setMode(OutputMode newMode) {
  mode = newMode;

  if (mode == MODE_RELEASED) {
    digitalWrite(TRIGGER_PIN, LOW);
  }

  if (mode == MODE_FULL_ON) {
    digitalWrite(TRIGGER_PIN, HIGH);
  }
}

bool applyDecodedCommand() {
  if (TinyIRReceiverDecode()) {
    uint16_t address = TinyIRReceiverData.Address;
    uint8_t command  = TinyIRReceiverData.Command;
    uint8_t flags    = TinyIRReceiverData.Flags;

    if (!(flags & IRDATA_FLAGS_IS_REPEAT) && address == IR_ADDRESS) {
      if (command == BOOK_FORCE_ON_CODE) {
        setMode(MODE_FULL_ON);
        return true;
      }

      if (command == BOOK_RELEASE_PIR_CODE) {
        setMode(MODE_RELEASED);
        return true;
      }

      if (command == BOOK_DIM_50_CODE) {
        setMode(MODE_DIM_50);
        return true;
      }

      if (command == BOOK_DIM_25_CODE) {
        setMode(MODE_DIM_25);
        return true;
      }

      if (command == BOOK_FLICKER_CODE) {
        setMode(MODE_FLICKER);
        return true;
      }

      if (command == BOOK_FLICKER_50_CODE) {
        setMode(MODE_FLICKER_50);
        return true;
      }

      if (command == BOOK_FLICKER_25_CODE) {
        setMode(MODE_FLICKER_25);
        return true;
      }
    }
  }

  return false;
}

void listenWindow(uint16_t ms) {
  for (uint16_t i = 0; i < ms; i++) {
    applyDecodedCommand();
    delay(1);
  }
}

void sleepNow() {
  set_sleep_mode(SLEEP_MODE_PWR_DOWN);

  noInterrupts();
  sleep_enable();
  interrupts();

  sleep_cpu();

  sleep_disable();
}

// Software PWM on PB3.
// 0 = off, 255 = full on.
// One cycle is about 1 ms, around 1 kHz PWM.
void softwarePwm(uint8_t duty) {
  if (duty == 0) {
    digitalWrite(TRIGGER_PIN, LOW);
    delayMicroseconds(1000);
    return;
  }

  if (duty >= 255) {
    digitalWrite(TRIGGER_PIN, HIGH);
    delayMicroseconds(1000);
    return;
  }

  uint16_t onTime = duty * 4;      // 0..1020 us
  uint16_t offTime = 1024 - onTime;

  digitalWrite(TRIGGER_PIN, HIGH);
  delayMicroseconds(onTime);

  digitalWrite(TRIGGER_PIN, LOW);
  delayMicroseconds(offTime);
}

void runDim50() {
  softwarePwm(128);
  applyDecodedCommand();
}

void runDim25() {
  softwarePwm(64);
  applyDecodedCommand();
}

void runFlicker() {
  /*
    Existing full-range slow realistic candle/lantern flicker.
  */

  static uint8_t targetBrightness = 230;
  static uint8_t currentBrightness = 230;
  static uint16_t updateCounter = 0;

  updateCounter++;

  if (updateCounter >= 120) {
    updateCounter = 0;

    int r = random(0, 100);

    if (r < 75) {
      targetBrightness = random(205, 256);
    } else if (r < 95) {
      targetBrightness = random(170, 220);
    } else {
      targetBrightness = random(120, 180);
    }
  }

  if (currentBrightness < targetBrightness) {
    currentBrightness++;
  } else if (currentBrightness > targetBrightness) {
    currentBrightness--;
  }

  softwarePwm(currentBrightness);
  applyDecodedCommand();
}

void runLimitedFlicker(uint8_t maxBrightness) {
  /*
    Same style as the existing flicker, but scaled down.

    maxBrightness = 128 gives 50% max candle flicker.
    maxBrightness = 64 gives 25% max candle flicker.
  */

  static uint8_t targetBrightness50 = 115;
  static uint8_t currentBrightness50 = 115;
  static uint16_t updateCounter50 = 0;

  static uint8_t targetBrightness25 = 58;
  static uint8_t currentBrightness25 = 58;
  static uint16_t updateCounter25 = 0;

  uint8_t *target;
  uint8_t *current;
  uint16_t *counter;

  if (maxBrightness <= 64) {
    target = &targetBrightness25;
    current = &currentBrightness25;
    counter = &updateCounter25;
  } else {
    target = &targetBrightness50;
    current = &currentBrightness50;
    counter = &updateCounter50;
  }

  (*counter)++;

  if (*counter >= 120) {
    *counter = 0;

    int r = random(0, 100);

    if (r < 75) {
      // Mostly near the selected maximum.
      *target = random((uint8_t)(maxBrightness * 80UL / 100UL), maxBrightness + 1);
    } else if (r < 95) {
      // Small soft dip.
      *target = random((uint8_t)(maxBrightness * 65UL / 100UL),
                       (uint8_t)(maxBrightness * 86UL / 100UL));
    } else {
      // Rare deeper dip.
      *target = random((uint8_t)(maxBrightness * 45UL / 100UL),
                       (uint8_t)(maxBrightness * 70UL / 100UL));
    }
  }

  if (*current < *target) {
    (*current)++;
  } else if (*current > *target) {
    (*current)--;
  }

  softwarePwm(*current);
  applyDecodedCommand();
}

void setup() {
  pinMode(TRIGGER_PIN, OUTPUT);
  digitalWrite(TRIGGER_PIN, LOW);   // default: release PIR

  ADCSRA &= ~_BV(ADEN);
  power_adc_disable();
  power_usi_disable();

  randomSeed(analogRead(0));

  initPCIInterruptForTinyIRReceiver();

  // Brief listen at power-up.
  listenWindow(100);
}

void loop() {
  if (mode == MODE_DIM_50) {
    runDim50();
    return;
  }

  if (mode == MODE_DIM_25) {
    runDim25();
    return;
  }

  if (mode == MODE_FLICKER) {
    runFlicker();
    return;
  }

  if (mode == MODE_FLICKER_50) {
    runLimitedFlicker(128);
    return;
  }

  if (mode == MODE_FLICKER_25) {
    runLimitedFlicker(64);
    return;
  }

  // For RELEASED and FULL_ON, the output is static.
  // These modes sleep after the receive window.
  sleepNow();

  // After any IR wake activity, stay awake long enough to catch the command.
  listenWindow(350);
}


ROKR EA04 Teacups Firmware (teacups.ino)

/*
  ROKR EA04 / Teacups capacitive sleeve remote trigger - v1.2 Reliability Patch
  ATtiny85 + TSOP39238 + capacitive sleeve

  PB2 / physical pin 7 = TSOP OUT
  PB3 / physical pin 2 = CAP_SLEEVE drive through R2

  Updates in v1.2:
  - Increased BURST_MS to 250ms to ensure overlap with touch IC low-power sampling (~125-160ms).
  - Maintained sleep/wake logic for battery longevity.
*/

#include <Arduino.h>
#include <avr/sleep.h>
#include <avr/power.h>
#include <avr/interrupt.h>

#define IR_RECEIVE_PIN 2
#define TRIGGER_PIN    3

#include "TinyIRReceiver.hpp"

const uint16_t IR_ADDRESS = 0x7D;

const uint8_t TEACUPS_SINGLE_TAP_CODE = 0x70;
const uint8_t TEACUPS_DOUBLE_TAP_CODE = 0x71;

const uint32_t BURST_HZ = 25000;
const uint16_t BURST_MS = 250;        // Increased from 125ms for TTP233D reliability
const uint16_t DOUBLE_TAP_GAP_MS = 100;

void releaseSleeve(unsigned long ms) {
  pinMode(TRIGGER_PIN, INPUT);   // floating / released
  delay(ms);
}

void capacitiveBurst(uint32_t frequencyHz, uint16_t durationMs) {
  uint16_t halfPeriodUs = 500000UL / frequencyHz;

  pinMode(TRIGGER_PIN, OUTPUT);

  unsigned long endTime = millis() + durationMs;

  while (millis() < endTime) {
    digitalWrite(TRIGGER_PIN, HIGH);
    delayMicroseconds(halfPeriodUs);

    digitalWrite(TRIGGER_PIN, LOW);
    delayMicroseconds(halfPeriodUs);
  }

  pinMode(TRIGGER_PIN, INPUT);   // release immediately after burst
}

void singleTap() {
  capacitiveBurst(BURST_HZ, BURST_MS);
  releaseSleeve(250);
}

void doubleTap() {
  capacitiveBurst(BURST_HZ, BURST_MS);
  releaseSleeve(DOUBLE_TAP_GAP_MS);
  capacitiveBurst(BURST_HZ, BURST_MS);
  releaseSleeve(250);
}

bool applyDecodedCommandOnce() {
  if (TinyIRReceiverDecode()) {
    uint16_t address = TinyIRReceiverData.Address;
    uint8_t command  = TinyIRReceiverData.Command;
    uint8_t flags    = TinyIRReceiverData.Flags;

    if (!(flags & IRDATA_FLAGS_IS_REPEAT) && address == IR_ADDRESS) {
      if (command == TEACUPS_SINGLE_TAP_CODE) {
        singleTap();
        return true; 
      }

      if (command == TEACUPS_DOUBLE_TAP_CODE) {
        doubleTap();
        return true; 
      }
    }
  }
  return false;
}

void listenWindowUntilCommandOrTimeout(uint16_t ms) {
  for (uint16_t i = 0; i < ms; i++) {
    if (applyDecodedCommandOnce()) {
      return; 
    }
    delay(1);
  }
}

void sleepNow() {
  set_sleep_mode(SLEEP_MODE_PWR_DOWN);
  noInterrupts();
  sleep_enable();
  interrupts();
  sleep_cpu();
  sleep_disable();
}

void setup() {
  pinMode(TRIGGER_PIN, INPUT);   // default released/floating

  ADCSRA &= ~_BV(ADEN);
  power_adc_disable();
  power_usi_disable();

  initPCIInterruptForTinyIRReceiver();

  listenWindowUntilCommandOrTimeout(100);
}

void loop() {
  sleepNow();
  // Stay awake long enough for the Broadlink wake + gap + NEC frame
  listenWindowUntilCommandOrTimeout(350);
}

As a bonus here's my broadlink RM4 scripts.yaml and the commands to trigger the devices with broadlink:

# ----------------------------------------------------------------
# LIVING ROOM BROADLINK IR COMMANDS
# ----------------------------------------------------------------
living_room_ir_command:
  alias: Living Room IR Command
  mode: queued
  fields:
    command:
      name: Command
      required: true
      selector:
        select:
          options:
            - 'on'
            - 'off'
            - dim50
            - dim25
            - flicker
            - flicker50
            - flicker25
            - teacups_single
            - teacups_double
  sequence:
    - variables:
        codes:
          'on': "b64:JgBKAAABEokRNBERETMRNBEzETQRMxERERESMxERERERERERERIRMxE0ERERERERETMRNBERERERERE0ETMRNBERERERMxIzEQAEwg0F"
          'off': "b64:JgBKAAABEokRNBERETMRNBEzETQRMxERERESMxERERERERERERIRMxERETQRERERETMRNBERERERNBERETMRNBERERERMxIzEQAEwg0F"
          dim50: "b64:JgBKAAABEokRNBERETMRNBEzETQRMxERERESMxERERERERERERIRMxERERERNBERETMRNBERERERNBEzERERNBERERERMxIzEQAEwg0F"
          dim25: "b64:JgBKAAABEokRNBERETMRNBEzETQRMxERERESMxERERERERERERIRMxE0ERERMxERETQRMxERERIREREzERERNBERERERMxIzEQAEwg0F"
          flicker: "b64:JgBKAAABEokRNBERETMRNBEzETQRMxERERESMxERERERERERERIRMxERETQRMxERETQRMxERERIRMxERERERNBERERERMxIzEQAEwg0F"
          flicker50: "b64:JgBKAAABEokRNBERETMRNBEzETQRMxERERESMxERERERERERERIRMxE0ETMRNBERETMRNBERERERERERERERNBERERERMxIzEQAEwg0F"
          flicker25: "b64:JgBKAAABEokRNBERETMRNBEzETQRMxERERESMxERERERERERERIRMxERERERERE0ETMRNBERERERNBEzETQRERERERERMxIzEQAEwg0F"
          teacups_single: "b64:JgBKAAABEokRNBERETMRNBEzETQRMxERERESMxERERERERERERIRMxEREREREREREjMRMxIzERERNBEzETQRMxERERERERIzEQAEwg0F"
          teacups_double: "b64:JgBKAAABEokRNBERETMRNBEzETQRMxERERESMxERERERERERERIRMxE0ERERERERETMRNBEzERIREREzETQRMxERERERERIzEQAEwg0F"

    - condition: template
      value_template: "{{ command in codes }}"

    - action: remote.send_command
      target:
        entity_id: remote.living_room_ir
      data:
        command: "{{ codes[command] }}"