[READ-ONLY] Mirror of https://github.com/SantaClaas/embedded-fan-control.
10 kB
Markdown
at main

Modbus relay module #

A Shenzhen LC / Elesai LC-Modbus-1R-D7: one relay output, one opto-isolated input, speaking Modbus RTU over RS-485 or a TTL UART. The controller drives it on a second Modbus bus — UART1 on GP8/GP9 with GP7 arbitrating a second transceiver, announced to Home Assistant as a switch. What follows is what talking to it from a desktop established first, so the firmware did not have to rediscover any of it; fan-controller/documentation.md carries the wiring.

The manufacturer's manual is docs/manufacturer/relay, in the private submodule, and serial/src/devices/relay.ts models the device from it. Everything below is our own observation of the module on the bench, which is a different thing and belongs in this repo: the model says what the manual claims, this says what the hardware did. They agree on the defaults, which is worth knowing — it means the manual was transcribed correctly for this device.

As it arrived #

Verified 2026-09-04 against the module on a USB RS-485 adapter, by reading its address, baud code and coil state and checking the CRC on each answer.

Setting Value How it is known
Device address 0xFF (255) 00 03 00 00 00 01 → 00 03 02 00 FF
Baud 9600 FF 03 03 E8 00 01 → FF 03 02 00 03, code 0x03
Framing 8 data bits, no parity, 1 stop bit Answers at 8N1, silent at 8E1
Relay Open, de-energized FF 01 00 00 00 08 → FF 01 01 00

All of that is the factory default, so the module is untouched. Address and baud are both stored in flash and survive a power cycle, so anything changed here is changed permanently.

The address read is worth keeping in the toolbox: 00 03 00 00 00 01 85 DB is answered whatever address the module is set to, so it finds a module whose address has been forgotten without sweeping all 255 of them.

Useful frames #

Addressed to 0xFF, CRC included. The module echoes writes back verbatim, which is how a write is confirmed — and which is why the tool has to know whether the adapter echoes too, since on an adapter that does, a coil write puts the same eight bytes on the line twice and only the second copy is the module's. It settles that from any read, where the request's bytes cannot be an answer.

Intent Send Expect
Relay on FF 05 00 00 FF 00 99 E4 the same bytes back
Relay off FF 05 00 00 00 00 D8 14 the same bytes back
Read relay state FF 01 00 00 00 08 28 12 FF 01 01 <bitmap>, bit 0 is this relay
Read opto input FF 02 00 00 00 08 6C 12 FF 02 01 <bitmap>
Read address 00 03 00 00 00 01 85 DB 00 03 02 00 <address>
Read baud FF 03 03 E8 00 01 11 A4 FF 03 02 00 <02=4800, 03=9600, 04=19200>
Set baud to 19200 FF 10 03 E9 00 01 02 00 04 CA 0E FF 10 03 E9 00 01 C5 A7

Send them as printed. The serial tool used to derive its requests from the register addresses alone, which gave FF 03 00 00 00 01 for the address, FF 01 00 00 00 01 for the coil and FF 02 00 00 00 01 for the input: one register each, which is what Modbus says a read of one register is, and three frames that appear in no manual. All three timed out. Only these printed forms have ever been answered, and a module whose manual is a list of worked examples is the wrong place to assume the general case works too — the address query in particular is defined as a question put to unit 0, not to the module's own address.

The relay's registers therefore name the frame they are read by, so a read plans to the rows above byte for byte, and devices.test.ts holds the planned frames to the same manufacturer bytes the CRC tests use. Whether the module would have answered the one-wide reads is untested and now moot.

Note the baud rate is read from 0x03E8 but written to 0x03E9 — two addresses for the one setting. That is not a transcription slip; it is what the manual's own worked examples do, and the read above was confirmed against the hardware.

There is also a flash mode that closes or opens the contact for a set time on its own, in units of 0.1 s, up to about 6553 s. Any use of it puts a timeout in the relay that the controller cannot see or cancel, so prefer holding the state from the firmware where the state is known.

The power-up banner #

Important

On power-up the module sends this down the line, unasked, before anything has requested anything:

Thank you for using the Modbus modules of LCTECH\r\n

It is not a Modbus frame and it does not belong to any request. Observed on the first exchange after power-up: it collided with a coil write, the echo came back as FF 05 00 00 FF 80 0A 54 68 61 6E 6B ... instead of FF 05 00 00 FF 00 99 E4, and the write was dropped — the relay stayed off. Every exchange after that was clean, and three on/off cycles ran without a single bad frame.

