Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 39 additions & 25 deletions ocrustar.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
# Constants
# ---------------------------------------------------------------------------
USB_VID = 0x045C # Renesas/NEC (borrowed by device)
USB_PID = 0x02AA # Ocrustar IR Blaster
USB_PIDS = [0x02AA, 0x014A, 0x0134, 0x0195, 0x0184, 0x0130, 0x0189, 0x018F, 0x0131, 0x0132, 0x0133]

# Handshake tokens
TOKEN_HELLO = bytes([0xFC] * 4)
Expand Down Expand Up @@ -158,33 +158,43 @@ def frame_checksum(frame_bytes):

def leb128_encode(value):
"""
LEB128 encode a timing value WITH mandatory ÷16 prescaling.
Encode a timing value (µs) using the firmware's two-regime scheme.

The device hardware operates on 16µs ticks. All timing values (in
microseconds) must be divided by 16 before encoding. This prescaling
step is not obvious from the code and is the #1 cause of encoding bugs.
The device distinguishes encodings by the high bit of the first byte:

Special cases:
- Values ≤ 1 are returned as-is (used for dictionary indices 0x00/0x01)
- The byte 0xFF is reserved as a separator; any LEB128 output byte
that would be 0xFF is clamped to 0xFE
"""
if value <= 1:
return [value]
- value ≤ 2032µs: a SINGLE byte = round(µs / 16). The high bit is clear,
so the device decodes it back as byte × 16. (Values 0 and 1 are passed
through as-is — they double as the dictionary indices 0x00 / 0x01.)

- value > 2032µs: LEB128 of the RAW microseconds, NOT prescaled. The first
byte's continuation bit is set, which is how the device knows to decode
the value directly in µs instead of multiplying by 16.

# Prescale: convert µs to 16µs ticks (with rounding)
scaled = int(value / 16.0 + 0.5)
Prescaling EVERY value by 16 (the previous behavior) is the #1 cause of
encoding bugs: any pulse over 2032µs — i.e. every IR lead-in mark and
inter-frame gap — then transmits ~16× too short and is undecodable by the
receiver, even though the IR LED still visibly fires.

The byte 0xFF is reserved as a separator; any output byte that would be
0xFF is clamped to 0xFE.
"""
if value <= 2032:
# 0 and 1 pass through (dictionary indices); else single ÷16 tick byte
q = value if value in (0, 1) else int(value / 16.0 + 0.5)
return [q & 0xFF]

# Large value: LEB128 of the raw microseconds (no prescaling)
result = []
raw = value
while True:
byte = scaled & 0x7F
scaled >>= 7
if scaled:
byte = raw & 0x7F
raw >>= 7
if raw:
byte |= 0x80 # set continuation bit
if (byte & 0xFF) == 0xFF:
byte = 0xFE # escape 0xFF (reserved separator)
result.append(byte & 0xFF)
if not scaled:
if not raw:
break
return result

Expand Down Expand Up @@ -447,19 +457,23 @@ def __init__(self):

def connect(self):
"""Find and claim the USB device. Returns True on success."""
self.device = usb.core.find(
idVendor=USB_VID,
idProduct=USB_PID,
backend=_backend,
)
for pid in USB_PIDS:
self.device = usb.core.find(
idVendor=USB_VID,
idProduct=pid,
backend=_backend,
)
if self.device:
break
if not self.device:
print(f"Device not found (VID=0x{USB_VID:04X} PID=0x{USB_PID:04X})")
pids_str = ", ".join(f"0x{p:04X}" for p in USB_PIDS)
print(f"Device not found (VID=0x{USB_VID:04X}, PIDs=[{pids_str}])")
print(" - Is the device plugged in?")
print(" - On Linux, you may need: sudo or a udev rule")
print(" - On Windows, install libusb via Zadig")
return False

print(f"Found device: VID=0x{USB_VID:04X} PID=0x{USB_PID:04X}")
print(f"Found device: VID=0x{USB_VID:04X} PID=0x{self.device.idProduct:04X}")

# Detach kernel driver if necessary (Linux)
try:
Expand Down