REV Hub Serial Protocol (RHSP)

Complete wire-protocol reference for the REV Robotics Expansion/Control Hub, with a machine-readable command catalogue.

Source repository: League-Robotics/python-serial-hub-control — pure-Python driver plus the machine-readable command catalogue protocol.json (also mirrored at docs/rhsp-protocol.json).

REV Hub Serial Protocol (RHSP)

This document describes the wire protocol and software interface used by the rhsp package (src/rhsp/) to communicate with a REV Robotics Expansion / Control Hub over a USB serial connection. It was reconstructed by reviewing the Python source, principally src/rhsp/internal/messages.py (the message definitions) and src/rhsp/client.py (the framing, send/receive, and checksum logic).

It is intended to be complete enough for an agent to reimplement the protocol in another language/environment. It ships with two companion artifacts in this directory:

  • rhsp-protocol.json — a machine-readable catalogue of every command and response, with field layouts, byte offsets, enums, and sequencing recipes (§10).
  • generate_protocol_json.py — the generator that introspects the live message classes to (re)produce that JSON, so it can never drift from the implementation.

§9 covers the ordering/sequencing constraints (what must be set up before what).


Sources & references

The REV Hub Serial Protocol has no single published specification; the authoritative description is the set of implementations below. They are listed here, grouped by language, with the specific documents/files that define the wire format. This document was written against the Python source and cross-checked against the C and Java sources.