Two consequences for firmware that talks to this module:

  • Validate the CRC and resynchronize on it. Code that assumes the bytes arriving after a request are that request's answer will mis-frame, read the banner as a response, and be wrong about whether the relay switched.
  • Tolerate a dropped first exchange rather than flushing once at startup and trusting the line afterwards. The banner arrives whenever the module is powered, which is not the same moment the RP2040 boots — a brown-out on the relay's own supply produces it again with the controller already running and none the wiser.

The serial tool already does both. Its frame scan was never troubled by the banner — it searches for a valid frame at every offset, and ASCII cannot be mistaken for the relay's address of 0xFF — but the spoiled exchange leaves no frame to find at all, and that read used to fail as "no reply", which reads like a wrong address or a dead bus. It now separates a line that stayed silent from one that carried bytes it could not use, retries only the second, and quotes the stray bytes when they are legible, so the greeting names itself in the error.

The firmware's Modbus client no longer assumes the first two bytes after a request are the header either. It slides a two-byte window forward until the address and function code are the ones it asked for, bounded by 80 bytes and by the 3.5 characters of silence that end a burst, so a greeting in front of an answer costs a few milliseconds instead of the whole transaction. That is groundwork rather than a fix in use: this module cannot join the fans' bus at all, because they run 8E1 and it only answers 8N1, so nothing the controller drives today can hear the greeting.

What it needs for power #

Important

VCC/GND is a DC 7–24 V input, and the relay on the board is a 15 V type. The manual says so in as many words: "Power supply voltage: DC7-24V, with input anti-reverse connection protection".

Observed 2026-09-04, with VCC taken from a Pico W's VSYS pin and the grounds tied together: every read worked perfectly, and every coil write failed the same way. The module executed the write — its relay LED flickered — then never finished confirming it, and sent its power-up greeting instead. The tool's retry produced a byte-identical failure, 59 bytes both times: the few bytes the module managed of its answer, then the greeting.

That is a brown-out, and the 7–24 V rating is why. The MCU and the MAX485 run on far less than the board is rated for, so a module fed 5 V looks entirely healthy for as long as it is only talking. The coil is the first real load it ever sees, and there is nothing in reserve to meet it: the rail collapses, the MCU resets, the coil drops out — which is the flicker — and the module comes back up and greets the line, with the master still waiting for a confirmation that no longer has a sender.

VSYS is doubly the wrong place to take it from. It is below the module's minimum to begin with — about 4.7 V when the Pico is USB-powered, VBUS less a Schottky drop — and it is not a supply rail at all but the Pico's own input, feeding the RP2040's buck-boost converter and the CYW43 radio, both of which draw in bursts of their own.

Given 7–24 V from its own supply, with only the ground shared, the module switches on command and holds: the coil write is confirmed, and the relay opens and closes from the tool one press at a time. Take the supply from its own rail and share only the ground. The serial tool now names this rather than leaving it to be read out of the quoted bytes: any failure carrying the greeting is reported as the module having restarted mid-exchange, with the supply as the thing to suspect.

Why it cannot share the fan bus #

The fans run 19_200 baud, 8 data bits, even parity, 1 stop bit. The relay runs 8N1 and its manual never mentions parity at all; it answered nothing at 8E1 at any of the three baud rates. Baud is settable to 19200, parity is not settable at all, so the two cannot be made to agree.

A shared RS-485 segment is therefore off the table as the hardware stands, and the relay has a second UART rather than a place on the fan pair — GP8/GP9, which is the only pair UART1 has left here. If some later revision does put them together, re-address the relay off 0xFF first — the fans deliberately start at 0x02/0x03, skipping 0x01 as a likely factory default, and 0xFF is a likely default for the same reason.

Talking to it from a desktop #

The module hangs off a USB RS-485 adapter, /dev/cu.usbserial-160 on this machine, the same kind of adapter debug-listener uses to watch the fans. That path changes between machines and between plug-ins.

The serial tool is the normal way to do this — it opens the adapter from the browser and already knows this device's registers. The frames above are what to fall back on when talking to the module from a script instead.

Note

macOS refuses the port with Errno 16 Resource busy while anything else holds the matching /dev/tty.* — a browser tab with an open Web Serial connection is the easy one to forget. lsof /dev/tty.usbserial-160 names the process.