Elitech RC-5 (USB 246c:9001, protocol 0x35) HID wire protocol
Reference for maintainers, distilled from the reverse-engineering work of elitech-hid-webui and python-elitech. Line numbers refer to those projects' sources at the time of writing.
Extracted from elitech-hid-webui/elitech-webui.py ("primary", line numbers
below refer to it unless prefixed pe:) and cross-checked against
python-elitech/elitech/src/{frames,commands,device,record,parameters}.py
("python-elitech", pe:). Primary was tested end-to-end on real 246c:9001
hardware with protocol version 0x35 and reproduces flows statically
reverse-engineered from ElitechLog Win V8.0.5.0. python-elitech was tested on
RC-5+ (04d8:3005) and RC-51H; its VID/PID list is at pe:device.py:31-53.
Notation: all multi-byte hex is shown in transmit order. "u16 BE" = big-endian unsigned 16-bit. Config addresses are byte offsets in the device's parameter space (a flat address space read/written by GetParameter/SetParameter).
1. Device discovery and I/O
1.1 Locating the hidraw node (lines 77-146)
- Enumerate
/sys/class/hidraw/hidraw*(sorted). - For each, parse
/sys/class/hidraw/hidrawN/device/ueventasKEY=VALUElines (line 77). HID_IDhas the formBUS:VVVVVVVV:PPPPPPPP(three colon-separated fields). Vendor = last 4 hex chars of field 1, product = last 4 hex chars of field 2, compared upper-case (lines 93-100). Match246C/9001(lines 28-29).- Device node is
/dev/hidrawN. Also read from uevent:HID_NAME(display name, fallback "Elitech RC-5"),HID_UNIQ(USB serial string),HID_PHYS(lines 122-133).
python-elitech instead walks the resolved sysfs path's parents looking for
idVendor/idProduct files (pe:device.py:167-181) and matches against its
own VID/PID table. Either approach yields the same node.
udev rule used by primary (line 2662):
KERNEL=="hidraw*", ATTRS{idVendor}=="246c", ATTRS{idProduct}=="9001", MODE="0660", TAG+="uaccess"
1.2 Report size and report ID
- Every packet written is exactly 64 bytes; the frame is zero-padded to 64
(
packet + bytes(64 - len(packet)), lines 174, 745, 873, 1084, 1342, 1497). - Every read is
os.read(fd, 64)(lines 180, 264, 928, 1011, 1130, 1442, 1720). - There is no HID report-ID byte: byte 0 of the written buffer is the
frame header
0x33itself, and byte 0 of what is read is0x33(response[0:3] != bytes([0x33,0xCC,0x00]), line 191). - python-elitech reads the input/output report sizes from
/sys/class/hidraw/hidrawN/device/report_descriptorand defaults to 64 (pe:device.py:118-134). Nothing in either source uses a report other than 64 bytes.
1.3 Open mode, timeouts, drain
- Primary opens with
os.open(path, O_RDWR | O_NONBLOCK)(line 580 and every other flow) and waits for input withselect.select([fd],[],[],timeout). - python-elitech opens
open(path, 'rb+')(blocking) and does a blockingread(inReportSize)with no timeout (pe:device.py:77, 143-146). drain_hid(fd)(lines 177-184): loopos.read(fd, 64)untilBlockingIOErroror empty read. Primary calls this before every GetParameter (line 235), SetParameter (lines 886, 980) and generic command (line 1098), and once before the record preamble (lines 1362-1389). It deliberately does not drain between GetRecord packets (see §6.5).
Timeouts (seconds, select on the fd):
| Operation | Timeout | Line |
|---|---|---|
| GetParameter | 1.0 | 232 |
| GetParameter during post-record cleanup | 0.75 | 2047 |
| SetParameter | 1.0 | 883, 977 |
| Generic command (Format/Stop) | 2.0 | 1094, 1165 |
| Preamble read packet | 1.0 per packet | 1346 |
| GetRecord (total wait for data report) | 1.5, polled in slices of ≤0.35 | 1733, 1770 |
Sleeps:
| Where | Sleep | Line |
|---|---|---|
| After each preamble packet's response ("factory Thread.Sleep(80)") | 80 ms | 1456 |
| Between retries of the 0x42 capacity/count read | 60 ms | 1246 |
| After last GetRecord, before re-priming the parameter session | 50 ms | 2042 |
| After FormatCommand, keep fd open ("factory Thread.Sleep(500)") | 550 ms | 2556 |
| After closing fd post-configure, before reopening to verify | 1000 ms | 2564 |
No sleep is inserted between ordinary GetParameter requests.
2. Frame format
2.1 Request frame (all operations)
idx 0 1 2 3 4 5 6 7 8 9 10 11..
33 CC 00 LEN OP_LO OP_HI 00 OFF_MID OFF_LO OFF_HI N DATA... CKSUM
| Byte | Meaning |
|---|---|
| 0-2 | Constant header 33 CC 00 |
| 3 | LEN = total frame length in bytes including the checksum = 11 + len(DATA) + 1 (lines 736, 1081; pe:frames.py:126) |
| 4-5 | Operation code, little-endian u16 (op & 0xFF, op >> 8; lines 1062-1063, pe:frames.py:125) |
| 6 | Always 0x00 |
| 7 | (offset >> 8) & 0xFF — middle byte |
| 8 | offset & 0xFF — low byte |
| 9 | (offset >> 16) & 0xFF — high byte |
| 10 | N: for reads, number of bytes (GetParameter) or records (GetRecord) requested; for writes, declared data length |
| 11.. | DATA (absent for reads) |
| last | CKSUM = sum(frame[0 : LEN-1]) & 0xFF — byte-wise sum modulo 256 over every byte before it, header included |
The offset byte order [mid, low, high] is deliberate and identical in both
sources (lines 165-167; pe:frames.py:125). Offset range 0..0xFFFFFF.
Padding: append zero bytes to reach 64. The zeros are not included in the checksum (the checksum is appended before padding).
Data length limit: primary's builders cap at 52 data bytes (1 <= len <= 52,
lines 155, 725); python-elitech also caps at 52 (pe:frames.py:112-118).
52 data bytes yields exactly a 64-byte frame (11 + 52 + 1).
Read frames (no DATA) are always 12 bytes with LEN = 0x0C.
2.2 Response frame
Same layout. LEN at byte 3, op at byte 4, offset in bytes 7-9 (decode
(b9 << 16) | (b7 << 8) | b8, line 218), data length at byte 10, data from
byte 11, checksum at index LEN-1 = sum(frame[0:LEN-1]) & 0xFF
(lines 203-211). Trailing bytes after LEN up to 64 are padding and ignored.
Primary validation for a GetParameter response (parse_response, lines
187-225): len >= 12, header 33 CC 00, byte4 == 0x03, LEN >= 12,
LEN <= bytes read, checksum matches, 11 + N <= LEN - 1.
python-elitech is looser: it warns instead of failing on offset/length/checksum
mismatch and re-slices from the offset the device reported (pe:frames.py: 153-187). Primary requires the requested range to be contained in the returned
range and slices out the requested bytes (lines 269-283) — i.e. the device
may return a wider range than requested and an implementation must slice
by offset rather than assume data[0] is the requested address.
2.3 Worked frames (test vectors; 64-byte zero padding omitted)
| Purpose | Frame |
|---|---|
| GetParameter 0x000000, 14 bytes | 33 CC 00 0C 03 00 00 00 00 00 0E 1C |
| GetParameter 0x000094, 2 bytes | 33 CC 00 0C 03 00 00 00 94 00 02 A4 |
| GetParameter 0x00012C, 48 bytes | 33 CC 00 0C 03 00 00 01 2C 00 30 6B |
| GetRecord from record 0, 6 records | 33 CC 00 0C 01 00 00 00 00 00 06 12 |
| GetRecord from record 6, 6 records | 33 CC 00 0C 01 00 00 00 06 00 06 18 |
FormatCommand (0x02C0), offset 0, data 00 |
33 CC 00 0D C0 02 00 00 00 00 01 00 CF |
StopCommand (0x03C0), offset 0, data 00 |
33 CC 00 0D C0 03 00 00 00 00 01 00 D0 |
| GetRecord ACK-only response observed on 0x35 hardware (line 1506) | 33 CC 00 0C 01 00 00 00 00 00 00 0C then zeros |
Note the ACK has N = 0 and LEN = 0x0C; its checksum 0x0C is consistent
with the rule above.
3. Commands
| Name | Op (u16) | Bytes 4-5 | Request | Response | Purpose |
|---|---|---|---|---|---|
| GetRecord | 0x0001 | 01 00 |
offset = record index (0-based), N = record count (primary always 6) | data = N × 8-byte records at byte 11 (see §6); may be preceded by an ACK-only report | Read stored measurements |
| GetParameter | 0x0003 | 03 00 |
offset = parameter address, N = byte count (1..52) | data = N bytes (or a superset range) at byte 11 | Read config/info |
| SetParameter | 0x0004 | 04 00 |
offset, N = declared length, DATA | N = 1, byte 11 = status, 1 = success (lines 749-804) | Write config |
| "Read 0x05" | 0x0005 | 05 00 |
offset, N = length, no data | read and discarded | Only used inside the factory connect preamble (§3.3). Semantics unknown; primary comments only "the factory program sends several 0x0005 read packets on every first connection" (lines 1277-1280). |
| FormatCommand | 0x02C0 | C0 02 |
offset 0, DATA = 00 |
raw response, not parsed (line 1152-1166) | Erases stored records; sent at the end of the factory configure flow |
| StopCommand | 0x03C0 | C0 03 |
offset 0, DATA = 00 |
raw, not parsed (pe:commands.py:633-636) |
Stop recording. python-elitech only; primary never sends it (it is listed in the comment at line 1046 but unused). |
Primary uses no other opcode. python-elitech's enum is exactly these five
minus 0x0005 (pe:frames.py:105-110).
3.1 SetParameter response detail (lines 749-804)
33 CC 00 0D 04 00 00 OFF_MID OFF_LO OFF_HI 01 STATUS CKSUM
Validation: len >= 13, header, byte4 == 0x04, 13 <= LEN <= bytes read,
checksum ok, response offset == requested offset, byte10 == 1,
byte11 == 1 (any other status = device rejected the write).
3.2 Session prime ("prime_parameter_session", lines 303-376)
Two ordinary GetParameter reads, performed at the start of every high-level operation and again at the end of a record download:
- GetParameter offset 0x000000, 14 bytes (identity)
- GetParameter offset 0x000094, 2 bytes (protocol)
Rationale quoted from the source (lines 309-321, translated): on the 246c:9001 / protocol 0x35 device, after the GetRecord download flow some short GetParameter reads temporarily return only 0xFF sentinel data; performing these two reads consistently restores normal parameter reading. It is read-only.
3.3 Factory connect preamble (lines 1282-1303, 1344-1458)
Sent once before the first GetRecord, after one explicit drain. 17 read-type
frames, each a 12-byte read frame (§2.1, LEN = 0x0C) with the given op,
waiting for one response report (timeout 1.0 s) then sleeping 80 ms. The
responses are logged and discarded.
| # | Op | Offset | N |
|---|---|---|---|
| 1 | 0x03 | 0x000000 | 0x30 |
| 2 | 0x03 | 0x000030 | 0x30 |
| 3 | 0x03 | 0x000060 | 0x30 |
| 4 | 0x03 | 0x000090 | 0x08 |
| 5 | 0x03 | 0x000098 | 0x34 |
| 6 | 0x03 | 0x0000CC | 0x30 |
| 7 | 0x05 | 0x000000 | 0x20 |
| 8 | 0x05 | 0x000070 | 0x10 |
| 9 | 0x05 | 0x000080 | 0x30 |
| 10 | 0x05 | 0x0000B0 | 0x30 |
| 11 | 0x05 | 0x0000E0 | 0x30 |
| 12 | 0x05 | 0x000110 | 0x30 |
| 13 | 0x05 | 0x000140 | 0x30 |
| 14 | 0x05 | 0x000020 | 0x30 |
| 15 | 0x05 | 0x000050 | 0x20 |
| 16 | 0x03 | 0x00012C | 0x30 |
| 17 | 0x03 | 0x0000FD | 0x30 |
Primary's comment (lines 1275-1280): "ElitechLog Win V8.0.5.0 DataFactory.GetListForParameter() exact, statically reverse-engineered 17-packet connect/read preamble … these are read operations; v16 reproduces them before record download." python-elitech has no preamble and reads records directly; whether the preamble is strictly required on 0x35 hardware is not established in the source — primary introduced it in v16 while diagnosing failed record reads and kept it.
4. Device info decoding (lines 578-706)
All values are read with GetParameter. Reads made by read_device_info:
| Address | Len | Field |
|---|---|---|
| 0x00 | 14 | identity |
| 0x94 | 2 | protocol |
| 0x20 | 1 | start-mode byte |
| 0x25 | 1 | device state |
| 0x26 | 2 | actual stop mode, battery |
| 0x28 | 7 | configuration time |
| 0x30 | 15 | start time, pad, stop time |
| 0x42 | 10 | capacity + record count |
| 0x4C | 2 | sampling interval |
| 0x88 | 7 | device time |
Decoding:
| Field | Address | Encoding | Lines |
|---|---|---|---|
| Model code | 0x00-0x01 | u16 BE | 596 |
| Serial number | 0x02-0x0D | 12 ASCII bytes, strip \0, trim |
598-603 |
| Protocol version | 0x95 (byte [1] of the 2-byte read at 0x94) | u8; tested device = 0x35 | 605; pe:parameters.py:721 agrees (0x95) |
| Capacity (records) | 0x42-0x45 | u32 BE; see stop-state normalisation below | 426-505 |
| Record count | protocol >= 0x24: 0x46-0x49 u32 BE; else 0x48-0x49 u16 BE | 474-478; pe:parameters.py:708-709 (same rule, ≥0x24 variant commented out) |
|
| Sampling interval | 0x4C-0x4D | u16 BE, units of 10 s; 0 or FF FF = invalid | 508-528; pe:parameters.py:712 (TimeSpanParameter ×10) |
| Start mode byte | 0x20 | bits 0-2 mode: 000 Immediate, 001 Manual, 010 Timer, 111 MAX; bit 3 = button stop enabled; bit 4 = software stop enabled | 543-551, 683-696; pe:parameters.py:674-676 |
| Device state | 0x25 | raw u8 (undecoded; python-elitech only defines MAX=0x7F) | 613 |
| Actual stop mode | 0x26 bits 0-2 | 000 Manual, 011 Temporary, 111 none/MAX | 554-561 |
| Battery | 0x27 low nibble | 0..15; whole byte 0xFF = unknown | 618-622 |
| Configuration time | 0x28-0x2E | 7-byte datetime (§4.1) | 624 |
| Start time | 0x30-0x36 | 7-byte datetime | 625 |
| (pad) | 0x37 | ignored | pe:parameters.py:703 |
| Stop time | 0x38-0x3E | 7-byte datetime | 626 |
| Device time | 0x88-0x8E | 7-byte datetime | 627 |
4.1 7-byte datetime (lines 379-423, 943-970)
[0] year - 2000 [1] month [2] day-of-week [3] day [4] hour [5] minute [6] second
Byte 2 is .NET DayOfWeek (Sunday = 0 … Saturday = 6) when written by the
factory software; primary's encode_official_elitech_datetime writes it
(line 960: (python_weekday + 1) % 7). It is ignored on decode.
python-elitech writes 0x00 there (pe:parameters.py:181) — noted by primary as
a divergence from the factory program (lines 951-955).
Decode rules: all-zero → no value; all-0xFF → no value; any of bytes 0,1,3,4,5,6 == 0xFF → no value (stopped device fills runtime fields with 0xFF); otherwise construct the date (invalid calendar date → no value).
4.2 Capacity/count sentinel normalisation (lines 426-505)
Observed 10-byte block at 0x42 while running: 00 00 7d 00 00 00 00 1b 00 00
→ capacity 32000, count 27. Observed after a stop:
ff ff 7d 00 00 00 00 1b ff ff. Rules:
- capacity bytes 0x42-0x45 ==
FF FF FF FF→ unknown. - bytes 0x42-0x43 ==
FF FFand 0x44-0x45 !=FF FF→ capacity = u16 BE of 0x44-0x45. - otherwise capacity = u32 BE.
- record-count bytes (per protocol rule above) all 0xFF → unknown.
- count > capacity → treat both as unknown.
read_record_count_from_fd (lines 1186-1255) reads 0x94/2 once, then reads
0x42/10 up to 3 times (60 ms apart) until both values decode; otherwise it
fails rather than proceed with a destructive operation.
5. Config block layout
Addresses known from python-elitech's parameter table
(pe:parameters.py:657-722) plus primary. Types: bit fields are within the
byte at the given address, bit 0 = LSB. "W" = writable per python-elitech;
"I" = "immutable" (python-elitech zeroes or preserves it on write, see §7).
| Address | Size | Field | Encoding | W |
|---|---|---|---|---|
| 0x00 | 2 | model | u16 BE | – |
| 0x02 | 12 | serial-number | ASCII, NUL padded | – |
| 0x0E | 2 | ignored | 00 00 |
|
| 0x10 | 13 | travel-number | ASCII, NUL padded | W |
| 0x1D | 1 | pdf-language | 0x00 en, 0x01 zh, 0x02 es, 0xFF MAX | W |
| 0x1E | bits 0-3 | product-properties | nibble | – |
| 0x1E | bit 4 | light-on | W | |
| 0x1E | bit 7 | allow-cycle (overwrite when full) | noted "does not work on RC-5+" | W |
| 0x1F | 1 | firmware-version | u8 | – I |
| 0x20 | bits 0-2 | start-mode | 000 Immediate, 001 Manual, 010 Timer, 111 MAX | W |
| 0x20 | bit 3 | button-stop | 1 = device can be stopped by button | W |
| 0x20 | bit 4 | software-stop | 1 = can be stopped by software | W |
| 0x20 | bit 5 | ignored | 0 | |
| 0x20 | bit 6 | repeat | new recording without reading previous | W |
| 0x20 | bit 7 | pause-allowed | W | |
| 0x21 | bit 0 | pdf-password-protected | W | |
| 0x21 | bit 1 | temperature-sensor-location | 0 Internal, 1 External | W |
| 0x21 | bit 2 | humidity-sensor-location | 0 Internal, 1 External | W |
| 0x21 | bit 3 | temperature-sensor-unit | 0 Celsius, 1 Fahrenheit | W |
| 0x21 | bit 4 | temperature-alarm-mode | marked "TODO two bits" (AlarmModes: 00 none, 01 single, 10 multiple) | W |
| 0x21 | bit 6 | humidity-alarm-mode | same caveat | W |
| 0x22 | bit 0 | high-temperature-alarm3-type | untested | W |
| 0x22 | bit 1 | high-temperature-alarm2-type | untested | W |
| 0x22 | bit 2 | high-temperature-alarm1-type | untested | W |
| 0x22 | bit 3 | low-temperature-alarm1-type | untested | W |
| 0x22 | bit 4 | low-temperature-alarm2-type | untested | W |
| 0x22 | bit 5 | low-temperature-alarm3-type | untested | W |
| 0x22 | bit 6 | high-humidity-alarm-type | untested | W |
| 0x22 | bit 7 | low-humidity-alarm-type | untested | W |
| 0x23 | bits 0-1 | exact-sensor-type | 00 none, 01 glycol bottle, 11 MAX | W |
| 0x23 | bits 4-7 | light-intensity | nibble | W |
| 0x24 | 12 (bytes 0x24 & 0x2F) | timezone | hours in 0x24 bits 0-4 (h > 12 → negative, -(24-h)); minutes in 0x2F | W |
| 0x25 | bits 0-6 | device-state | raw | – I |
| 0x26 | bits 0-2 | actual-stop-mode | 000 Manual, 011 Temporary, 111 MAX | – I |
| 0x26 | bit 3 | temporary-pdf | W | |
| 0x26 | bit 4 | display-time | "no effect" | W |
| 0x27 | bits 0-3 | battery-level | 0..15 | – I |
| 0x27 | bit 4 | csv | "does not work on RC-5+" | W |
| 0x28 | 7 | configuration-time | datetime §4.1 | W |
| 0x2F | 1 | timezone minutes | see 0x24 | W |
| 0x30 | 7 | start-time | datetime | – |
| 0x37 | 1 | ignored | ||
| 0x38 | 7 | stop-time | datetime | – |
| 0x3F | 1 | ignored | ||
| 0x40 | 2 | start-delay | u16 BE, delay before start in Timer mode (untested) | W |
| 0x42 | 4 | device-capacity | u32 BE records | – |
| 0x46 | 4 | record-number (protocol ≥ 0x24) | u32 BE | – |
| 0x48 | 2 | record-number (protocol < 0x24) | u16 BE | – |
| 0x4A | 2 | ignored | ||
| 0x4C | 2 | interval | u16 BE × 10 s | W |
| 0x80 | 6 | password (PDF) | ASCII | W |
| 0x88 | 7 | device-time | datetime | – I |
| 0x95 | 1 | protocol-version | u8 | – |
Alarm threshold values are not located in either source. python-elitech
defines a FloatParameter encoding (u16 BE; 0xFFFF = NaN; < 0x8000 →
value/10; else -(v - 0x8000)/10; pe:parameters.py:460-490) but no
parameter in its table uses it, so the alarm limit addresses are unknown.
Nothing about alarm limits, units C/F beyond the bit at 0x21.3, or the
0x98..0x12B region's contents is decoded in either source; primary reads and
rewrites those regions opaquely.
Primary's only decoded config fields are: 0x20 (mode + stop bits), 0x28
(config time), 0x4C (interval), 0x88 (device time), 0x42 block, 0x95
(read_device_config, lines 2112-2225). Interval fallback: if 0x4C/2 returns
FF FF, read 0x30/0x30 and take bytes [0x1C:0x1E] of it (same address via
a wider read; lines 2153-2172).
6. Records
6.1 Storage and addressing
- Record length: 8 bytes (line 1262;
pe:record.py:22). - GetRecord
offset= record index (0-based),N= number of records (line 1461-1497: "offset = record ordinal (0, 6, 12, …); standard 8-byte records: count = 6; operation = 0x0001; byte[5] = 0 for normal record store"). - Records per packet: 6 (line 1263). python-elitech derives the same:
n = 51 // 8 = 6(pe:commands.py:525). - Response: header as §2.2 with op 0x01; record data starts at byte 11
(line 1264),
6 × 8 = 48bytes, so a full data report hasLEN = 0x3C. Primary does not check the echoed offset/count bytes 7-10 of a data report (validate_record_report_header, lines 1530-1547, only checks header and op). python-elitech interprets bytes 7-10 of the response as the returned record offset/count and re-slices if they differ (pe:frames.py:181-184). - Timestamps are per record, encoded inside each 8-byte record. Nothing is derived from start time + index × interval.
6.2 Record bit layout (lines 1549-1668; pe:record.py:75-113)
The 8 bytes form a little-endian 64-bit word q (byte 0 = bits 0-7).
| Byte | Bits | Field |
|---|---|---|
| 0 | 0-7 | flags (§6.3) |
| 1 | 0 | ignored (python-elitech warns if set) |
| 1 | 1 | temperature extension bit (protocol ≥ 0x23) — see note |
| 1 | 2-7 | second (0-59) |
| 2 | 0-6 | year − 2000 |
| 2 | 7 | month bit 0 |
| 3 | 0-2 | month bits 1-3 (month = 4 bits total) |
| 3 | 3-7 | day |
| 4 | 0-4 | hour |
| 4 | 5-7 | temperature bits 0-2 |
| 5 | 0-7 | temperature bits 3-10 |
| 6 | 0-5 | minute |
| 6 | 6-7 | humidity bits 0-1 (python-elitech only) |
| 7 | 0-7 | humidity bits 2-9 (python-elitech only) |
Primary's explicit expressions (lines 1583-1607):
second = (b1 >> 2) & 0x3F
year = 2000 + (b2 & 0x7F)
month = ((b3 & 0x07) << 1) | ((b2 >> 7) & 1)
day = (b3 >> 3) & 0x1F
hour = b4 & 0x1F
minute = b6 & 0x3F
temp_raw (protocol >= 0x23) = (((b1 >> 1) & 1) << 11) | (b5 << 3) | (b4 >> 5)
temp_raw (protocol < 0x23) = (b5 << 3) | (b4 >> 5)
temperature = temp_raw / 10.0, negated if flags & 0x08 (Sign1)
Units: tenths of a degree in whatever unit the device records; neither source consults the C/F bit at 0x21.3 when decoding.
Extension-bit divergence. Primary places bit 1 of byte 1 at temperature
bit 11 (<< 11, line 1596). python-elitech ORs it into bit 10
(temperature |= ((q >> 9) & 0x01) << 10, pe:record.py:94) — which
collides with bit 47 = byte 5 bit 7 already in the 11-bit field, so
python-elitech's version is self-inconsistent. Use primary's (12-bit) form.
Neither source shows a captured record with this bit set.
Humidity (python-elitech only, pe:record.py:86, 103-106, 112):
hum_raw = (b6 >> 6) | (b7 << 2) (10 bits); value = raw/10 %, negated if
flags & 0x40 (Sign2); raw 0 → no humidity. Primary ignores humidity on the
temperature-only RC-5 and treats bits 6-7 of byte 0 differently (§6.3).
Sentinel: a record of FF FF FF FF FF FF FF FF means "no data" (line 1571;
pe:record.py:83).
6.3 Flags (byte 0)
| Bit | Mask | Name | Meaning |
|---|---|---|---|
| 0 | 0x01 | Mark | user mark |
| 1 | 0x02 | Pause | record is a pause event |
| 2 | 0x04 | Stop | record is a stop event |
| 3 | 0x08 | Sign1 | temperature negative |
| 4 | 0x10 | Light | light event |
| 5 | 0x20 | Vibration | vibration event |
| 6 | 0x40 | Sign2 | protocol < 0x35: humidity negative (python-elitech) |
| 7 | 0x80 | Error | protocol < 0x35: error record |
Protocol ≥ 0x35 (primary, lines 1555-1567, 1577-1582): normal measurement
records have byte 0 == 0xC0. Bits 6-7 are therefore treated as a
record-format marker and masked off (flags = b0 & 0x3F); only bits 0-5 are
interpreted. Interpreting 0xC0 with the old map would label every record
"Error".
Status decision table (lines 1626-1648):
| Pause (0x02) | Stop (0x04) | Error (0x80) and protocol < 0x35 | Status | Temperature reported |
|---|---|---|---|---|
| 1 | – | – | Pause | no |
| 0 | 1 | – | Stop | no |
| 0 | 0 | 1 | Error | no |
| 0 | 0 | 0 | Measurement | yes |
Mark/Light/Vibration are additive labels and do not change status.
6.4 Download algorithm (read_device_records, lines 1856-2108)
open fd (O_RDWR|O_NONBLOCK)
prime_parameter_session() # 0x00/14 then 0x94/2
protocol, capacity, count = read_record_count_from_fd() # 0x94/2, 0x42/10 (≤3 tries)
if count == 0: done
wanted_start = 0 if limit == 0 else max(0, count - limit) # "last N"
packet_offset = (wanted_start // 6) * 6
run_official_parameter_preamble() # 17 frames, 80 ms apart
while packet_offset < count:
report = get_record_data_report(packet_offset) # §6.5
valid = min(6, count - packet_offset)
for i in 0..valid-1:
raw = report[11 + 8*i : 19 + 8*i]
parse; keep if packet_offset + i >= wanted_start
packet_offset += 6
sleep 50 ms
prime_parameter_session(timeout 0.75) # cleanup; failure is non-fatal
close fd
Records beyond count inside the last packet are ignored, not parsed.
Record numbering shown to users is 1-based (record_index + 1).
python-elitech instead reads open-ended and stops when an entire 6-record
packet is all 0xFF (pe:commands.py:538); it never consults the count field.
On 0x35 hardware the count field is authoritative in primary.
6.5 Per-packet exchange (get_record_data_report, lines 1729-1853)
- Write the 64-byte GetRecord frame. Do not drain first.
- Loop until 1.5 s elapsed,
selectin slices of ≤ 0.35 s:- Read 64 bytes. Skip (log) any report that is not 64 bytes or fails header/op check.
- If the report is ACK-only (
33 CC 00 0C 01 00 00 00 00 00 00 0Cand all bytes 12..63 zero; lines 1499-1527) — notesaw_ack, keep waiting, do not resend. - Otherwise count how many of the 6 slots at byte 11 parse to a valid non-sentinel record (lines 1671-1704). If ≥ 1, return this report. If 0, log and keep waiting.
- Timeout → error (distinguishing "ACK but no data" from "no response").
Primary's comments (lines 1737-1748): on this hardware the device sends an immediate ACK-only report and the data report a few ms later; draining before the next request silently discarded that late report in v15, so v16 stopped draining between record packets.
7. Configuration write (factory save flow, lines 2228-2660)
Destructive: the flow ends with FormatCommand (0x02C0), which erases the
record store. Primary refuses unless record_count == 0 or the caller has
explicitly confirmed data loss and the confirmed count still matches the
device's count (lines 2413-2440). Also refuses if protocol < 0x16
(lines 2444-2448).
Steps (apply_device_config, lines 2320-2660):
-
Open fd;
prime_parameter_session();read_record_count_from_fd(). -
Read the six blocks with GetParameter (lines 2241-2248, 2452-2457):
Block Offset Read length 0 0x00 0x30 1 0x30 0x30 2 0x60 0x30 3 0x98 0x34 4 0xCC 0x30 5 0xFD 0x2F Not covered: 0x90-0x97 (protocol version lives here) and 0xFC.
-
Patch in memory (
_patch_compat_config_blocks, lines 2251-2317):0x20 = (old & 0b11100000) | 0b001(Manual start),| 0x08if button stop,| 0x10if software stop. Primary always writes Manual mode.- if sync-clock:
0x28..0x2E = nowin factory datetime form (§4.1, with day-of-week). 0x4C..0x4D = u16 BE (interval_seconds / 10). Interval must be a whole number of minutes 1..1440 in primary's UI; the wire constraint is a multiple of 10 s, ≤ 0xFFFF × 10 s.0x88..0x8E = 00 00 00 00 00 00 00(factory zeroes device-time in the 0x60 packet; lines 2307-2313). Primary never writes a real value to 0x88; "sync clock" means writing configuration-time only.
-
Send SetParameter frames in this exact order (lines 2496-2540), each followed by waiting for and validating the status response (§3.1):
# Offset Data bytes sent Byte 10 (declared) Byte 3 (LEN) 1 0x00 48 0x30 0x3C 2 0x30 48 0x30 0x3C 3 0x60 47 (0x60..0x8E) 0x30 0x3B 4 0x98 52 0x34 0x40 5 0xCC 48 0x30 0x3C 6 0xFD 47 0x2F 0x3B Packet 3 reproduces a factory off-by-one byte-for-byte: declared length 0x30 but only 47 payload bytes, frame length 59, checksum at index 58 (lines 807-873, 2511-2520). Address 0x8F is therefore never written.
-
Send FormatCommand
33 CC 00 0D C0 02 00 00 00 00 01 00 CF, wait up to 2 s for a response, ignore its contents (lines 1152-1166, 2542-2549). -
Sleep 550 ms with the fd still open, then close (lines 2551-2559).
-
Sleep 1 s, reopen, verify by GetParameter: 0x20/1, 0x4C/2, 0x28/7, 0x88/7 (lines 2564-2581). Checks: interval raw equal;
0x20 & 0x1Fequal; config time bytes equal (if sync-clock). Device time is logged only.
Primary's own note (README, "Adatbiztonság"): after this flow the device does not start recording; on the tested RC-5 in Manual mode the user long-presses the ▶ button. Earlier "plain SetParameter" attempts appeared to succeed but reverted after a physical replug; the factory sequence (with FormatCommand and the post-format hold) is what persisted.
7.1 python-elitech differences on write
- Same six compat ranges (
pe:commands.py:337-344) but block 0x60 is written as a normal 48-byte frame, and no FormatCommand follows. - Only writes to non-compat, minimal ranges by default; compat mode is opt-in.
- Sets configuration-time to now in compat mode (
pe:commands.py:388-392). - Zeroes "immutable" fields with byte/datetime types on write: firmware
version 0x1F and device time 0x88..0x8E (
pe:commands.py:376-381withpe:parameters.py:157, 181); enum/nibble immutables (0x25, 0x26, 0x27) keep their old bytes. - Writes 0x00 as the datetime day-of-week byte.
- GetParameter quirk: a 1-byte read is turned into a 2-byte read at
offset - 1(pe:frames.py:189-192). Primary issues 1-byte reads (0x20/1, 0x25/1) and they work on 0x35 hardware. - SetParameter to python-elitech splits ranges to avoid rewriting
configuration-time unless asked (
pe:commands.py:393-398).
8. Quirks, ordering constraints, destructive operations
- Destructive: FormatCommand 0x02C0 erases records. SetParameter itself is not observed to erase, but primary always follows it with Format because that is the only sequence that persisted across replug on this firmware. StopCommand 0x03C0 stops recording (python-elitech only, effect on 0x35 untested).
- Prime the parameter session (0x00/14, 0x94/2) at the start of every operation, and again after a record download; otherwise short reads may return all-0xFF blocks (lines 303-321, 2036-2058).
- Treat all-0xFF fields as "unknown", never as values (§4.1, §4.2). Stopped devices fill several runtime fields with 0xFF.
- Read 0x42/10 up to three times before trusting capacity/count; refuse destructive work if it never decodes.
- Drain stale reports before GetParameter/SetParameter/Format, but never between GetRecord request and its data report.
- Handle an ACK-only report before the GetRecord data report; keep waiting on the same request.
- 80 ms pause after each preamble packet; 550 ms open-hold after Format; 1 s before reopening.
- The device may answer a GetParameter with a wider range than requested; slice by returned offset.
- Only one HID transaction at a time (primary serialises with a global lock, line 34).
9. Minimal sequences
9.1 Minimal read (info + records)
fd = open("/dev/hidrawN", O_RDWR|O_NONBLOCK)
# prime
get(0x000000, 14) -> model = u16be[0:2]; serial = ascii[2:14]
get(0x000094, 2) -> protocol = [1]
# info
get(0x42, 10) -> capacity, count (retry ≤3, 60 ms apart, sentinel rules)
get(0x4C, 2) -> interval = u16be * 10 s
get(0x20, 1) -> start byte
get(0x28, 7), get(0x30, 15), get(0x88, 7) -> datetimes
# records
if count > 0:
drain
for (op, off, n) in PREAMBLE: write read-frame(op, off, n); read 1 report (1 s); sleep 80 ms
for off in 0, 6, 12, ... < count:
write GetRecord(off, 6) # no drain
wait ≤1.5 s: skip ACK-only; accept first report with ≥1 plausible record
for i in 0 .. min(6, count-off)-1: parse report[11+8i : 19+8i]
sleep 50 ms
get(0x000000, 14); get(0x000094, 2) # re-prime
close(fd)
where get(off, n) = drain; write 33 CC 00 0C 03 00 00 mid lo hi n cksum
(+pad); select ≤1 s; read 64; validate (§2.2); slice [off-resp_off : +n].
9.2 Minimal configure (interval, stop bits, clock) — erases records
fd = open(...)
prime; protocol, capacity, count = read_record_count()
require count == 0 or explicit confirmation of count
blocks = { off: get(off, len) for (off,len) in [(0x00,0x30),(0x30,0x30),(0x60,0x30),(0x98,0x34),(0xCC,0x30),(0xFD,0x2F)] }
blocks[0x00][0x20] = (old & 0xE0) | 0x01 | (button?0x08:0) | (software?0x10:0)
blocks[0x00][0x28:0x2F] = [yy, mm, dow, dd, hh, mi, ss] # if syncing
blocks[0x30][0x1C:0x1E] = u16be(interval_s / 10)
blocks[0x60][0x28:0x2F] = 00*7 # 0x88..0x8E
set(0x00, blocks[0x00]) # 48 B, LEN 0x3C
set(0x30, blocks[0x30]) # 48 B, LEN 0x3C
set(0x60, blocks[0x60][:47], declared=0x30)# 47 B, LEN 0x3B
set(0x98, blocks[0x98]) # 52 B, LEN 0x40
set(0xCC, blocks[0xCC]) # 48 B, LEN 0x3C
set(0xFD, blocks[0xFD]) # 47 B, LEN 0x3B
write 33 CC 00 0D C0 02 00 00 00 00 01 00 CF ; read (≤2 s, ignore)
sleep 550 ms; close(fd)
sleep 1 s; reopen; get(0x20,1), get(0x4C,2), get(0x28,7) and compare; close
set(off, data) = drain; write 33 CC 00 LEN 04 00 00 mid lo hi N data cksum
(+pad); select ≤1 s; read 64; require byte4==0x04, echoed offset, byte10==1,
byte11==1.
10. Findings on protocol 0x54 (model 0x0134, temperature + humidity)
Observed on a logger reporting model 0x0134, protocol 0x54, serial
EL26.... Everything in §1-§5 and §7 behaved as described (identity,
capacity/count, interval, start byte, configure flow, verification).
Differences:
-
Records are 4 bytes:
TT TT HH HH, each a big-endian value in tenths (temperature °C, relative humidity %).0xFFFF= no reading. The sign is bit 12 (0x1000) with the magnitude in bits 0-11: a logger going into a freezer recorded00 07,00 01,10 05,10 0B,10 10= 0.7, 0.1, -0.5, -1.1, -1.6. This differs from the status space, whose min/mean fields use bit 15 (80 BC= -18.8). A GetRecord reply carries 6 × 4 = 24 data bytes (LEN 0x24). Infer the width fromlen(data) / N. -
No per-record timestamp. Time = start time + index × interval. The parameter-space start time at 0x30 keeps the factory placeholder
2012-01-01 01:01:01while recording; the real start time lives in the status space. -
The op 0x05 "reads" are a status space, not opaque. Offsets seen:
Offset Bytes Meaning 0x00 1 01while recording0x08 7 start time (datetime §4.1, day-of-week 0) 0x8A 2 record count, little-endian (verified < 256 only) 0x90 2 max temperature (tenths) 0x98 2 min temperature 0xA0 2 mean temperature (tracks the running mean) 0xA2 2 equal to 0xA0 so far; possibly mean kinetic temperature 0xB0 2 max humidity 0xB8 2 min humidity 0xC0 2 mean humidity 0x109, 0x159 2 22 01, unknownThere is no "current reading" field; the last record is the current value.
-
The count in the parameter space (0x46) is live and matches 0x8A.
-
StopCommand 0x03C0 gets no reply from an idle logger within 2 s.
-
The logger does not record while on USB; recording starts with a 10 s button press after unplugging, even in Immediate start mode.