diff --git a/CLAUDE.md b/CLAUDE.md index 8d914d4..05b8d5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,7 +150,9 @@ synchronization primitive as a `static`, and spawns tasks that communicate only Nothing shares mutable state directly. Pin assignments live in that destructuring: PIN_4 Modbus driver-enable, UART0 on PIN_12/PIN_13, -PIN_18 button, PIN_20/PIN_21 status LEDs, PIN_23/25/24/29 + PIO0 + DMA_CH0 for the CYW43 Wi-Fi chip. +PIN_7 driver-enable and UART1 on PIN_8/PIN_9 for the relay's bus, PIN_18 button, PIN_20/PIN_21 +status LEDs, PIN_23/25/24/29 + PIO0 + DMA_CH0 for the CYW43 Wi-Fi chip. GP8/GP9 is not a choice — +UART1's other pins here are the fans' driver-enable and a status LED. The primitive type encodes the intent, so pick deliberately when adding one: @@ -160,6 +162,9 @@ The primitive type encodes the intent, so pick deliberately when adding one: set point, published only after the fan acknowledged the Modbus write, and fanned out to both the display routine and the button routine. - `OnceLock` (`FANS`) — the Modbus client, so fan tasks await initialization rather than race it. +- Nothing at all for the relay's Modbus client: `relay_routine` is the only task on that bus, so it + owns the client outright. The mutex and once lock around `FANS` exist because four tasks reach + for that one, which is a reason to copy the pattern only where it applies. Flow of a speed change: @@ -175,6 +180,13 @@ Flow of a speed change: drives `LED_STATE` and publishes state back to MQTT via `OUT`. 5. `led_routine` renders `LedState`; an in-flight animation is cancelled when the state changes. +`relay_routine` is a second, much shorter flow on its own UART. `mqtt_brain_routine` turns a +command on the relay's topic into a `Signal`; the routine writes the coil, retries with the +same back-off the fans use, and publishes the state through `OUT` only once the module has echoed +the write. It reads the contact back on boot for the same reason the fans' speeds are read back, +and reports nothing at all rather than a guess when the module cannot be reached. The relay is on +its own bus because it answers 8N1 and only 8N1 while the fans run 8E1 — see `docs/relay.md`. + Independently of that flow, `sensor_routine` (pool of 2, one per fan address) polls what the fans measure about themselves every `SENSOR_POLL_INTERVAL` and publishes it through `OUT`. It shares the Modbus mutex with `fan_control_routine` and yields to it: a failed poll is logged and dropped @@ -199,7 +211,12 @@ be encoded straight into the TCP buffer without intermediate allocation — ther `MAX / 3`), which makes the button cycle through the same steps the Home Assistant slider shows rather than through arbitrary points on it. - Fan Modbus addresses start at `0x02`/`0x03`; `0x01` is avoided as a likely factory default. -- UART is 19_200 baud, 8 data bits, **even** parity, 1 stop bit. +- The fans' UART is 19_200 baud, 8 data bits, **even** parity, 1 stop bit. The relay's is 9_600 + 8N1, which is why it is a second UART rather than a third address — its parity is not settable. +- Coils are their own address space: `0x05` writes one and is confirmed by the device echoing the + request, `0x01` reads a run of them packed into bits. The relay is read eight coils at a time + although it has one, because that is the only frame its manual prints and the only one it + answers. - Sensor values live in *input* registers (function code `0x04`), which are read only, unlike the holding registers (`0x03` / `0x06`) the set point lives in. `ReadInputRegisters` asks for a range rather than one register, because a range costs the same round trip; the fan refuses more diff --git a/docs/relay.md b/docs/relay.md index 8b78bd3..a90305c 100644 --- a/docs/relay.md +++ b/docs/relay.md @@ -1,8 +1,10 @@ # 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. Not yet wired to the controller — this is what talking to it -from a desktop established, so the firmware does not have to rediscover it. +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](manufacturer/relay), in the private submodule, and [serial/src/devices/relay.ts](../serial/src/devices/relay.ts) models the device from @@ -144,8 +146,9 @@ The fans run 19_200 baud, 8 data bits, **even** parity, 1 stop bit. The relay ru 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 wants a -second UART rather than a place on the fan pair. If some later revision does put them together, +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. diff --git a/fan-controller/build.rs b/fan-controller/build.rs index 95c0d4f..19fe913 100644 --- a/fan-controller/build.rs +++ b/fan-controller/build.rs @@ -17,7 +17,7 @@ use std::rc::Rc; use std::str::Utf8Error; use home_assistant_discovery::{ - Component, Device, DeviceClass, DiscoveryPayload, ListOrString, Origin, StateClass, + Component, Device, DeviceClass, DiscoveryPayload, ListOrString, Origin, StateClass, SwitchClass, }; #[derive(Debug, thiserror::Error)] @@ -222,6 +222,19 @@ fn create_components() -> BTreeMap { ), ]); + // The relay on the second Modbus bus. Its state topic carries what the module confirmed rather + // than what it was asked for, so Home Assistant shows the contact rather than the command + components.insert( + topic::fan_controller::relay::UNIQUE_ID.to_string(), + Component::Switch { + name: Some("Relay 1"), + unique_id: Some(topic::fan_controller::relay::UNIQUE_ID), + state_topic: Some(topic::fan_controller::relay::state::STATE), + command_topic: topic::fan_controller::relay::state::COMMAND, + device_class: Some(SwitchClass::Switch), + }, + ); + components.extend(create_fan_sensor_components( "Fan 1", SensorIdentifiers { diff --git a/fan-controller/documentation.md b/fan-controller/documentation.md index 093bef5..dc2e0ed 100644 --- a/fan-controller/documentation.md +++ b/fan-controller/documentation.md @@ -48,7 +48,19 @@ After successfully joining the network it tries to look up Homeassistant under t Homeassistant needs to have the MQTT broker installed as the controller uses MQTT to connect to homeassitant and send data between them. After successful connection to the MQTT broker, the controller sends a discovery packet as defined by Homeassistant and the device should appear in Homeassistant on the dashboard when using the default Homeassistant configuration. -### 3. Sensors +### 3. What is announced + +Two fans, four sensors each, and one switch. The switch is the relay module on the second Modbus +bus: a plain contact, with no speed and nothing it measures. + +Its state topic carries what the module confirmed rather than what it was asked for, which is the +rule the fans follow too. A write that is never acknowledged leaves the last confirmed state +standing in Home Assistant rather than showing a command as though it had taken effect, and the +contact is read back on boot, so a controller that restarts while the relay is closed says so +instead of assuming it is open. If the module cannot be reached at all the switch stays unknown, +which is the honest answer rather than a guess. + +### 4. Sensors Alongside the two fans the device announces four sensors per fan: speed in rpm, motor temperature, electronics temperature, and power draw in watts. The fans are polled every 30 seconds, starting @@ -78,6 +90,8 @@ flowchart LR PICO[Raspberry Pi Pico W] -- "GP4 to DE/RE
GP12 to DI, GP13 to RO
3V3 and GND" --> TRANSCEIVER[RS-485 transceiver] TRANSCEIVER -- "A and B, twisted pair" --> FAN1[Fan 1, address 0x02] FAN1 -- "the same pair, daisy chained" --> FAN2[Fan 2, address 0x03] + PICO -- "GP7 to DE/RE
GP8 to DI, GP9 to RO
3V3 and GND" --> TRANSCEIVER2[RS-485 transceiver, second bus] + TRANSCEIVER2 -- "A and B, twisted pair" --> RELAY[Relay module, address 0xFF
own 7-24 V supply] PROBE[Debug probe, optional] -. "SWCLK, GND, SWDIO" .-> PICO ``` @@ -88,16 +102,24 @@ flowchart LR | 6 | GP4 | Output, idle low | `MODBUS_DE` | DE and RE on the transceiver, tied together | | 16 | GP12 | UART0 TX | `MODBUS_TX` | DI | | 17 | GP13 | UART0 RX | `MODBUS_RX` | RO | +| 10 | GP7 | Output, idle low | `RELAY_DE` | DE and RE on the second transceiver, tied together | +| 11 | GP8 | UART1 TX | `RELAY_TX` | DI on the second transceiver | +| 12 | GP9 | UART1 RX | `RELAY_RX` | RO on the second transceiver | | 24 | GP18 | Input, internal pull-up | `BUTTON` | One side of the button, the other side to GND | | 26 | GP20 | Output, active high | `LED_2` | LED 2 anode through a series resistor, cathode to GND | | 27 | GP21 | Output, active high | `LED_1` | LED 1 anode through a series resistor, cathode to GND | | 36 | 3V3(OUT) | Supply out | `+3V3` | Transceiver VCC | -| 38 | GND | — | `GND` | Transceiver GND, LED cathodes, button, RS-485 common | +| 38 | GND | — | `GND` | Transceiver GND, LED cathodes, button, RS-485 common, the relay module's supply ground | | On the module | GP23, GP24, GP25, GP29 | PIO0 + DMA0 | CYW43439 | Nothing. The Wi-Fi chip sits on the Pico W itself | Pin numbers are physical positions on the board, GPIO numbers are what the firmware calls them. Any of the eight ground pins will do; 38 is just the one nearest the signals on that side. +GP8 and GP9 are not a preference. They are the only pair UART1 can use here: its other transmit +pins are GP4, which arbitrates the fans' bus, and GP20, which drives a status LED. GP0 and GP1 are +free but left alone, because that is where a debug probe's UART bridge is conventionally wired and +this build has no reason to take them. + ### RS-485 to the fans The fans speak Modbus RTU on a two-wire bus, which is half duplex: the same pair carries the @@ -127,6 +149,38 @@ early truncates the frame, the fan rejects it on the checksum, and the result lo fan that is not answering. The line has to be back in the fan's hands well within the 3.5 characters of silence it waits before replying, which is about 2 ms at this baud rate. +### RS-485 to the relay module + +A second bus rather than two more devices on the fans'. The reason is framing: the fans run 8E1 and +the relay module answers 8N1 and only 8N1, its parity is not settable at all, and one UART speaks +one of those at a time. Its baud rate *is* settable, so the two could be made to agree on 19_200 — +but parity cannot, which settles it. `docs/relay.md` records where that was established on the +bench. + +Everything the fans' bus needs, this one needs too: a 3.3 V transceiver, DE and RE tied together to +GP7, 120 Ω across A and B at each end, and a third conductor tying the module's RS-485 common back +to controller ground. + +Three things are specific to this module: + +- **It needs its own supply.** `VCC`/`GND` on the board is a DC 7–24 V input and the relay on it is + a 15 V type. Powering it from the Pico's `VSYS` works right up until the coil pulls in, at which + point the rail collapses, the module resets, and the write is lost — with the relay LED flickering + as the contact drops out again. Give it 7–24 V of its own and share only the ground. +- **It greets the line when it powers up**, 49 bytes of ASCII, unasked and belonging to no request. + That follows the *module's* supply rather than the controller's boot, so it can arrive with the + firmware already running. The Modbus client steps over stray bytes while looking for a response + header, which is what makes a greeting in front of an answer cost a few milliseconds instead of + the transaction; a greeting that collides with the answer spoils that exchange outright, which is + what the retries are for. +- **It is at address `0xFF`**, its factory default, and left there. It is alone on this bus, so + there is nothing to collide with, and re-addressing writes a permanent change to its flash. + +The firmware asks for eight coils when it reads the contact, though the board has one. The module +is a one-relay variant of an eight-relay design and answers only the eight wide read its manual +prints — asking for the single coil that exists gets silence. Bit 0 of the byte that comes back is +the relay. + ### Button GP18 has the internal pull-up on and the firmware acts on the falling edge, so the switch just @@ -142,9 +196,14 @@ the wrong fan. ### Power -The Pico runs from USB or from a supply on VSYS, and the transceiver is the only thing hanging off -3V3(OUT). The fans have their own mains supply and none of it passes through this board; only the -RS-485 pair and its ground reference cross between the two. +The Pico runs from USB or from a supply on VSYS, and the two transceivers are the only things +hanging off 3V3(OUT). The fans have their own mains supply and none of it passes through this +board; only the RS-485 pair and its ground reference cross between the two. + +The relay module is the same arrangement and for a stronger reason: its 7–24 V supply is its own, +and only the RS-485 pair and a ground reference cross to the controller. It draws far more when its +coil pulls in than at rest, and a rail shared with the Pico is a rail that sags at exactly that +moment — see the note above. ### Debug probe diff --git a/fan-controller/src/main.rs b/fan-controller/src/main.rs index 04d7e93..a3ee354 100644 --- a/fan-controller/src/main.rs +++ b/fan-controller/src/main.rs @@ -12,7 +12,7 @@ use embassy_futures::select::{Either, Either3, select, select3}; use embassy_net::Stack; use embassy_rp::gpio::{Input, Level, Output, Pin, Pull}; use embassy_rp::peripherals::{ - DMA_CH0, PIN_4, PIN_18, PIN_20, PIN_21, PIN_23, PIN_25, PIO0, UART0, + DMA_CH0, PIN_4, PIN_7, PIN_18, PIN_20, PIN_21, PIN_23, PIN_25, PIO0, UART0, UART1, }; use embassy_rp::pio::{InterruptHandler as PioInterruptHandler, Pio, PioPin}; use embassy_rp::uart::BufferedInterruptHandler; @@ -40,12 +40,16 @@ mod debounce; mod fan; mod modbus; mod mqtt; +mod relay; mod reset_cause; mod task; bind_interrupts!(struct Irqs { PIO0_IRQ_0 => PioInterruptHandler; UART0_IRQ => BufferedInterruptHandler; + // The relay module's bus. Its own UART rather than an address on the fans': it answers 8N1 and + // only 8N1, and the fans run 8E1 + UART1_IRQ => BufferedInterruptHandler; }); #[embassy_executor::task] @@ -150,6 +154,21 @@ async fn input_routine( type ModbusMutex = Mutex>; type ModbusOnceLock = OnceLock; +/// The relay module's bus, which is owned outright rather than shared. +/// +/// The fans' client is behind a mutex and a once lock because four tasks reach for it — two that +/// set a speed and two that poll sensors — and behind the once lock so those tasks can await its +/// initialization rather than race it. Nothing of the sort applies here: one task talks to this +/// bus, so it holds the client itself and there is no lock for anything to wait on +type RelayClient = modbus::Client<'static, UART1, PIN_7>; + +/// The state Home Assistant last asked the contact to be in. Only the latest matters, the same way +/// only the latest set point does, so it is a signal rather than a channel +type RelayStateSignal = Signal; + +/// For the log, matching the identifiers the modbus client prints +const RELAY_IDENTIFIER: &str = "[Relay]"; + /// A set point signalled to a [`fan_control_routine`], together with where it came from. The /// origin is what decides whether a fan that cannot be reached drags the other one with it #[derive(Clone, Copy, Format)] @@ -394,6 +413,16 @@ enum SetStateCommandValue { Off, } +impl From for SetStateCommandValue { + fn from(is_on: bool) -> Self { + if is_on { + SetStateCommandValue::On + } else { + SetStateCommandValue::Off + } + } +} + impl From for SetStateCommandValue { fn from(speed: SetPoint) -> Self { if speed == SetPoint::ZERO { @@ -433,6 +462,8 @@ enum IncomingPublish { target: Fan, command: FanCommand, }, + /// Close or open the relay module's contact + RelayCommand(SetStateCommandValue), } enum FromPublishError { @@ -499,6 +530,11 @@ impl TryFrom> for IncomingPublish { command: FanCommand::SetSpeed { set_point }, }) } + topic::fan_controller::relay::state::COMMAND => match publish.payload { + b"ON" => Ok(Self::RelayCommand(SetStateCommandValue::On)), + b"OFF" => Ok(Self::RelayCommand(SetStateCommandValue::Off)), + _other => Err(FromPublishError::InvalidSetStateCommandPayload), + }, other => { warn!( "Unexpected topic: {} with payload: {}", @@ -559,6 +595,9 @@ enum OutgoingPublish { fan: Fan, payload: heapless::String<{ fan::sensor::JSON_CAPACITY }>, }, + /// Where the relay's contact is, published only once the module has confirmed the write. The + /// same rule the fans follow: what is reported is what the device did, not what it was asked + UpdateRelayState(SetStateCommandValue), } impl Publish for OutgoingPublish { @@ -588,6 +627,7 @@ impl Publish for OutgoingPublish { OutgoingPublish::UpdateSensors { fan: Fan::Two, .. } => { topic::fan_controller::fan_2::sensor::STATE } + OutgoingPublish::UpdateRelayState(_) => topic::fan_controller::relay::state::STATE, } } @@ -604,6 +644,10 @@ impl Publish for OutgoingPublish { SetStateCommandValue::Off => b"OFF", }, OutgoingPublish::UpdateSensors { fan: _, payload } => payload.as_bytes(), + OutgoingPublish::UpdateRelayState(payload) => match payload { + SetStateCommandValue::On => b"ON", + SetStateCommandValue::Off => b"OFF", + }, } } @@ -653,6 +697,7 @@ async fn mqtt_brain_routine( >, fan_one_state: &'static SetPointSignal, fan_two_state: &'static SetPointSignal, + relay_state: &'static RelayStateSignal, mut display_state: (DisplayStateReceiver, DisplayStateReceiver), ) { // Remembering the last speed the fans were running at for when Home Assistant turns the device @@ -727,6 +772,9 @@ async fn mqtt_brain_routine( } } }, + IncomingPublish::RelayCommand(new_state) => { + relay_state.signal(matches!(new_state, SetStateCommandValue::On)) + } IncomingPublish::FanCommand { target, command: FanCommand::SetState(new_state), @@ -841,6 +889,123 @@ async fn read_set_point( None } +/// Reads where the relay's contact is now. +/// +/// Retried like any other transaction, and for one reason beyond the usual: the module greets the +/// line in ASCII whenever *it* is powered, which is not the same moment the controller boots, and a +/// greeting that collides with a request leaves no frame to find at all. The client steps over a +/// greeting that merely precedes an answer; a spoiled exchange needs the second attempt. +/// +/// `None` when it cannot be reached, which is honest about not knowing rather than assuming the +/// contact is open. Nothing is published then, so Home Assistant shows the switch as unknown +/// instead of showing a guess +async fn read_relay_state(client: &mut RelayClient) -> Option { + let function = modbus::function::ReadCoils::<{ relay::coil::COUNT }>::new( + relay::address::RELAY, + relay::coil::RELAY, + ); + + let mut attempt = 1; + loop { + match client.read_coils(&function).await { + Ok(coils) => return Some(coils & relay::coil::RELAY_BIT != 0), + Err(error) if attempt >= MAX_ATTEMPTS => { + error!( + "{} Failed to read the contact after {} attempts: {:?}", + RELAY_IDENTIFIER, MAX_ATTEMPTS, error + ); + return None; + } + Err(error) => { + warn!( + "{} Failed to read the contact on attempt {}: {:?}", + RELAY_IDENTIFIER, attempt, error + ); + attempt += 1; + back_off(attempt).await; + } + } + } +} + +/// Drives the relay module on its own bus, and reports where its contact actually ended up. +/// +/// It holds the client rather than sharing it, because nothing else is on this UART. That also +/// makes the retry simpler than the fans': there is no lock to release between attempts, and no +/// second device whose transaction is being held up +#[embassy_executor::task] +async fn relay_routine( + mut client: RelayClient, + requested_state: &'static RelayStateSignal, + mqtt_out: channel::Sender<'static, CriticalSectionRawMutex, OutgoingPublish, CHANNEL_SIZE>, +) { + // The module holds its contact while the controller resets, so where it is now is the state to + // start from — the same reason the fans' speeds are read back rather than assumed + let mut current_state = read_relay_state(&mut client).await; + if let Some(is_closed) = current_state { + info!("{} Contact is closed: {}", RELAY_IDENTIFIER, is_closed); + mqtt_out.send(OutgoingPublish::UpdateRelayState(is_closed.into())).await; + } + + 'signal_loop: loop { + info!("{} Waiting for a state to be requested", RELAY_IDENTIFIER); + let is_closed = requested_state.wait().await; + + if current_state == Some(is_closed) { + info!( + "{} Requested state is the state it is already in", + RELAY_IDENTIFIER + ); + continue; + } + + let function = modbus::function::WriteSingleCoil::new( + relay::address::RELAY, + relay::coil::RELAY, + is_closed, + ); + + let mut attempt = 1; + loop { + match client.write_single_coil(&function).await { + Ok(()) => break, + Err(error) if attempt >= MAX_ATTEMPTS => { + error!( + "{} Failed to write the contact after {} attempts: {:?}", + RELAY_IDENTIFIER, MAX_ATTEMPTS, error + ); + + // The write may or may not have been carried out — a module that stops + // answering partway through an exchange has still received the request. Saying + // the state is unknown makes the next command attempt the write rather than + // skip it as already satisfied, and leaves Home Assistant showing the last + // state that was actually confirmed + current_state = None; + continue 'signal_loop; + } + Err(error) => { + warn!( + "{} Failed to write the contact on attempt {}: {:?}", + RELAY_IDENTIFIER, attempt, error + ); + attempt += 1; + + // A newer request supersedes this one rather than being made to wait behind it + if requested_state.signaled() { + continue 'signal_loop; + } + + back_off(attempt).await; + } + } + } + + info!("{} Contact is now closed: {}", RELAY_IDENTIFIER, is_closed); + current_state = Some(is_closed); + mqtt_out.send(OutgoingPublish::UpdateRelayState(is_closed.into())).await; + } +} + /// Receives the fan state updates and sends them to modbus as modbus messages /// After a successful response, this sends an update to the fan display logic unit #[embassy_executor::task(pool_size = 2)] @@ -1263,6 +1428,17 @@ async fn main(spawner: Spawner) { PIN_12: pin_12, // Receiver pin UART + Modbus PIN_13: pin_13, + // The relay module's bus. UART1's transmit and receive can only be GP8 and GP9 here: its + // other pairs are GP4, which is the fans' driver enable, and GP20/GP21, which are the + // status LEDs. GP0 and GP1 are left alone as well, since that is where a debug probe's + // UART bridge is conventionally wired + UART1: uart1, + // Driver enable for the relay's transceiver, next to the pair it belongs with + PIN_7: pin_7, + // Transmitter pin UART + Modbus, relay + PIN_8: pin_8, + // Receiver pin UART + Modbus, relay + PIN_9: pin_9, // Button pin PIN_18: pin_18, // Status LEDs @@ -1296,6 +1472,27 @@ async fn main(spawner: Spawner) { // Just initialize it _ = FANS.get_or_init(|| client.into()); + // The relay's bus. Its buffers are its own: two devices on two UARTs cannot share one, and the + // longest frame either direction carries here is the eight byte coil write and its echo + static RELAY_TX_BUFFER: StaticCell<[u8; 16]> = StaticCell::new(); + let relay_tx_buffer = &mut RELAY_TX_BUFFER.init([0; 16])[..]; + /// Larger than any answer the module sends, because what arrives is not always an answer: it + /// greets the line with 49 bytes of ASCII whenever it is powered, and room to take that in is + /// what lets the client step over it rather than read it as a frame + static RELAY_RX_BUFFER: StaticCell<[u8; 64]> = StaticCell::new(); + let relay_rx_buffer = &mut RELAY_RX_BUFFER.init([0; 64])[..]; + + let relay_client: RelayClient = modbus::client::Client::new( + uart1, + pin_8, + pin_9, + Irqs, + pin_7, + relay_tx_buffer, + relay_rx_buffer, + relay::get_configuration(), + ); + /// Channel for messages incoming from the MQTT broker to this fan controller static IN: Channel< CriticalSectionRawMutex, @@ -1376,14 +1573,23 @@ async fn main(spawner: Spawner) { .expect("Expected the watch to be configured for DISPLAY_STATE_RECEIVERS receivers"), ); + static RELAY_STATE: RelayStateSignal = Signal::new(); + let receiver_in = IN.receiver(); unwrap!(spawner.spawn(mqtt_brain_routine( receiver_in, &FAN_ONE_STATE, &FAN_TWO_STATE, + &RELAY_STATE, brain_receivers ))); + unwrap!(spawner.spawn(relay_routine( + relay_client, + &RELAY_STATE, + OUT.sender() + ))); + let display_fan_one_sender = FAN_ONE_DISPLAY_STATE.sender(); let display_fan_two_sender = FAN_TWO_DISPLAY_STATE.sender(); diff --git a/fan-controller/src/modbus/client.rs b/fan-controller/src/modbus/client.rs index 9d7c526..acf5009 100644 --- a/fan-controller/src/modbus/client.rs +++ b/fan-controller/src/modbus/client.rs @@ -11,7 +11,8 @@ use embedded_io_async::{Read, ReadExactError, Write}; use crate::{ configuration, modbus::function::{ - ReadHoldingRegister, ReadInputRegisters, WriteHoldingRegister, code, read_input_register, + ReadCoils, ReadHoldingRegister, ReadInputRegisters, WriteHoldingRegister, WriteSingleCoil, + code, read_input_register, }, }; @@ -189,6 +190,13 @@ const READ_RESPONSE_LENGTH: usize = 7; /// How many data bytes a read of the single register asked for has to announce const READ_BYTE_COUNT: u8 = 2; +/// A successful response to a read of eight or fewer coils: the header, the byte count, the one +/// byte the coils are packed into, and the checksum +const COILS_RESPONSE_LENGTH: usize = 6; + +/// Eight or fewer coils are packed into one data byte, so this is the only count that can arrive +const COILS_BYTE_COUNT: u8 = 1; + /// What a read response carries around its data bytes: the header, the byte count, and the /// checksum const READ_OVERHEAD_LENGTH: usize = HEADER_LENGTH + 1 + 2; @@ -218,11 +226,14 @@ const DISCARD_TIMEOUT: Duration = Duration::from_millis(5); /// forever const MAX_STRAY_BYTES: usize = 80; -/// Which of the two fans a device address belongs to, for the log -fn fan_identifier(device_address: u8) -> &'static str { +/// Which device an address belongs to, for the log. The fans and the relay module are on separate +/// buses and could not collide even if they shared a number, but one name for one address is +/// simpler to read back than two tables that have to be matched against a UART +fn device_identifier(device_address: u8) -> &'static str { match device_address { 2 => "[Fan 1]", 3 => "[Fan 2]", + 0xFF => "[Relay]", _other => "Unknown (oops)", } } @@ -242,19 +253,19 @@ fn unexpected_header( seen: [u8; HEADER_LENGTH], device_address: u8, function_code: u8, - fan_identifier: &str, + device_identifier: &str, ) -> ExchangeError { if seen[0] != device_address { warn!( "{} Response came from device address {:?} instead of {:?}", - fan_identifier, seen[0], device_address + device_identifier, seen[0], device_address ); return ExchangeError::DeviceAddress(seen[0]); } warn!( "{} Response used function code {:?} instead of {:?}", - fan_identifier, seen[1], function_code + device_identifier, seen[1], function_code ); ExchangeError::FunctionCode(seen[1]) } @@ -297,10 +308,54 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { &mut self, message: &WriteHoldingRegister, ) -> Result<(), WriteError> { - let fan_identifier = fan_identifier(*message.device_address()); + let device_identifier = device_identifier(*message.device_address()); - let result = self.transact_write(message, fan_identifier).await; - self.clear_line_after(&result, fan_identifier).await; + let result = self + .transact_write(message.as_ref(), IGNORED_SET_POINT_BITS, device_identifier) + .await; + self.clear_line_after(&result, device_identifier).await; + + result + } + + /// Closes or opens a coil and waits for the device to confirm it. + /// + /// The confirmation is the request sent back byte for byte, which is the same shape a written + /// holding register is acknowledged in, so it is the same transaction with nothing masked off + pub(crate) async fn write_single_coil( + &mut self, + message: &WriteSingleCoil, + ) -> Result<(), WriteError> { + let device_identifier = device_identifier(*message.device_address()); + + let result = self + .transact_write(message.as_ref(), 0, device_identifier) + .await; + self.clear_line_after(&result, device_identifier).await; + + result + } + + /// Reads up to eight coils, answered as one byte with the first coil in bit 0. + /// + /// How many are asked for is the device's business rather than the caller's: the relay module + /// answers only the eight wide read its manual prints and stays silent at any other, so + /// `COUNT` comes from the device rather than from how many coils are wanted + pub(crate) async fn read_coils( + &mut self, + message: &ReadCoils, + ) -> Result { + const { + assert!( + COUNT >= 1 && COUNT <= 8, + "a run of coils this reads has to fit the single data byte it reads back" + ) + }; + + let device_identifier = device_identifier(*message.device_address()); + + let result = self.transact_read_coils(message, device_identifier).await; + self.clear_line_after(&result, device_identifier).await; result } @@ -311,12 +366,12 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { &mut self, message: &ReadInputRegisters, ) -> Result<[u16; COUNT], ReadError> { - let fan_identifier = fan_identifier(*message.device_address()); + let device_identifier = device_identifier(*message.device_address()); let result = self - .transact_read_input_registers(message, fan_identifier) + .transact_read_input_registers(message, device_identifier) .await; - self.clear_line_after(&result, fan_identifier).await; + self.clear_line_after(&result, device_identifier).await; result } @@ -326,10 +381,10 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { &mut self, message: &ReadHoldingRegister, ) -> Result { - let fan_identifier = fan_identifier(*message.device_address()); + let device_identifier = device_identifier(*message.device_address()); - let result = self.transact_read(message, fan_identifier).await; - self.clear_line_after(&result, fan_identifier).await; + let result = self.transact_read(message, device_identifier).await; + self.clear_line_after(&result, device_identifier).await; result } @@ -337,26 +392,33 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { /// A failed transaction can leave part of a frame in the receive buffer. Dropping it keeps the /// next transaction from reading those leftovers as its own response. Both fans share this /// UART, so leftovers from one would otherwise be read as an answer from the other. - async fn clear_line_after(&mut self, result: &Result, fan_identifier: &str) { + async fn clear_line_after(&mut self, result: &Result, device_identifier: &str) { if result.is_err() { - self.discard_incoming(fan_identifier).await; + self.discard_incoming(device_identifier).await; } } + /// The write half of a transaction, for every function that is confirmed by the device sending + /// the request back byte for byte: a holding register, and a coil. + /// + /// `ignored_value_bits` are the bits of the written value that the device is allowed to answer + /// differently in. The fan is why: it ignores the four least significant bits of a set point, + /// and the specification does not say whether it echoes back the bits it received or the value + /// it stored. A coil has no such latitude and passes zero async fn transact_write( &mut self, - message: &WriteHoldingRegister, - fan_identifier: &str, + request: &[u8], + ignored_value_bits: u16, + device_identifier: &str, ) -> Result<(), WriteError> { - let request = message.as_ref(); - self.send_request(request, fan_identifier).await?; + self.send_request(request, device_identifier).await?; // The response is either an echo of the request or a shorter exception frame, so the // address and function code are read first to find out which one is arriving. Reading // exactly as many bytes as the frame holds leaves nothing behind for the next transaction. let mut response = [0u8; WRITE_RESPONSE_LENGTH]; if let Answer::Exception(code) = self - .read_header(&mut response, request, fan_identifier) + .read_header(&mut response, request, device_identifier) .await? { return Err(WriteError::Exception(code.into())); @@ -369,34 +431,33 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if !is_checksum_valid(&response) { warn!( "{} Response failed checksum: {:?}", - fan_identifier, response + device_identifier, response ); return Err(WriteError::EchoChecksum); } // The echo repeats the register and the value that were written. The register has to match - // exactly, but the fan ignores the four least significant bits of a set point, and the - // specification does not say whether it echoes back the bits it received or the value it - // stored. Masking those bits on both sides accepts either without accepting a real - // mismatch, and the checksum above still catches a corrupted frame + // exactly; the value is compared with the bits the device is allowed to differ in masked + // off on both sides, which accepts what it is entitled to answer without accepting a real + // mismatch. The checksum above still catches a corrupted frame let echoed_register = u16::from_be_bytes([response[2], response[3]]); let requested_register = u16::from_be_bytes([request[2], request[3]]); let echoed_value = u16::from_be_bytes([response[4], response[5]]); let requested_value = u16::from_be_bytes([request[4], request[5]]); if echoed_register != requested_register - || echoed_value & !IGNORED_SET_POINT_BITS != requested_value & !IGNORED_SET_POINT_BITS + || echoed_value & !ignored_value_bits != requested_value & !ignored_value_bits { warn!( "{} Response {:?} does not echo the request {:?}", - fan_identifier, response, request + device_identifier, response, request ); return Err(WriteError::EchoMismatch(response)); } info!( - "{} Fan acknowledged the write: {:?}", - fan_identifier, response + "{} Device acknowledged the write: {:?}", + device_identifier, response ); Ok(()) @@ -405,17 +466,17 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { async fn transact_read( &mut self, message: &ReadHoldingRegister, - fan_identifier: &str, + device_identifier: &str, ) -> Result { let request = message.as_ref(); - self.send_request(request, fan_identifier).await?; + self.send_request(request, device_identifier).await?; // Unlike the write, the answer does not repeat the request: it carries a byte count and // the register contents. Only one register was asked for, so its length is known in // advance and the byte count is a check rather than something to act on. let mut response = [0u8; READ_RESPONSE_LENGTH]; if let Answer::Exception(code) = self - .read_header(&mut response, request, fan_identifier) + .read_header(&mut response, request, device_identifier) .await? { return Err(ReadError::Exception(code.into())); @@ -431,7 +492,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if response[2] != READ_BYTE_COUNT { warn!( "{} Response announced {:?} data bytes instead of {:?}: {:?}", - fan_identifier, response[2], READ_BYTE_COUNT, response + device_identifier, response[2], READ_BYTE_COUNT, response ); return Err(ReadError::ByteCount(response[2])); } @@ -439,7 +500,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if !is_checksum_valid(&response) { warn!( "{} Response failed checksum: {:?}", - fan_identifier, response + device_identifier, response ); return Err(ReadError::ContentsChecksum); } @@ -447,19 +508,70 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { let value = u16::from_be_bytes([response[3], response[4]]); info!( "{} Fan answered the read with {:?}: {:?}", - fan_identifier, value, response + device_identifier, value, response ); Ok(value) } + async fn transact_read_coils( + &mut self, + message: &ReadCoils, + device_identifier: &str, + ) -> Result { + let request = message.as_ref(); + self.send_request(request, device_identifier).await?; + + // Like the register reads, the answer carries a byte count and the contents rather than + // repeating the request. A run of eight or fewer coils is one data byte whatever was asked + // for, because the device packs them into bits + let mut response = [0u8; COILS_RESPONSE_LENGTH]; + if let Answer::Exception(code) = self + .read_header(&mut response, request, device_identifier) + .await? + { + return Err(ReadError::Exception(code.into())); + } + + self.receive_exact(&mut response[HEADER_LENGTH..]) + .await + .map_err(ReadError::Contents)?; + + // Checked before the checksum, for the same reason the register read checks it first: a + // different byte count means a different frame length, and the checksum would then fail + // for a reason that does not name the actual problem + if response[2] != COILS_BYTE_COUNT { + warn!( + "{} Response announced {:?} data bytes instead of {:?}: {:?}", + device_identifier, response[2], COILS_BYTE_COUNT, response + ); + return Err(ReadError::ByteCount(response[2])); + } + + if !is_checksum_valid(&response) { + warn!( + "{} Response failed checksum: {:?}", + device_identifier, response + ); + return Err(ReadError::ContentsChecksum); + } + + let coils = response[3]; + info!( + "{} Device answered the coil read with {:?}: {:?}", + device_identifier, coils, response + ); + + Ok(coils) + } + async fn transact_read_input_registers( &mut self, message: &ReadInputRegisters, - fan_identifier: &str, + device_identifier: &str, ) -> Result<[u16; COUNT], ReadError> { let request = message.as_ref(); - self.send_request(request, fan_identifier).await?; + self.send_request(request, device_identifier).await?; // Like the holding register read the answer carries a byte count and the contents rather // than repeating the request, so its length is known from the count that was asked for. @@ -468,7 +580,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { let mut buffer = [0u8; MAX_INPUT_REGISTERS_RESPONSE_LENGTH]; let response = &mut buffer[..READ_OVERHEAD_LENGTH + 2 * COUNT]; - if let Answer::Exception(code) = self.read_header(response, request, fan_identifier).await? + if let Answer::Exception(code) = self.read_header(response, request, device_identifier).await? { return Err(ReadError::Exception(code.into())); } @@ -485,7 +597,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if response[2] != expected_byte_count { warn!( "{} Response announced {:?} data bytes instead of {:?}: {:?}", - fan_identifier, response[2], expected_byte_count, response + device_identifier, response[2], expected_byte_count, response ); return Err(ReadError::ByteCount(response[2])); } @@ -493,7 +605,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if !is_checksum_valid(response) { warn!( "{} Response failed checksum: {:?}", - fan_identifier, response + device_identifier, response ); return Err(ReadError::ContentsChecksum); } @@ -507,7 +619,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { info!( "{} Fan answered the read with {:?}: {:?}", - fan_identifier, registers, response + device_identifier, registers, response ); Ok(registers) @@ -517,26 +629,26 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { async fn send_request( &mut self, request: &[u8], - fan_identifier: &str, + device_identifier: &str, ) -> Result<(), ExchangeError> { // Write then read // Set pin setting DE (driver enable) to on (high) on the MAX485 to send data self.driver_enable.set_high(); - info!("{} Sending message to fan: {:?}", fan_identifier, request); + info!("{} Sending message to device: {:?}", device_identifier, request); // As ref because &[u8; 8] is not the same as &[u8] with_timeout(configuration::FAN_TIMEOUT, self.uart.write_all(request)) .await .map_err(|_timeout| ExchangeError::Request(SendFailure::Timeout))? .map_err(|_error| ExchangeError::Request(SendFailure::Uart))?; - info!("{} Request written", fan_identifier); + info!("{} Request written", device_identifier); // Flushing only drains the software buffer, which empties as soon as the interrupt handler // has moved the frame into the hardware FIFO. At that point none of it has reached the wire let result = self.uart.blocking_flush(); if let Err(_error) = result { - error!("{} UART flush error", fan_identifier); + error!("{} UART flush error", device_identifier); } // So wait for the transmitter itself to go idle. BUSY stays asserted until the FIFO has @@ -568,15 +680,15 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { &mut self, response: &mut [u8], request: &[u8], - fan_identifier: &str, + device_identifier: &str, ) -> Result { - info!("{} Waiting for response from fan", fan_identifier); + info!("{} Waiting for response from device", device_identifier); self.receive_exact(&mut response[..HEADER_LENGTH]) .await .map_err(|failure| ExchangeError::Response(Part::Header, failure))?; let function_code = request[1]; - self.find_answer_start(response, request[0], function_code, fan_identifier) + self.find_answer_start(response, request[0], function_code, device_identifier) .await?; if response[1] == function_code | code::EXCEPTION_MASK { @@ -588,14 +700,14 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if !is_checksum_valid(frame) { warn!( "{} Exception response failed checksum: {:?}", - fan_identifier, frame + device_identifier, frame ); return Err(ExchangeError::ExceptionChecksum); } error!( - "{} Fan rejected function code {:?} with modbus exception code {:?}", - fan_identifier, function_code, response[2] + "{} Device rejected function code {:?} with modbus exception code {:?}", + device_identifier, function_code, response[2] ); return Ok(Answer::Exception(response[2])); } @@ -625,7 +737,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { response: &mut [u8], device_address: u8, function_code: u8, - fan_identifier: &str, + device_identifier: &str, ) -> Result<(), ExchangeError> { // Kept for the failure, which is more useful naming what arrived than where the search // stopped @@ -636,13 +748,13 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if stepped_over == MAX_STRAY_BYTES { warn!( "{} No header in {:?} bytes, giving up on finding the answer", - fan_identifier, MAX_STRAY_BYTES + device_identifier, MAX_STRAY_BYTES ); return Err(unexpected_header( seen, device_address, function_code, - fan_identifier, + device_identifier, )); } @@ -656,7 +768,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { seen, device_address, function_code, - fan_identifier, + device_identifier, )); } @@ -666,7 +778,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if stepped_over > 0 { warn!( "{} Stepped over {:?} bytes that were not the answer, starting with {:?}", - fan_identifier, stepped_over, seen + device_identifier, stepped_over, seen ); } @@ -697,19 +809,19 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { /// Reads until the line has been silent for [`DISCARD_TIMEOUT`] to drop a partial or /// unexpected frame before the next transaction starts - async fn discard_incoming(&mut self, fan_identifier: &str) { + async fn discard_incoming(&mut self, device_identifier: &str) { let mut discarded = [0u8; WRITE_RESPONSE_LENGTH]; while let Ok(result) = with_timeout(DISCARD_TIMEOUT, self.uart.read(&mut discarded)).await { match result { Ok(0) => break, Ok(count) => info!( "{} Discarded {:?} unexpected bytes: {:?}", - fan_identifier, + device_identifier, count, &discarded[..count] ), Err(_error) => { - error!("{} UART error while clearing the line", fan_identifier); + error!("{} UART error while clearing the line", device_identifier); break; } } diff --git a/fan-controller/src/modbus/function/code.rs b/fan-controller/src/modbus/function/code.rs index 981f8d9..b22ef51 100644 --- a/fan-controller/src/modbus/function/code.rs +++ b/fan-controller/src/modbus/function/code.rs @@ -1,8 +1,16 @@ +/// Reads the state of a run of coils. The relay module answers only the eight wide form its +/// manual prints, whatever it has — see `docs/relay.md` +pub const READ_COILS: u8 = 0x01; + pub const READ_HOLDING_REGISTERS: u8 = 0x03; pub const READ_INPUT_REGISTERS: u8 = 0x04; pub const WRITE_SINGLE_REGISTER: u8 = 0x06; +/// Closes or opens one coil. Like [`WRITE_SINGLE_REGISTER`] it is confirmed by the device sending +/// the request back byte for byte +pub const WRITE_SINGLE_COIL: u8 = 0x05; + /// A device reports an error by responding with the function code of the request and this bit set pub const EXCEPTION_MASK: u8 = 0x80; diff --git a/fan-controller/src/modbus/function/mod.rs b/fan-controller/src/modbus/function/mod.rs index 43f5a55..c87dd1a 100644 --- a/fan-controller/src/modbus/function/mod.rs +++ b/fan-controller/src/modbus/function/mod.rs @@ -1,8 +1,12 @@ pub(super) mod code; +pub(crate) mod read_coils; pub(crate) mod read_holding_register; pub(crate) mod read_input_register; pub(crate) mod write_holding_register; +pub(crate) mod write_single_coil; +pub(crate) use read_coils::ReadCoils; pub(crate) use read_holding_register::ReadHoldingRegister; pub(crate) use read_input_register::ReadInputRegisters; pub(crate) use write_holding_register::WriteHoldingRegister; +pub(crate) use write_single_coil::WriteSingleCoil; diff --git a/fan-controller/src/modbus/function/read_coils.rs b/fan-controller/src/modbus/function/read_coils.rs new file mode 100644 index 0000000..d2523d8 --- /dev/null +++ b/fan-controller/src/modbus/function/read_coils.rs @@ -0,0 +1,46 @@ +use crate::modbus; + +/// Reads a run of coils, of which only the first is asked about here. +/// +/// `COUNT` is how many coils the request asks for, not how many are wanted. The relay module is +/// why the distinction exists: it is a one relay variant of an eight relay design, and its manual +/// prints only the eight wide read. It answers that frame and stays silent at any other, so the +/// count is the device's to dictate rather than the caller's to minimise — `docs/relay.md` records +/// where that was established +pub(crate) struct ReadCoils([u8; 8]); + +impl ReadCoils { + pub(crate) fn new( + device_address: modbus::device::Address, + first_coil: modbus::register::Address, + ) -> Self { + let first_coil = first_coil.to_be_bytes(); + let count = COUNT.to_be_bytes(); + let mut data = [ + *device_address, + modbus::function::code::READ_COILS, + first_coil[0], + first_coil[1], + count[0], + count[1], + // CRC set in next step + 0, + 0, + ]; + + let checksum = modbus::CRC.checksum(&data[..6]).to_be_bytes(); + data[6] = checksum[1]; + data[7] = checksum[0]; + Self(data) + } + + pub(crate) fn device_address(&self) -> modbus::device::Address { + self.0[0].into() + } +} + +impl AsRef<[u8]> for ReadCoils { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/fan-controller/src/modbus/function/write_single_coil.rs b/fan-controller/src/modbus/function/write_single_coil.rs new file mode 100644 index 0000000..b6cfcee --- /dev/null +++ b/fan-controller/src/modbus/function/write_single_coil.rs @@ -0,0 +1,50 @@ +use crate::modbus; + +/// Closes or opens one coil. +/// +/// The same eight byte shape as [`super::WriteHoldingRegister`], with the value carrying no +/// quantity: `0xFF00` closes the contact and `0x0000` opens it, and nothing else is allowed. The +/// device confirms by sending the request back byte for byte +pub(crate) struct WriteSingleCoil([u8; 8]); + +/// The only two values a coil write may carry. Anything else is a malformed request rather than +/// an intermediate state — a coil is closed or it is open +const CLOSED: u16 = 0xFF00; +const OPEN: u16 = 0x0000; + +impl WriteSingleCoil { + pub(crate) fn new( + device_address: modbus::device::Address, + coil_address: modbus::register::Address, + is_closed: bool, + ) -> Self { + let coil_address = coil_address.to_be_bytes(); + let value = if is_closed { CLOSED } else { OPEN }; + let mut data = [ + *device_address, + modbus::function::code::WRITE_SINGLE_COIL, + coil_address[0], + coil_address[1], + (value >> 8) as u8, + value as u8, + // CRC set in next step + 0, + 0, + ]; + + let checksum = modbus::CRC.checksum(&data[..6]).to_be_bytes(); + data[6] = checksum[1]; + data[7] = checksum[0]; + Self(data) + } + + pub(crate) fn device_address(&self) -> modbus::device::Address { + self.0[0].into() + } +} + +impl AsRef<[u8]> for WriteSingleCoil { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/fan-controller/src/relay/mod.rs b/fan-controller/src/relay/mod.rs new file mode 100644 index 0000000..089b889 --- /dev/null +++ b/fan-controller/src/relay/mod.rs @@ -0,0 +1,50 @@ +//! The Modbus relay module on the controller's second bus. +//! +//! One contact and nothing else: no speed, nothing it measures. What makes it a second bus rather +//! than another address on the fans' is its framing — it answers 8N1 and only 8N1, while the fans +//! run 8E1, and its parity is not configurable at all. `docs/relay.md` records where that was +//! established, along with what it needs for power and the greeting it sends whenever it boots. + +use embassy_rp::uart::{self, DataBits, Parity, StopBits}; + +/// Its line settings, which are the factory defaults and cannot be brought closer to the fans': +/// the baud rate is settable, the parity is not +pub(crate) const BAUD_RATE: u32 = 9_600; + +pub(crate) fn get_configuration() -> uart::Config { + let mut configuration: uart::Config = uart::Config::default(); + configuration.baudrate = BAUD_RATE; + configuration.data_bits = DataBits::DataBits8; + configuration.parity = Parity::ParityNone; + configuration.stop_bits = StopBits::STOP1; + configuration +} + +pub(crate) mod address { + use crate::modbus; + + /// The address the module ships with, and the one every frame in its manual uses. Left alone + /// deliberately: it is the only device on this bus, so there is nothing to collide with, and + /// re-addressing it writes a permanent change to its flash + pub(crate) const RELAY: modbus::device::Address = modbus::device::Address::new(0xFF); +} + +pub(super) mod coil { + use crate::modbus; + + /// Relay 1. The board is a one relay variant of an eight relay design, so its manual lists + /// 0x0000 … 0x0007 and only the first of them exists here + pub(crate) const RELAY: modbus::register::Address = modbus::register::Address::new(0x0000_u16); + + /// How many coils a read has to ask for. + /// + /// Not one, although one is all there is. The manual prints only the eight wide read — the + /// full width of the design this board is a variant of — and the module answers that frame and + /// stays silent at any other, so asking for what exists gets nothing back. Bit 0 of the byte + /// that comes back is this relay; the rest belong to relays the board does not have. See + /// `docs/relay.md` + pub(crate) const COUNT: u16 = 8; + + /// Which bit of the byte a coil read answers with is the relay + pub(crate) const RELAY_BIT: u8 = 0b0000_0001; +} diff --git a/fan-controller/src/task.rs b/fan-controller/src/task.rs index 832f665..861dd2d 100644 --- a/fan-controller/src/task.rs +++ b/fan-controller/src/task.rs @@ -455,7 +455,7 @@ const SUBSCRIBE_OPTIONS: mqtt::packet::subscribe::Options = mqtt::packet::subscr mqtt::packet::subscribe::RetainHandling::SendAtSubscribe, ); -const SUBSCRIPTIONS_LENGTH: usize = 5; +const SUBSCRIPTIONS_LENGTH: usize = 6; // Subscribe to home assistant topics const SUBSCRIPTIONS: [Subscription; SUBSCRIPTIONS_LENGTH] = [ Subscription { @@ -478,6 +478,10 @@ const SUBSCRIPTIONS: [Subscription; SUBSCRIPTIONS_LENGTH] = [ topic_filter: topic::fan_controller::fan_2::percentage::COMMAND, options: SUBSCRIBE_OPTIONS, }, + Subscription { + topic_filter: topic::fan_controller::relay::state::COMMAND, + options: SUBSCRIBE_OPTIONS, + }, ]; /// Trait must be implemented by types that represent messages that can be published to MQTT. diff --git a/home_assistant_discovery/src/lib.rs b/home_assistant_discovery/src/lib.rs index edbe89b..459722e 100644 --- a/home_assistant_discovery/src/lib.rs +++ b/home_assistant_discovery/src/lib.rs @@ -94,6 +94,15 @@ pub enum StateClass { TotalIncreasing, } +/// What kind of thing a switch controls, which Home Assistant uses only to pick an icon. +/// See https://www.home-assistant.io/integrations/switch.mqtt/#device_class +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SwitchClass { + Outlet, + Switch, +} + /// Internally tagged by the required `platform` (`p`) field #[derive(Serialize)] #[serde(tag = "p", rename_all = "lowercase")] @@ -124,6 +133,26 @@ pub enum Component { #[serde(rename = "spd_rng_max")] speed_range_max: Option, }, + /// A plain on/off control. What the relay module is: one contact, commanded and reported, with + /// no speed and nothing measured + Switch { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<&'static str>, + #[serde(rename = "uniq_id")] + #[serde(skip_serializing_if = "Option::is_none")] + unique_id: Option<&'static str>, + /// Where the confirmed state arrives. Without it Home Assistant assumes the command took + /// effect, which for a device that can refuse or go silent would be a guess shown as fact + #[serde(rename = "stat_t")] + #[serde(skip_serializing_if = "Option::is_none")] + state_topic: Option<&'static str>, + #[serde(rename = "cmd_t")] + command_topic: &'static str, + /// `switch` or `outlet`, which only decides the icon Home Assistant draws + #[serde(rename = "dev_cla")] + #[serde(skip_serializing_if = "Option::is_none")] + device_class: Option, + }, Sensor { /// The name of the sensor. Owned rather than borrowed, unlike the name of a fan, because /// both fans report the same five values and each name is composed from the fan it diff --git a/topic/src/lib.rs b/topic/src/lib.rs index 534c109..f5b621d 100644 --- a/topic/src/lib.rs +++ b/topic/src/lib.rs @@ -23,6 +23,27 @@ pub mod fan_controller { /// This topic is used by Home Assistant to notify the fan controller to turn on or off. pub const COMMAND: &str = formatcp!("{OBJECT_ID}/on/set"); + /// The relay module on the controller's second Modbus bus. + /// + /// It has one contact and nothing else — no speed, nothing it measures — so unlike a fan it is + /// a plain on/off pair of topics + pub mod relay { + use super::OBJECT_ID; + use const_format::formatcp; + + pub const UNIQUE_ID: &str = formatcp!("{OBJECT_ID}/relay-1"); + + pub mod state { + use super::UNIQUE_ID; + use const_format::formatcp; + + /// Published after the module has confirmed the coil write, never before it + pub const STATE: &str = formatcp!("{UNIQUE_ID}/on/state"); + /// Subscribed to for Home Assistant asking the contact to open or close + pub const COMMAND: &str = formatcp!("{UNIQUE_ID}/on/set"); + } + } + pub mod fan_1 { use super::OBJECT_ID; use const_format::formatcp;