SIRCS (Sony infrared remote control)¶
SIRCS is the signalling protocol Sony uses for its
infrared remote controls and its wired CONTROL S jacks. vcrtool implements it with a
sans-I/O design: the protocol logic lives in SIRCSCodec, which does no
input or output, and a separate transport class drives the actual hardware. The codec is therefore
trivially testable, and a new delivery path only needs a new transport.
Protocol summary¶
A frame begins with a start (header) mark, followed by the data bits, and is padded with a trailing
space so that every frame occupies a fixed period. Each interval is a multiple of a single unit
T of 600 microseconds. Marks are bursts of a 40 kHz carrier over the air, or a plain baseband
level over a wire.
Interval |
Duration |
Notes |
|---|---|---|
Start mark |
2400 µs ( |
Begins every frame. |
Logical one mark |
1200 µs ( |
|
Logical zero mark |
600 µs ( |
|
Space |
600 µs ( |
Separates every mark. |
Frame period |
45000 µs |
The trailing space is stretched to reach it. |
Bits are transmitted least-significant first, ordered command, then address, then the extended
field. Three frame widths exist, selected by SIRCSVariant:
Variant |
Payload |
|---|---|
|
7-bit command, 5-bit address. |
|
7-bit command, 8-bit address. |
|
7-bit command, 5-bit address, 8-bit extended field. |
Sony receivers expect a command to be repeated at least three times.
Encoding and decoding¶
SIRCSCodec converts a SIRCSCommand to and
from a tuple of Pulse intervals. It performs no I/O, so it is trivially
testable.
from vcrtool.sansio import SIRCSCodec, SIRCSCommand
codec = SIRCSCodec()
pulses = codec.encode(SIRCSCommand(command=0x15, address=1), repeat=3)
# A pulse is (carrier_on, duration_us). Decoding recovers the command:
assert codec.decode(pulses) == SIRCSCommand(command=0x15, address=1)
decode() inspects only the marks, infers the variant from the
number of data bits, and stops at the next start mark, so a captured repeated signal decodes
directly.
Transport¶
Timing is the hard part of SIRCS. Driving a pin from the host with one write per pulse and a
time.sleep() between writes cannot hold the protocol’s 600 microsecond resolution, because
USB latency and scheduler jitter are both on the order of a millisecond. The package therefore
delegates the timing to a Raspberry Pi Pico over USB serial.
PicoSIRCSTransport holds a SIRCSCodec and
exposes send_command and transmit. The PC keeps all protocol knowledge; the Pico is a dumb,
precisely-timed output peripheral whose firmware clocks the pin edges with a PIO state machine. The
transport serialises the pulse train into a compact message:
Bytes |
Meaning |
|---|---|
|
Synchronisation byte. |
2 (big-endian) |
Pulse count. |
3 per pulse |
Level byte (final pin state), then a 16-bit big-endian duration in microseconds. |
The level is the final pin state, so invert (used for an active-low CONTROL S jack) is
resolved on the PC and the firmware simply holds the pin at the given level.
from vcrtool.sircs import PicoSIRCSTransport
from vcrtool.sansio import SIRCSCommand
pico = PicoSIRCSTransport('/dev/ttyACM0') # invert=True for a direct active-low jack
pico.send_command(SIRCSCommand(command=0x15, address=1))
Wiring¶
The Sony CONTROL S jack takes the demodulated baseband envelope, so no 40 kHz carrier is needed
over a wire. The jack idles high and is pulled low for a mark, and its logic level may differ from
the controller’s, so drive it through a small open-collector stage rather than directly:
controller pin ──1kΩ──>|base NPN collector|──> CONTROL S tip
emitter ──┐
controller GND ───────────────────────────┴──> jack sleeve (common ground)
Set invert=True when wiring directly to the jack (idle high, mark low), or invert=False
through the inverting transistor shown. For infrared instead of a wire, replace the jack with an
infrared LED and its current-limiting resistor and enable the carrier.
Warning
The wired levels and the exact jack pinout vary between devices. Measure the idle voltage on the
tip and confirm it is the CONTROL S jack (not S-LINK or CONTROL A1) before
connecting anything.
Pico firmware sketch¶
The following MicroPython sketch runs on the Pico (save it as main.py). The PIO state machine,
clocked at 1 MHz, makes one instruction tick equal one microsecond, so the timing is exact
regardless of what the interpreter is doing.
import rp2
from machine import Pin
import sys
PIN = 15 # GPIO pin to the transistor or jack
SYNC = 0xA5
# Each FIFO word: bit 0 is the pin level, bits 1..31 are the tick count to hold it.
@rp2.asm_pio(out_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_RIGHT)
def pulse_train():
wrap_target()
pull(block) # OSR <- 32-bit word (blocks until the PC sends one)
out(pins, 1) # drive the pin from the LSB (the level)
out(x, 31) # remaining 31 bits are the duration in ticks
label("hold")
jmp(x_dec, "hold") # hold the level for X + 1 ticks
wrap()
sm = rp2.StateMachine(0, pulse_train, freq=1_000_000, out_base=Pin(PIN))
sm.active(1)
stream = sys.stdin.buffer
while True:
if stream.read(1) != bytes([SYNC]): # resync on the sync byte
continue
header = stream.read(2)
count = (header[0] << 8) | header[1]
for _ in range(count):
level, high, low = stream.read(3)
duration = (high << 8) | low
sm.put((duration << 1) | (level & 1)) # blocks when the FIFO is full, so it self-paces
The pull and out instructions add about three microseconds of overhead per pulse, which is
within the SIRCS tolerance but can be subtracted from duration if tighter timing is wanted. For
production use a dedicated UART or disable the REPL on the serial interface so stray input cannot
desynchronise the stream; the sync byte makes it self-correcting in any case.