Volvo V/S90 front seats retrofit/standalone. - Working solution!
#71
Decoding the 2022 Volvo XC90 (SPA) seat heater + ventilation module over LIN

TL;DR — The seat heater/fan module in a 2022 XC90 (Volvo SPA platform) is a LIN slave. It listens on one frame, ID 0x20, 5 data bytes, enhanced checksum, 19200 baud. One frame sets the whole state and the module latches it (no keep-alive needed). The five bytes are, in plain terms:

Code:
byte0   byte1    byte2    byte3     byte4
seat    fan ramp cushion  backrest  reset
heat    rate     fan      fan       (leave 00)
target
  • byte 0 = seat heat target temperature — a real setpoint; heats both the cushion and the backrest
  • byte 1 = fan speed ramp rate — how fast the fans slew to the commanded speed (changes only the transition, not the endpoint). Factory value 0x0B
  • byte 2 = cushion fan speed (0–255)
  • byte 3 = backrest fan speed (0–255)
  • byte 4 bit 0 = soft reset — reboots the module (startup self-test); leave 0x00

That's the whole thing. Details below so you can build your own frames.

Hardware / bus
  • Module: LIN 2.x slave. NAD 0x73, supplier 0x0089, function 0x0002, variant 1.
  • Bus speed: 19200 baud (confirmed by the module's own report).
  • Wiring: LIN on the seat main connector pin 32 (green wire). You need a LIN master: a microcontroller (Arduino/ESP/etc.) + a LIN transceiver (TJA1020, MCP2003/4, or an ELM/ELIN-type master), 12 V on the transceiver, common ground.
  • The module only listens to ID 0x20. It publishes status on ID 0x09.

The command frame — ID 0x20
  • Frame ID 0x20 → protected ID (PID) 0x20 (parity bits both 0 for this ID).
  • 5 data bytes, enhanced checksum (checksum includes the PID).
  • On the wire: break + 0x55 (sync) + 0x20 (PID) + 5 data bytes + checksum.
  • It latches. Send once; the commanded state holds until you send the next 0x20 frame. No periodic keep-alive required (unlike the older SPA SHM that uses ID 0x30 / classic checksum — this is a different module).

Byte-by-byte
  • byte 0 — Seat heat target. target °C ≈ byte0 + 19. Valid 0x01–0x28; 0x00 and ≥0x29 = heat off. e.g. 0x10 = 35 °C (factory), 0x0A ≈ 29 °C, 0x18 ≈ 43 °C, 0x28 ≈ 60 °C (max). Heats both cushion and backrest; thermostats to the target and stops once reached. 60 °C is a hard ceiling — it cuts heating there even at max. (The 0x09 status reports the cushion sensor.)
  • byte 1 — Fan speed ramp rate. How fast the fans slew to the new byte 2 / byte 3 speed — changes only the transition, not the endpoint, which is why it's easy to miss. 0x00 = instant, 0xFF = slowest (higher = more damping); plain 0–255 scalar, no special bits. Factory value 0x0B (quick but slightly smoothed).
  • byte 2 — Cushion fan speed. Full 8-bit value, 0x00 = off up to 0xFF = full. Continuous/PWM-like, with a spin-up threshold somewhere above 0x3F.
  • byte 3 — Backrest fan speed. Same, for the backrest fans. 0x00 off … 0xFF full.
  • byte 4 — Soft reset (leave 0x00). bit 0 (0x01) reinitialises the module: it runs its power-on self-test (audible "startup" sound) and status byte 0 drops to 0x00 (init) before returning to idle. That's the 82/0A/02/8A flicker you'll see. Not an actuator. Keep it 0x00 in normal use.

Temperature encoding
  • Command (byte 0): target °C ≈ value + 19. So 0x04→23 °C, 0x10→35 °C, 0x18→43 °C, 0x28→~60 °C. ~1 °C per step, usable range 0x01–0x28. 60 °C is a hard ceiling.
  • Status (ID 0x09, byte 1): °C = raw × 0.5 − 40 (standard automotive scaling). e.g. 0x7F→23.5 °C, 0x96→35 °C, 0xA6→43 °C.

The status frame — ID 0x09

Poll ID 0x09, 2 data bytes:
  • byte 0 — state flags: 0x28 = idle, 0xAA = heating. bit 6 (0x40) set = the last command frame was rejected. A momentary 0x00 appears during a byte-4 reset.
  • byte 1 — cushion temperature, °C = raw × 0.5 − 40.

(The status only reports the cushion sensor; the backrest has no sensor here.)

Enhanced checksum (so you can build any frame)

Enhanced LIN checksum = inverted sum (with carry fold) of PID + all data bytes:

Code:
uint8_t lin_enhanced_checksum(uint8_t pid, const uint8_t *data, int n) {
    uint16_t sum = pid;
    for (int i = 0; i < n; i++) sum += data[i];
    while (sum > 0xFF) sum = (sum & 0xFF) + (sum >> 8);
    return (uint8_t)(0xFF - sum);
}

Worked example for 10 0B 3F 00 00 (PID 0x20):
0x20 + 0x10 + 0x0B + 0x3F + 0x00 + 0x00 = 0x7A → checksum 0xFF − 0x7A = 0x85.
On the wire: 55 20 10 0B 3F 00 00 85.

Ready-to-use frames (5 data bytes, ID 0x20)

Code:
Intent                              Data bytes
----------------------------------  ---------------
Factory capture (cushion 35 °C)     10 0B 3F 00 00
Heat low (cushion ~29 °C)           0A 00 00 00 00
Heat medium (cushion 35 °C)         10 00 00 00 00
Heat high (cushion ~43 °C)          18 00 00 00 00
Heat max (~60 °C)                   28 00 00 00 00
Cushion fan full, no heat           00 00 FF 00 00
Backrest fan full, no heat          00 00 00 FF 00
Both fans full, no heat             00 00 FF FF 00
Cushion 35 °C + both fans full      10 00 FF FF 00
Everything off                      00 00 00 00 00

(byte 1 is the fan ramp rate; 0x00 here = fastest/instant fan changes. Use the factory 0x0B if you want smoother fan transitions. byte 0 heats the whole seat, both cushion and backrest.)

Notes / gotchas
  • One module, not the old one. Older SPA seats used ID 0x30, PID 0xF0, 4 bytes, classic checksum, and needed a continuous keep-alive. This 2022 module is ID 0x20, 5 bytes, enhanced checksum, and latches. Don't mix the two protocols up.
  • Don't put two masters on one LIN line. If you tap the in-car bus while the car's own master is active you'll collide. Bench the module, or isolate the bus.
  • The massage function is not on this wire — it's a separate module inside the seat, reached over Body CAN / an internal LIN line.

Open questions (help confirm!)
  • byte 2/3 fan curves. Exact spin-up threshold and whether the low bits do anything beyond magnitude.
  • Why 0x29+ cuts out. 0x28 (~60 °C) is the max; values above it turn heat off rather than clamping — probably a range check treating out-of-range as off.

Credits

Reverse-engineered on the bench by capturing a working seat and sweeping each byte with a simple LIN master and a current-monitored bench supply.
Reply


Forum Jump:


Users browsing this thread: ElMyggo, 3 Guest(s)