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
#72
I got this seat working as well on my simracing setup, thank you all for this great project.
Reply
#73
Volvo SPA power seat: CAN frames (updated)

Context: I worked these out on a real car with a CANable, pressing each switch and logging the result. An AI assistant (Claude) helped analyse the captures and wrote this summary, so treat it as a working document, not an official reference. Everything comes from my captures; unconfirmed parts are marked.

New since the last version: the position switches stop being reported, and one frame keeps them coming
The slide, height, cushion and backrest switches only show in the module's switch frame for about 10 s after the last use of one, or after the knob was used. After that the seat still moves, but those switches no longer appear on the bus. The knob and its four buttons are always reported, and using them brings the others back.

Sending one frame keeps them reported:
Code:
ID 501   01 40 A0 21 00 00 00 00   repeated every 0.5 s
I found this by replaying a real S90's frames and narrowing them down: ID 501 alone was enough, and this one payload alone was enough. Not enough on their own: nothing sent, a diagnostic session, the car's real 0F0 sequence, and the earlier state 01 40 20 01. I have only tried that one payload alone, tested on the driver seat.

What I think it is (not confirmed): a network-management message, AUTOSAR style with partial networks.
  • 505, 506 and 501 all have byte 0 = the ID's last digit (the node number) and byte 1 = 40. In that scheme byte 1 40 is the "partial network information" bit and bytes 2 onward are partial-network request bits.
  • 505 and 506 are the seat modules' own frames (driver, passenger). Their byte 2 bit 6 comes on when the knob is used and goes off a few seconds later, which looks like the seat asking for its network.
  • 501 is node 1, some central module, saying what the car wants awake. In the S90 trace it changes as the ignition goes to position 2: A0 21 and later F7 FD 04 while on, 20 01 and 24 09 before and after.
I could not decode the individual bits of 501. A car stops sending it when it is off, so it should follow the ignition.

Commissioning
The module reads its own switches and drives its motors once it has seen this frame. Nothing else is commanded over CAN.
Code:
ID 0F0   80 32 00 00 D6 D4 00 00
A static copy works and the module latches it. On a real car bytes 1, 4 and 7 roll: byte 4 follows byte 1 (23 pairs seen, not a standard CRC-8) and byte 7's bits 5 and 7 flip between frames. That did not matter for the position switches.

Switch frames
A switch frame is held-state: the code stays while the switch is down and drops back to idle on release.
Code:
DRIVER      ID 090   [b0] 05 [b2] [b3] B8 00 00 00
  b0  bit 1 = a switch is down, bits 5-6 = menu (below)
  b2  01 / 02   seat position control up / down (height)
      04 / 08   seat cushion control up / down
      10 / 20 / 40 / 80   knob button up / down / forward / back
  b3  01        idle
      04 / 08   seat position control forward / back (slide)
      10 / 20   knob turned up / down
      40 / 80   backrest control forward / back
  idle: 68, 48 or 28 05 00 01 B8 00 00 00      down: b0 | 02

PASSENGER    ID 0A5   [b0] [b1] 00 00 00 00 00 00
  b0 = the driver's b2
  b1 = the driver's b3, but the idle bit is bit 1: idle 02
  ID 0A0   05 [b1] C0 00
  b1 bits 5-6 = menu, bit 3 = a switch is down (idle 61, 41 or 21)
Not yet confirmed: the passenger's slide back and knob buttons are the driver's codes mirrored; I haven't pressed them on the passenger seat.

The adjustment knob's menus
Turning the knob moves through three menus and stops at each end. Up goes to the top menu, down to the bottom.
Code:
bits 5-6 (090 b0 / 0A0 b1)   menu
  0x40  top      Side Support (the bolsters)
  0x20  middle   Lumbar Support
  0x60  bottom   Seat Cushion Extension
The knob's four buttons act on the selected menu:
Code:
forward           back          up                 down
Cushion extension   extends           retracts      nothing            nothing
Lumbar support      inflates          deflates      support moves up   support moves down
Side support        inflates bolsters deflates      nothing            nothing

Not on the bus
There is no live position of the slide, height, tilt or backrest in any frame, with the module kept awake or not.

Still unknown
  • Memory recall and store (on a real car these come from the driver door module). I need a real SPA car or the door module to capture them.
  • The bits of 501, and what the other 501 payloads do alone.
  • The adjustment lock the manual mentions; no frame seen.

Corrections welcome, and captures of the memory buttons most of all.
Reply


Forum Jump:


Users browsing this thread: 1 Guest(s)