Python implementations

  • League-Robotics/python-serial-hub-control (this repo) — the rhsp package. Wire-format definitions: src/rhsp/internal/messages.py; framing / send-receive / checksum: src/rhsp/client.py.
  • unofficial-rev-port/SerialHubControl — most likely direct upstream of the rhsp package.
  • unofficial-rev-port/REVHubInterface — GUI tool sharing the same message layer. The definitions originate from a decompiled REVmessages.pyc (note the # okay decompiling REVmessages.pyc marker still present in messages.py).
  • This document’s machine-readable companion: rhsp-protocol.json and its generator generate_protocol_json.py.

C / C++ / Node implementation (REV official — librhsp)

  • REVrobotics/node-rhsplib — REV’s official C library (librhsp) plus a Node.js binding. The C library is the canonical reference for framing, checksum, and command encoding, at packages/rhsplib/librhsp/. Key documents:
    • src/packet.c + include/internal/packet.h — packet framing, length, checksum.
    • include/internal/RhspRxStates.h — the receive (parser) state machine.
    • src/command.c + include/internal/command.h — request/response dispatch and retries.
    • src/rhsp.c + include/rhsp/rhsp.h — top-level API; include/rhsp/errors.h — NACK/error codes.
    • Per-subsystem: motor.c, servo.c, dio.c, i2c.c, deviceControl.c, module.c, revhub.c (with matching headers under include/rhsp/).
    • Transport: src/arch/{linux,mac,win}/serial.c + include/rhsp/serial.h.
    • Node/C++ binding: packages/rhsplib/src/RevHubWrapper.cc and packages/rhsplib/lib/binding.ts (the API surface cross-checked in §11; reviewed locally as rhsplib-old).

Java implementation (FTC SDK / “Lynx” — ground truth)

  • OpenFTC/Extracted-RC — the extracted FTC Robot Controller SDK. The original, most complete implementation, under Hardware/src/main/java/com/qualcomm/hardware/lynx/ (“Lynx” is REV’s internal name for the hub). Key documents:
    • commands/LynxDatagram.java — on-wire packet framing, length, and checksum.
    • commands/LynxMessage.java, LynxCommand.java, LynxResponse.java, LynxRespondable.java — message base classes and command/response numbering.
    • commands/LynxInterface.java, LynxInterfaceCommand.java, LynxInterfaceResponse.java — the QueryInterface mechanism and dynamically-assigned interface command base ids.
    • commands/standard/ — the system commands (e.g. LynxDiscoveryCommand, LynxKeepAliveCommand, LynxQueryInterfaceCommand, LynxGetModuleStatusCommand, LynxSetModuleLEDColorCommand, LynxFailSafeCommand, LynxSetNewModuleAddressCommand, LynxAck/LynxNack).
    • commands/core/ — the DEKA I/O commands (base class LynxDekaInterfaceCommand; e.g. LynxGetADCCommand, LynxGetBulkInputDataCommand, and the motor/servo/DIO/I2C command classes). ~85 classes, one per command, each giving the exact payload layout.
    • LynxModule.java, LynxUsbDevice.java — transport, discovery, dispatch, keep-alive; LynxNackException.java — error handling.

Reverse-engineered firmware & independent protocol write-up

  • DuckTapeAndAPrayer/DuckLynx — a clean-room replacement firmware (C) for the Lynx Hardware Interface Board, plus reverse-engineering notes. Two documents here are especially valuable because they describe the hub (device) side of the protocol:
    • info/RHSP.md — an independent prose specification of RHSP. It is the only source consulted that enumerates the NACK codes (§4.4), the module/motor status bitfields (§4.5), the inter-hub RS485 topology (§1), the timeout semantics (§3), and the firmware command map for the high command IDs (§4.6). Cross-references the FTC SDK, the Saleae analyzer, and librhsp.
    • info/Stock Firmware.md and firmware/src/rhsp/ — notes on, and a partial re-implementation of, the stock firmware’s command handling.

Protocol analyzer & on-wire validation

  • REVrobotics/REV-Hub-Serial-Protocol-Analyzer-For-Saleae — REV’s official Saleae Logic high-level analyzer for this protocol (HighLevelAnalyzer.py). It decodes captured RHSP/UART frames (command, header fields, payload) and is the best tool for validating a reimplementation against real traffic from a known-good client.

Hardware / sensor datasheets (for the I2C device layer, §7)

  • Bosch BNO055 — absolute-orientation IMU (I2C address 0x28); register map mirrored in internal/imu.py.
  • Broadcom/Avago APDS-9960 — RGB/proximity sensor family used by the REV color sensor (I2C address 0x39); register map in internal/i2c.py.
  • STMicroelectronics VL53L0X — time-of-flight distance sensor used by the REV 2 m distance sensor; driver in distance.py.

Provenance. The rhsp message layer was originally decompiled from REVmessages.pyc and reorganized into this package (this repo is League-Robotics/python-serial-hub-control). The protocol is REV’s firmware-level “DEKA” interface — a name confirmed by both the C source (deviceControl.c) and the Java source (LynxDekaInterfaceCommand). Field names and semantics in this document come from the Python source, cross-checked against the C and Java references above.


Parameter Value Source
Transport USB CDC serial (virtual COM port) client.py
Baud rate 460800 Client._serial_config
Data bits 8 Client._serial_config
Parity None Client._serial_config
Stop bits 1 Client._serial_config
Flow control None

Note: the RHSPSerial wrapper (rshp_serial.py) defaults to 9600 baud, but Client always overrides it with the 460800 configuration above when it opens a port.

Port discovery

comPort.enumerate() (rshp_serial.py:183) scans system serial ports (serial.tools.list_ports) and selects a hub by USB descriptor:

  • The port’s hwid must contain a SER= (serial-number) field, and
  • that serial number must start with the letter D.

This D… serial-number prefix is how a genuine REV hub is distinguished from other USB serial devices.

Topology (per DuckLynx RHSP.md)

The link this package speaks to — controller ↔ parent hub — is the USB serial port above. Beyond that, hubs form a small network:

  • The controller (this library, or the FTC Robot Controller app) connects to the parent hub over UART0 (USB).
  • Child hubs connect to the parent over RS485 on UART1, daisy-chained and addressed individually.
  • The protocol is the same on both legs; the parent hub forwards/repeats packets between USB and RS485. Discovery (§3.3) is what walks the RS485 chain.
  • The whole protocol is little-endian to match the ARM MCU in the hub.

2. Packet framing

All traffic is exchanged as discrete packets. Internally the code manipulates packets as hex-ASCII strings (two characters per byte) and only converts to raw bytes at the moment of write()/read(); this document describes the bytes on the wire.

2.1 Packet structure

+--------+--------+---------+--------+--------+--------+--------+----------+---------+--------+
| 0x44   | 0x4B   | length  |  dest  | source | msgNum | refNum | cmd/type | payload | chksum |
| (1B)   | (1B)   | (2B LE) |  (1B)  | (1B)   | (1B)   | (1B)   | (2B LE)  |  (N B)  | (1B)   |
+--------+--------+---------+--------+--------+--------+--------+----------+---------+--------+
| <----------------------- frame + 8-byte header ----------------------> |
| <----------------------------- covered by checksum -----------------------------> |

Byte offsets (from REVPacket index constants):

Offset Size Field Description
0 2 Frame bytes Constant 0x44 0x4B (ASCII "DK"). Marks the start of a packet.
2 2 Length Total packet length in bytes, little-endian (includes frame, header, payload, and checksum).
4 1 Destination Target module address. 0xFF (255) = broadcast to all hubs (used for Discovery).
5 1 Source Originating address. Always 0x00 for the controller; on a reply it is the responding hub’s address.
6 1 Message number Sender’s sequence id for this request. Per spec it must never be 0 — it starts at 1 and, on overflow, wraps back to 1. (The Python client does not honor this; see §12.)
7 1 Reference number On a response, echoes the request’s message number (correlates response↔request).
8 2 Packet type / command Command id, little-endian. Bit 15 = response flag (§4.3). See §4.
10 N Payload Command-specific fields. May be empty.
10+N 1 Checksum 8-bit additive checksum (see §2.3).

The fixed header is 8 bytes; with the 2 frame bytes and 1 checksum byte, the minimum packet (empty payload) is 11 bytes.

2.2 Length field

REVPacket.calcLength() (messages.py:309) computes:

length = len(header) + len(payload) + 3
       = 8           + N            + 3   (2 frame bytes + 1 checksum byte)
       = total packet size in bytes

The value is then byte-swapped to little-endian for transmission. On receive, the parser rejects any frame whose declared payload length exceeds PAYLOAD_MAX_SIZE = 128.

Max payload discrepancy. This Python package caps the payload at 128 bytes (PAYLOAD_MAX_SIZE). REV’s librhsp allows up to 512 bytes (RHSP_PACKET_PAYLOAD_BUFFER_SIZE, per DuckLynx RHSP.md). A reimplementation that needs the large I2C/version payloads should size its receive buffer for 512, not 128.

2.3 Checksum

8-bit additive checksum over every byte from the frame start through the end of the payload (i.e. all bytes except the checksum byte itself), taken mod 256:

chksum = (sum of all preceding bytes) % 256

Computed in REVPacket.getPacketData() (messages.py:313) and verified on receive in Client.checkPacket() (client.py:218). Per the firmware spec, if the checksum is wrong the hub sends no reply at all (the packet is dropped, not NACK’d), and a bad-checksum packet does not reset the keep-alive timeout (§3).

2.4 Byte order

Multi-byte integer fields are little-endian on the wire. The code performs this conversion explicitly:

  • For the 2-byte length and command fields via the idiom x >> 8 | x % 256 << 8.
  • For general payload fields via Client.swapEndianess() (client.py:253), which reverses the byte order of a hex string.

Signed fields use two’s-complement (see §5).


3. Transaction model

The protocol is strictly request/response, half-duplex, one outstanding transaction at a time. Client.sendAndReceive(packet, destination) (client.py:65) drives every exchange:

  1. Set header.destination = destination and assign a message number.
  2. Write the encoded packet to the serial port.
  3. Wait (up to ~1 s) for bytes to arrive; on timeout, retry up to MaxRetries = 3 times.
  4. Feed incoming bytes through a receive state machine (§3.2).
  5. On a complete, checksum-valid packet, decode it (processPacket) and return the response object. On checksum failure, log and resync.

3.0 Keep-alive timeout / fail-safe (hub side)

The hub runs a watchdog. Per DuckLynx RHSP.md:

  • If a hub receives no packet for 2500 ms, it enters timeout mode: it signals the timeout on the LED and stops all motion by entering fail-safe (motors/servos disabled).
  • Any valid packet resets the timeout — even one that gets NACK’d. A packet is “valid” if it has the magic frame bytes and a correct checksum, regardless of whether the command or payload is accepted.
  • Packets with a bad checksum or missing magic number do not reset the timeout (and draw no reply).

Therefore a controller must send something (typically KeepAlive, §4.1) at least every ~2.5 s while any output is active. After a fail-safe, outputs must be re-enabled. The FailSafe command lets the controller trigger this state deliberately (e-stop).

3.1 Responses: ACK vs. typed response

Every command has an expected reply, captured in the printDict table (messages.py:2003):

  • Setters / actions (e.g. SetMotorConstantPower, KeepAlive, ResetMotorEncoder) reply with a bare ACK (payload: attnReq, 1 byte).
  • Getters / queries (e.g. GetADC, GetMotorEncoderPosition) reply with a dedicated *_RSP packet whose command id is the request id OR-ed with the response bit (§4.3).
  • On error, the hub replies with NACK (payload: nackCode, 1 byte).

Client.checkResponse() (client.py:195) validates that the received packetType equals the expected response and that the response’s refNum matches the request’s msgNum (Discovery is exempted, since it is a broadcast with multiple replies).

3.2 Receive state machine

The receiver parses byte-by-byte (client.py:134) through these states:

WaitForFrameByte1   -- discard until 0x44 seen
WaitForFrameByte2   -- expect 0x4B (0x44 repeats stay here; anything else resets)
WaitForPacketLengthByte1
WaitForPacketLengthByte2  -- assemble little-endian length; bounds-check vs 128
WaitForPayloadBytes -- accumulate until accumulated length == declared length,
                       then verify checksum and decode

The destination/source/msgNum/refNum/command fields are not parsed as separate states; they are accumulated with the payload and then sliced out by fixed offset in Client.processPacket() (client.py:226).

3.3 Discovery (broadcast)

Client.discovery() (client.py:317) sends a Discovery command to destination 255 (broadcast). Unlike normal transactions, the hub (and any daisy-chained modules) may emit multiple Discovery_RSP packets. The client collects responses, pausing ~2 s and re-checking the input buffer, until no more arrive. Each Discovery_RSP carries a parent byte and its source becomes that module’s address. One Module object is created per discovered module.

How the hub side responds (DuckLynx RHSP.md): the parent hub first replies with its own address (changing the packet’s source from the broadcast 0xFF to its own). It then sends a discovery to every possible child address (0–254) over RS485 and forwards each child’s reply back to the controller. The Discovery_RSP parent field (a bool) indicates whether that reply originated at the parent itself or was retransmitted from an RS485 child — this is how the controller learns the parent/child layout.

3.4 Addressing & message numbering

  • Destination is supplied per call; it is the module address (the address a Module was discovered at, reassignable via SetNewModuleAddress).
  • Message number is managed locally inside sendAndReceive (it starts at 0 for each call and increments only across retries; it is not a persistent monotonic counter across transactions — see §7).

4. Command catalogue

Command ids fall into two ranges, both defined in MsgNum (messages.py:1217).

4.1 System / module-management commands (0x7F01–0x7F0F)

Cmd id Name Request payload Reply
0x7F01 ACK attnReq:1
0x7F02 NACK nackCode:1
0x7F03 GetModuleStatus clearStatus:1 RSP: statusWord:1, motorAlerts:1
0x7F04 KeepAlive ACK
0x7F05 FailSafe ACK
0x7F06 SetNewModuleAddress moduleAddress:1 ACK
0x7F07 QueryInterface interfaceName:string RSP: packetID:2, numValues:2
0x7F08 StartProgramDownload ACK
0x7F09 ProgramDownloadChunk ACK
0x7F0A SetModuleLEDColor redPower:1, greenPower:1, bluePower:1 ACK
0x7F0B GetModuleLEDColor RSP: redPower:1, greenPower:1, bluePower:1
0x7F0C SetModuleLEDPattern rgbtStep0..15:4 each (16×4 = 64 B) ACK
0x7F0D GetModuleLEDPattern RSP: rgbtStep0..15:4 each
0x7F0E DebugLogLevel groupNumber:1, verbosityLevel:1 ACK
0x7F0F Discovery Discovery_RSP: parent:1 (broadcast; multiple replies)

QueryInterface is the mechanism by which a host asks the hub for the base command id of a named interface. The interfaceName is a null-terminated string (e.g. "DEKA"); the response gives the first command id and the number of commands in that interface.

LED pattern step layout. Each of the 16 rgbtStep entries is 4 bytes. On the wire (little-endian) the byte order is [tenths-of-seconds, blue, green, red] (DuckLynx RHSP.md). A step of all zeros terminates the pattern early. This matches the Python LEDPattern.set_step, which packs r<<24 | g<<16 | b<<8 | t (serialized little-endian → t, b, g, r).

4.2 DEKA I/O interface commands (base 0x1000 + index)

DekaInterfacePrefix = 0x1000 (4096). Each command id is 0x1000 + index. The notation below uses name:bytes.

Idx Cmd id Name Request payload Reply payload
0 0x1000 GetBulkInputData Large aggregate snapshot (§6.1)
1 0x1001 SetSingleDIOOutput dioPin:1, value:1 ACK
2 0x1002 SetAllDIOOutputs values:1 (bitmask) ACK
3 0x1003 SetDIODirection dioPin:1, directionOutput:1 ACK
4 0x1004 GetDIODirection dioPin:1 directionOutput:1
5 0x1005 GetSingleDIOInput dioPin:1 inputValue:1
6 0x1006 GetAllDIOInputs inputValues:1 (bitmask)
7 0x1007 GetADC adcChannel:1, rawMode:1 adcValue:2
8 0x1008 SetMotorChannelMode motorChannel:1, motorMode:1, floatAtZero:1 ACK
9 0x1009 GetMotorChannelMode motorChannel:1 motorChannelMode:1, floatAtZero:1
10 0x100A SetMotorChannelEnable motorChannel:1, enabled:1 ACK
11 0x100B GetMotorChannelEnable motorChannel:1 enabled:1
12 0x100C SetMotorChannelCurrentAlertLevel motorChannel:1, currentLimit:2 ACK
13 0x100D GetMotorChannelCurrentAlertLevel motorChannel:1 currentLimit:2
14 0x100E ResetMotorEncoder motorChannel:1 ACK
15 0x100F SetMotorConstantPower motorChannel:1, powerLevel:2 (signed) ACK
16 0x1010 GetMotorConstantPower motorChannel:1 powerLevel:2
17 0x1011 SetMotorTargetVelocity motorChannel:1, velocity:2 (signed) ACK
18 0x1012 GetMotorTargetVelocity motorChannel:1 velocity:2
19 0x1013 SetMotorTargetPosition motorChannel:1, position:4, atTargetTolerance:2 ACK
20 0x1014 GetMotorTargetPosition motorChannel:1 targetPosition:4, atTargetTolerance:2
21 0x1015 GetMotorAtTarget motorChannel:1 atTarget:1
22 0x1016 GetMotorEncoderPosition motorChannel:1 currentPosition:4 (signed)
23 0x1017 SetMotorPIDCoefficients motorChannel:1, mode:1, p:4, i:4, d:4 (Q16) ACK
24 0x1018 GetMotorPIDCoefficients motorChannel:1, mode:1 p:4, i:4, d:4 (Q16)
25 0x1019 SetPWMConfiguration pwmChannel:1, framePeriod:2 ACK
26 0x101A GetPWMConfiguration pwmChannel:1 framePeriod:2
27 0x101B SetPWMPulseWidth pwmChannel:1, pulseWidth:2 ACK
28 0x101C GetPWNPulseWidth* pwmChannel:1 pulseWidth:1
29 0x101D SetPWMEnable pwmChannel:1, enable:1 ACK
30 0x101E GetPWMEnable pwmChannel:1 enabled:1
31 0x101F SetServoConfiguration servoChannel:1, framePeriod:2 ACK
32 0x1020 GetServoConfiguration servoChannel:1 framePeriod:2
33 0x1021 SetServoPulseWidth servoChannel:1, pulseWidth:2 ACK
34 0x1022 GetServoPulseWidth servoChannel:1 pulseWidth:2
35 0x1023 SetServoEnable servoChannel:1, enable:1 ACK
36 0x1024 GetServoEnable servoChannel:1 enabled:1
37 0x1025 I2CWriteSingleByte i2cChannel:1, slaveAddress:1, byteToWrite:1 ACK
38 0x1026 I2CWriteMultipleBytes i2cChannel:1, slaveAddress:1, numBytes:1, bytesToWrite:≤121 ACK
39 0x1027 I2CReadSingleByte i2cChannel:1, slaveAddress:1 ACK (data via status query)
40 0x1028 I2CReadMultipleBytes i2cChannel:1, slaveAddress:1, numBytes:1 ACK (data via status query)
41 0x1029 I2CReadStatusQuery i2cChannel:1 i2cStatus:1, byteRead:1, payloadBytes:≤121
42 0x102A I2CWriteStatusQuery i2cChannel:1 i2cStatus:1, numBytes:1
43 0x102B I2CConfigureChannel i2cChannel:1, speedCode:1 ACK
44 0x102C PhoneChargeControl enable:1 ACK
45 0x102D PhoneChargeQuery enable:1
46 0x102E InjectDataLogHint length:1, hintText:≤121 ACK
47 0x102F I2CConfigureQuery i2cChannel:1 speedCode:1
48 0x1030 ReadVersionString length:1, versionString:40
49 0x1031 GetBulkPIDData motorChannel:1 Large PID telemetry dump (§6.2)
50 0x1032 I2CBlockReadConfig channel:1, address:1, startRegister:1, numberOfBytes:1, readInterval_ms:1 ACK
51 0x1033 I2CBlockReadQuery channel:1 address:1, startRegister:1, numberOfBytes:1, readInterval_ms:1
52 0x1034 I2CWriteReadMultipleBytes channel:1, address:1, startRegister:1, numberOfBytes:1 ACK
53 0x1035 IMUBlockReadConfig startRegister:1, numberOfBytes:1, readInterval_ms:1 ACK
54 0x1036 IMUBlockReadQuery channel:1 startRegister:1, numberOfBytes:1, readInterval_ms:1
55 0x1037 GetBulkMotorData Motor encoders/velocities/modes/time (§6.3)
56 0x1038 GetBulkADCData All analog inputs/currents/voltages/time (§6.4)
57 0x1039 GetBulkI2CData Cached I2C block data + IMU block (§6.5)
64 0x1040 GetBulkServoData All servo commands & frame periods (§6.6)

* GetPWNPulseWidth is spelled with an N in the source and its response declares pulseWidth as 1 byte while the setter uses 2 bytes — see §7.

Note the gap: GetBulkServoData is index 64 (0x1040), not 58.

⚠ Command numbering above index 0x30 diverges from current REV firmware. Indices 0x000x30 (0–48, through ReadVersionString) agree across this Python package, librhsp, and the FTC SDK. From index 0x31 (49) onward the Python package’s map does not match REV’s stock firmware / librhsp — see §4.6 for the side-by-side table. If you are reimplementing against a current hub, use the firmware numbering in §4.6, not the Python rows above. (The Python rows are accurate to this package, which appears to target an older or non-stock command set for the high IDs.)

4.3 Response command ids

RESPONSE_BIT = 0x8000 (messages.py:1295). A typed response’s command id is RESPONSE_BIT | requestCmd. For example GetADC = 0x1007GetADC_RSP = 0x9007. RespNum (messages.py:1297) enumerates all of these. (ACK/NACK replies, by contrast, use their own fixed ids 0x7F01/0x7F02 rather than a response-bit-encoded id.)

4.4 NACK codes

When a command is rejected, the hub replies with NACK (0x7F02) carrying a single nackCode byte. The Python package only prints this code; the meanings below are from DuckLynx RHSP.md (stock-firmware semantics):

Code Meaning
0–9 Parameter #N out of range (the index is the offending parameter).
10–17 GPIO #(code−10) not configured for output.
18 No GPIO pins configured for output.
20–27 GPIO #(code−20) not configured for input.
28 No GPIO pins configured for input.
30 Servo not fully configured before being enabled.
31 Battery voltage too low to run servo.
40 I2C master busy (command rejected).
41 I2C operation in progress (poll again for completion).
42 I2C no results pending.
43 I2C query mismatch (query doesn’t match last operation).
44 I2C timeout — SDA stuck.
45 I2C timeout — SCK stuck.
46 I2C timeout.
50 Motor not fully configured for the selected mode before being enabled.
51 Command not valid for the selected motor mode.
52 Battery voltage too low to run motor.
253 Command implementation pending (known/delivered but not implemented).
254 Command routing error (known but not handled by the receiving subsystem).
255 Packet Type ID unknown (should not happen if discovery/QueryInterface was done).

Codes 19, 29, 32–39, 47–49, 53–59 are reserved.

4.5 Status bitfields (GetModuleStatus response)

GetModuleStatus (0x7F03) returns two bytes. The Python layer exposes them as opaque statusWord / motorAlerts; the bit meanings (DuckLynx RHSP.md) are:

Byte 0 — Module status

Bit Meaning
0 Keep-alive timeout
1 Device reset (set once after the device comes up from reset)
2 Fail-safe (battery too low to run, or a keep-alive timeout)
3 Controller over-temperature
4 Battery low (set below ~7 V; also triggers fail-safe + LED)
5 HIB fault
6–7 Reserved

Byte 1 — Motor status (alerts)

Bit Meaning
0–3 Motor 0–3 lost encoder counts
4–7 Motor 0–3 driver overheat

4.6 Firmware command map for IDs ≥ 0x31 (divergence)

DEKA function numbers 0x000x30 agree across implementations. From 0x31 onward, REV’s stock firmware / librhsp (left, per DuckLynx RHSP.md, confirmed against librhsp deviceControl.c / motor.c) and this Python package (right, from messages.py) assign different commands to the same ids:

Idx id Stock firmware / librhsp Python rhsp package
0x31 0x1031 FTDI_RESET_CONTROL GetBulkPIDData
0x32 0x1032 FTDI_RESET_QUERY I2CBlockReadConfig
0x33 0x1033 SET_MOTOR_PIDF_COEFFICIENTS I2CBlockReadQuery
0x34 0x1034 I2C_WRITE_READ_MULTIPLE_BYTES I2CWriteReadMultipleBytes (agree)
0x35 0x1035 GET_MOTOR_PIDF_COEFFICIENTS IMUBlockReadConfig
0x36 0x1036 I2C_TRANSACTION IMUBlockReadQuery
0x37 0x1037 I2C_QUERY_TRANSACTION GetBulkMotorData
0x38 0x1038 SET_BULK_OUTPUT_DATA GetBulkADCData
0x39 0x1039 READ_VERSION (binary firmware version) GetBulkI2CData

Notes:

  • The firmware adds PIDF closed-loop coefficients (0x33/0x35) as a superset of the older PID command (0x17/0x18), plus FTDI reset control (0x31/0x32), a generic I2C transaction API (0x36/0x37), a SET_BULK_OUTPUT_DATA write-everything command (0x38), and a binary READ_VERSION (0x39).
  • librhsp resolves these at runtime via rhsp_getInterfacePacketID(hub, "DEKA", functionNumber, …) — i.e. it adds the function number to the base id returned by QueryInterface, rather than hard-coding 0x1000 + n. A portable reimplementation should do the same.
  • The Python package’s GetBulk*Data / *BlockRead* commands at these ids are not present in current stock firmware; treat them as belonging to a different firmware generation and verify against your target hub (capture with the Saleae analyzer) before relying on them.

5. Data encoding conventions

Concept Encoding
Integers Little-endian on the wire.
Signed integers Two’s-complement. Encoder position is 32-bit signed; target velocity and power are 16-bit signed. Sign extension is applied on decode (e.g. internal/motors.py:124).
PID coefficients Q16 fixed-point: transmitted as value × 65536 in a 4-byte field; decoded by dividing by 65536 (Q16 in internal/motors.py:6).
Strings ASCII, packed two hex chars per byte; ReadVersionString returns length + up to 40 bytes (not null-terminated), decoded char-by-char in module.py:136. The version string format is "HW: 20, Maj: 1, Min: 8, Eng: 2" — hardware revision (20 = 2.0), then semantic major/minor/patch (DuckLynx RHSP.md).

5.1 Motor modes (motorMode)

From internal/motors.py:

Value Mode
0 MODE_CONSTANT_POWER
1 MODE_CONSTANT_VELOCITY
2 MODE_POSITION_TARGET
3 MODE_CONSTANT_CURRENT

floatAtZero: 0 = BRAKE_AT_ZERO, 1 = FLOAT_AT_ZERO. Power level range observed in tests is ±(2^15 − 1).

5.2 Servo encoding

From servo.py: pulse widths are in microseconds, clamped to 500–2500 µs; the default frame period is 20000 µs (50 Hz). setAngle(0..180°) maps linearly to 500 + angle × (2000/180) µs.

5.3 ADC channels (adcChannel)

From adc.py / ADCChannel in messages.py:9:

0–3  Analog input channels 0–3        8–11 Motor 0–3 current
4    GPIO current                      12   5V monitor
5    I2C bus current                   13   Battery monitor
6    Servo current                     14   CPU temperature
7    Battery current

rawMode selects raw vs. scaled readings; adcValue is returned as 2 bytes.


6. Bulk / aggregate response payloads

To minimize round-trips at 460800 baud, the hub offers “bulk” reads that return a large, fixed-layout snapshot in one transaction. All fields are little-endian; sizes in bytes shown in parentheses. (Exact field orders are in the *_RSP_Payload classes in messages.py.)

6.1 GetBulkInputData_RSP (messages.py:895)

A full I/O snapshot: digitalInputs(1); motor0..3Encoder(4 each); motorStatus(1); motor0..3Velocity(2 each); motor0..3mode(1 each); analogInput0..3(2 each); gpioCurrent_mA,i2cCurrent_mA,servoCurrent_mA, batteryCurrent_mA(2 each); motor0..3current_mA(2 each); mon5v_mV(2); batteryVoltage_mV(2); servo0..5cmd(2 each); servo0..5framePeriod_us(2 each); i2c0..3data(10 each); imuBlock(10); i2c0..3Status(1 each); imuStatus(1); mototonicTime(4) (monotonic timestamp; field name is misspelled in the source).

6.2 GetBulkPIDData_RSP (messages.py:1103)

Per-motor PID telemetry: for each of the current, velocity, and position loops — Pterm, Iterm, Dterm, Output, Cmd, Error (4 bytes each) — plus monotonicTime(4).

6.3 GetBulkMotorData_RSP (messages.py:1144)

motor0..3Encoder(4 each); motorStatus(1); motor0..3Velocity(2 each); motor0..3mode(1 each); monotonicTime(4). Used by Motor.getVelocity().

6.4 GetBulkADCData_RSP (messages.py:1163)

analogInput0..3(2 each); the four bus currents (2 each); motor0..3current_mA (2 each); mon5v_mV(2); batteryVoltage_mV(2); monotonicTime(4).

6.5 GetBulkI2CData_RSP (messages.py:1183)

i2c0..3data(10 each); imuBlock(10); i2c0..3Status(1 each); imuStatus(1); monotonicTime(4). Returns the most recent block-read results cached by the hub (see §6.7).

6.6 GetBulkServoData_RSP (messages.py:1199)

servo0..5cmd(2 each); servo0..5framePeriod_us(2 each); monotonicTime(4).

6.7 Block reads (autonomous I2C/IMU polling)

I2CBlockReadConfig / IMUBlockReadConfig instruct the hub to autonomously poll a register block on an I2C device (or the on-board IMU) every readInterval_ms. The cached results are then retrievable cheaply via GetBulkI2CData (the i2cNdata / imuBlock fields) without a per-read I2C transaction. *BlockReadQuery returns the current block-read configuration.


7. I2C sub-bus and attached sensors

The hub bridges to up to 4 external I2C channels plus an internal IMU bus. The host drives I2C through the commands in §4.2 (single/multi byte write & read, status query, channel speed config, block read). Reads are two-phase: issue I2CRead…, then poll I2CReadStatusQuery to retrieve i2cStatus and the data bytes (internal/i2c.py:101).

I2C command/address bit helpers (I2CConstants, internal/i2c.py:9): COMMAND_REGISTER_BIT = 0x80, MULTI_BYTE_BIT = 0x20, SINGLE_BYTE_BIT = 0x00.

The higher-level device layer (i2c.py, color.py, distance.py, imu.py) implements specific sensors on top of these primitives:

Sensor I2C address Notes
REV color sensor (APDS-9960 family) 57 (0x39), device id 0x60 Register map in I2CConstants (internal/i2c.py).
BNO055 IMU 40 (0x28) Full register map in IMUConstants (internal/imu.py).
2 m distance sensor (VL53L0X) configured in Distance2m distance.py.

These sensor protocols are layered on top of the RHSP I2C commands; they are device-specific and not part of the core hub serial protocol.


8. Software object model

The package layers a clean API over the wire protocol:

comPort.enumerate()           → discovers hub USB ports
Client.open() / .discovery()  → opens serial, broadcasts Discovery → [Module]
  Module                      → one REV hub/module (holds its address)
    .motors[0..3]   Motor     → SetMotor* / GetMotor* commands
    .servos[0..5]   Servo     → Servo* commands (µs pulse widths)
    .adcPins[0..3]  ADCPin    → GetADC
    .dioPins[0..7]  DIOPin    → DIO commands
    .i2cChannels[0..3] I2CChannel → I2CDevice / ColorSensor / IMU / Distance2m

Module.init_periphs() (module.py:24) constructs 4 motors, 4 I2C channels, 8 DIO pins, 6 servos, and 4 ADC pins, and initializes motors to constant-power mode at zero. Module.keep_alive() must be sent periodically or the hub’s fail-safe disables outputs.


9. Sequencing & usage recipes

The protocol is mostly stateless per command, but the hub is stateful: some commands must precede others, and a heartbeat must run continuously while outputs are active. The recipes below are distilled from Module.init_periphs() and the example scripts in test/. They are also encoded machine-readably under "sequencing" in rhsp-protocol.json (§10).

9.1 Mandatory session bring-up (in order)

  1. Enumerate serial ports; pick the one whose USB serial number starts with D (§1).
  2. Open the port at 460800 8N1, no flow control.
  3. Discovery — send Discovery to destination 255; create one logical module per Discovery_RSP, using each reply’s source as that module’s address. Nothing else works until discovery has run.
  4. (Optional but recommended) QueryInterface(“DEKA”) to obtain the runtime base command id for the I/O block. This firmware uses 0x1000, but a replication should not hard-code it — read firstPacketID/packetID and add the per-command index from §4.2.
  5. Initialize peripherals (§9.2).
  6. Start the keep-alive heartbeat — send KeepAlive at least every ~2.5 s for as long as any output (motor/servo/LED) is driven. If the watchdog expires the hub enters fail-safe and disables outputs; you must then re-enable them.

9.2 Peripheral initialization (Module.init_periphs())

Block Count Init action
Motors 4 SetMotorChannelMode(ch, mode=0, floatAtZero=1), then SetMotorConstantPower(ch, 0)
I2C channels 4 handle only; no bus traffic until a device is added
DIO pins 8 handle only
Servos 6 SetServoConfiguration(ch, framePeriod=20000) (50 Hz)
ADC pins 4 handle only

9.3 Subsystem recipes

Motor — open-loop power (test_motor.py):

SetMotorChannelMode(ch, mode=0 CONSTANT_POWER, floatAtZero=1)
SetMotorConstantPower(ch, 0)
SetMotorChannelEnable(ch, 1)
SetMotorConstantPower(ch, power)      # power ∈ [-32767, 32767]
# …repeat KeepAlive() ≥ every 2.5 s…

Motor — closed-loop velocity (test_motor_velocity.py):

SetMotorChannelMode(ch, 0, 1); SetMotorConstantPower(ch, 0)   # init
SetMotorChannelEnable(ch, 1)
SetMotorChannelMode(ch, mode=1 CONSTANT_VELOCITY, floatAtZero=1)
SetMotorTargetVelocity(ch, velocity)                          # signed counts/s
# read back via GetBulkMotorData → motorNVelocity (16-bit signed)

Note the ordering: the channel is enabled then switched to velocity mode in the example. Position mode is analogous using SetMotorTargetPosition(ch, position, tolerance) and polling GetMotorAtTarget.

Servo (test_servo.py):

SetServoConfiguration(ch, framePeriod=20000)   # 50 Hz
SetServoPulseWidth(ch, 1500)                   # center, µs
SetServoEnable(ch, 1)
SetServoPulseWidth(ch, pw)                     # 500..2500 µs; angle→pw = 500 + angle*2000/180

Digital I/O: SetDIODirection(pin, 1) to make a pin an output, then SetSingleDIOOutput(pin, value). For inputs, SetDIODirection(pin, 0) then GetSingleDIOInput(pin) / GetAllDIOInputs.

Analog: GetADC(channel, rawMode) — channel per the ADCChannel enum; current channels read mA, voltage channels read mV when rawMode=0.

I2C color sensor (APDS-9960, addr 0x39) (color.py): write ENABLE/ATIME/PPULSE config registers (each as COMMAND_BIT|register then value), verify device id 0x60, then read color channels with COMMAND_BIT|MULTI_BYTE_BIT|register + a 2-byte read.

I2C distance sensor (VL53L0X, addr 0x29) (distance.py): initialize() performs the standard ST data-init sequence — read stop_variable, set the signal-rate limit, load SPAD config, write the large default tuning register map, configure the GPIO interrupt, run performSingleRefCalibration(0x40) then (0x00), setTimeout(200), startContinuous() — after which readRangeContinuousMillimeters() polls the interrupt-status register, reads the range, and clears the interrupt. This is a long fixed sequence; replicate it register-for-register.

IMU (BNO055): IMUBlockReadConfig(startRegister, numberOfBytes, readInterval_ms) to start autonomous polling, then read the cached block via GetBulkI2CData (imuBlock) or IMUBlockReadQuery.

9.4 Block reads (latency optimization)

For sensors polled continuously, configure the hub to auto-poll a register block (I2CBlockReadConfig / IMUBlockReadConfig) and then retrieve cached data with the bulk reads (§6.5) instead of issuing a full I2C transaction per sample.

9.5 Worked wire example

These are exact bytes produced by REVPacket.getPacketData() (frame + little-endian fields + additive checksum). Use them as encoder test vectors:

Command Bytes (hex)
KeepAlive (dest=1, msg=0) 44 4B 0B 00 01 00 00 00 04 7F 1E
Discovery (dest=255, msg=0) 44 4B 0B 00 FF 00 00 00 0F 7F 27
SetServoPulseWidth ch=0, pw=1500µs (dest=1) 44 4B 0E 00 01 00 00 00 21 10 00 DC 05 B0
SetMotorConstantPower ch=0, power=16000 (dest=1) 44 4B 0E 00 01 00 00 00 0F 10 00 80 3E 7B

Decoding the servo example: 44 4B frame · 0E 00 length=14 · 01 dest · 00 src · 00 msgNum · 00 refNum · 21 10 packetType=0x1021 (SetServoPulseWidth) · 00 servoChannel · DC 05 pulseWidth=0x05DC=1500 · B0 checksum. The checksum is (0x44+0x4B+0x0E+…+0x05) mod 256 = 0xB0.


10. Machine-readable command catalogue (rhsp-protocol.json)

For programmatic replication, the complete command set is emitted as a single structured file: rhsp-protocol.json. It is generated by generate_protocol_json.py, which introspects the live message classes in messages.py so the structural data cannot drift from the implementation. Regenerate with:

.venv/bin/python docs/generate_protocol_json.py

Top-level keys:

Key Contents
link_layer Baud/format, port-discovery rule, and RS485 topology (§1).
framing Frame bytes, header field table with offsets, checksum algorithm + on_bad_checksum, max_payload_size (+ librhsp note), payload offset (§2).
constants response_bit (0x8000), deka_interface_prefix (0x1000), broadcast address, response-id rule (§4.3).
enums MotorMode, ZeroPowerBehavior, ClosedLoopMode, DIODirection, ADCChannel, LEDColor, I2CSpeedCode, NackCode (§4.4), ModuleStatusBits / MotorStatusBits (§4.5).
counts Channel counts (4 motors, 6 servos, 8 DIO, 4 ADC, 4 I2C).
keep_alive_interval_ms / keep_alive_note 2500 ms watchdog and reset semantics (§3.0).
firmware_command_map_divergence Stock-firmware vs Python id map for indices ≥ 0x31 (§4.6).
commands Every host→hub command: id/id_hex, group, ordered payload (each field with name, bytes, offset, and optional signed/fixed_point/unit/enum/range/description), and the expected reply (ack or a typed response).
responses Every hub→host response packet with its ordered payload layout (including the bulk snapshots of §6).
sequencing The ordered recipes of §9 as step arrays.

Each command/response field carries a byte offset within the payload, so an agent can build an encoder/decoder directly from the JSON without re-reading the Python. Field structure (names, sizes, order, ids, reply mapping) is authoritative-by-introspection; signedness, units, fixed-point scaling, and enum references are a curated overlay (sourced from the device modules) and are the parts most worth double-checking against librhsp or the FTC SDK (§11).


11. Cross-reference: REV’s official C/Node implementation

The protocol above was extracted from the Python rhsp package. It was cross-checked against REV Robotics’ official implementation — the C library librhsp wrapped as a Node.js native addon (local copy at /Users/eric/proj/RobotProjects/rhsplib-old, upstream @rev-robotics/rev-hub-core / REVrobotics/RHSPlib). In that copy the librhsp C submodule was not checked out, so the exact framing/checksum C code could not be read directly, but the addon’s public API (lib/binding.ts, src/RevHubWrapper.cc) maps one-to-one onto the commands documented here, confirming the command set and semantics. Notable points of agreement and a few additions:

  • Same command surface. getBulkInputData, queryInterface, module status (statusWord, motorAlerts, attentionRequired), LED color/pattern, the full motor / servo / DIO / ADC / I2C command groups, phone charge control, injectDataLogHint, and version reads all match §4.
  • QueryInterface confirmed. The official API returns firstPacketID and numberIDValues — exactly the packetID / numValues of QueryInterface_RSP (§4.1). This confirms that the DEKA interface’s base command id (0x1000 here) is meant to be obtained dynamically at runtime via QueryInterface rather than hard-coded.
  • Discovery confirmed. discoverRevHubs() is a port-level operation returning parentAddress, childAddresses, and numberOfChildModules — matching the broadcast-to-255 / multi-reply behavior in §3.3.
  • Generic command interface. The official lib exposes sendWriteCommand(packetTypeID, payload) → ACK and sendReadCommand(packetTypeID, payload) → response, making explicit the setter→ACK / getter→typed-response split described in §3.1, with commands addressed by numeric packetTypeID.
  • Configurable response timeout. setResponseTimeoutMs / getResponseTimeoutMs parameterize the receive timeout that appears as a fixed ~1 s value in the Python sendAndReceive (§3).
  • Serial parameters confirmed. The Serial.open(name, baudrate, databits, parity, stopbits, flowControl) signature matches the 460800-8N1, no-flow configuration in §1.
  • Additions present in the C lib but not in the Python package:
    • readVersion() returns a structured version (hwType, majorVersion, minorVersion, engineeringRevision, majorHwRevision, minorHwRevision) alongside the raw readVersionString().
    • setMotorClosedLoopControlCoefficients with a ClosedLoopControlAlgorithm selector and PIDF coefficients (PidfCoefficients) — a superset of the older SetMotorPIDCoefficients (with its mode byte) documented in §4.2.
    • setFTDIResetControl / getFTDIResetControl — FTDI USB reset control, with no equivalent command in the Python package.

In short, the Python rhsp package implements an older/partial subset of the same REV Hub Serial Protocol that the official librhsp speaks; nothing in the official API contradicts the framing, addressing, or command encoding documented above.

12. Review observations / discrepancies

These were noted while extracting the protocol. Most are software issues in the host code; item 0 is a wire-protocol divergence. All affect correctness and are worth tracking:

  1. Command numbering ≥ 0x31 diverges from current REV firmware (§4.6). This is the most important caveat for a reimplementation: the Python package’s GetBulkPIDData / *BlockRead* / GetBulk*Data commands (ids 0x1031+) do not match REV’s stock firmware / librhsp, which place FTDI reset, PIDF coefficients, I2C transaction, SET_BULK_OUTPUT_DATA, and binary READ_VERSION at those ids. Resolve DEKA ids dynamically via QueryInterface and validate the high-id commands against your target hub.

  2. msgNum is not a persistent counter — and violates the spec. In Client.sendAndReceive (client.py:84) msgNum is a local that resets to 0 on every call (incrementing only across retries). The instance field self.msgNum is never used for transmission. Every first transmission therefore carries msgNum = 0, which the protocol spec forbids (message number must be ≥ 1; §2.1), and the response-correlation check in checkResponse is effectively trivial.

  3. checkResponse is unused on the receive path. sendAndReceive returns the first checksum-valid decoded packet without calling checkResponse, so response-type / refNum validation is not actually enforced during normal operation.

  4. Block-read config functions write to the wrong object. i2cBlockReadConfig and imuBlockReadConfig (internal/i2c.py:126, internal/i2c.py:144) set attributes on the message object (msg.channel = …) instead of its payload (msg.payload.channel = …). The payload fields stay 0, so the configured register/address/interval are not transmitted.

  5. setCurrentPIDCoefficients is broken. internal/motors.py:158 calls getMotorPIDCoefficients(...) (a getter) with setter arguments and the wrong arity. It should call setMotorPIDCoefficients(... mode=3 ...).

  6. I2CChannel.setSpeed drops the client argument. i2c.py:125 calls i2cConfigureChannel(self.destinationModule, self.channel, speedCode) without the leading commObj/client argument the function expects.

  7. GetPWNPulseWidth inconsistency. The command name is misspelled (PWN), and its response payload declares pulseWidth as 1 byte (messages.py:1039) while the setter SetPWMPulseWidth uses 2 bytes. This is likely a defect carried over from the decompiled source.

  8. I2CConfigureQuery_RSP is not registered in printDict. The response class and its RespNum id (0x902F) exist, and I2CConfigureQuery declares it as the expected reply, but there is no printDict entry for it. Because Client.processPacket looks the incoming command id up in printDict (client.py:236), an actual I2CConfigureQuery_RSP from the hub would raise KeyError. It is the only RespNum value missing from printDict.

  9. Cosmetic: several *_RSP entries in printDict use a key 'Response ' with a trailing space (messages.py:2212+); harmless because response packets have no further response. The mototonicTime field in GetBulkInputData_RSP is also a misspelling of “monotonic”.