mx_remote/wire/tx.rs
1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! The transmit path: the protocol-floor gate, and the only way through it.
5
6use std::fmt;
7use std::io;
8
9use super::conn::Conn;
10use super::frame::build_frame;
11use super::opcode::{stamp_for, Opcode};
12use super::uid::DeviceUid;
13
14/// Why a frame was not sent.
15#[derive(Debug)]
16#[non_exhaustive]
17pub enum SendError {
18 /// The addressed device speaks a protocol older than the opcode requires.
19 ///
20 /// A receiver silently drops any frame stamped above its own version, with
21 /// no NAK, so sending anyway would report success and change nothing.
22 ProtocolTooOld {
23 /// Serial number of the addressed device.
24 serial: String,
25 /// The opcode that was refused.
26 opcode: u16,
27 /// The protocol version the device reports.
28 have: u16,
29 /// The version the opcode requires.
30 need: u16,
31 },
32 /// The opcode has no entry in the protocol table, so there is no version
33 /// to stamp it with.
34 ///
35 /// Nothing this library sends can reach this: every opcode it declares has
36 /// an entry, and a test holds that true. It exists so that adding one
37 /// without an entry fails at the send rather than going out stamped with a
38 /// guess.
39 UnknownOpcode {
40 /// The opcode that has no entry.
41 opcode: u16,
42 },
43 /// The client is not connected, because it was never started or has been
44 /// closed.
45 NotConnected,
46 /// The socket write failed.
47 Io(io::Error),
48}
49
50impl fmt::Display for SendError {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 Self::ProtocolTooOld {
54 serial,
55 opcode,
56 have,
57 need,
58 } => write!(
59 f,
60 "{serial} reports protocol {have:#04x}, opcode {opcode:#04x} needs {need:#04x}"
61 ),
62 Self::UnknownOpcode { opcode } => {
63 write!(f, "opcode {opcode:#04x} has no protocol table entry")
64 }
65 Self::NotConnected => write!(f, "not connected"),
66 Self::Io(e) => write!(f, "{e}"),
67 }
68 }
69}
70
71impl std::error::Error for SendError {
72 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
73 match self {
74 Self::Io(e) => Some(e),
75 _ => None,
76 }
77 }
78}
79
80impl From<io::Error> for SendError {
81 fn from(e: io::Error) -> Self {
82 Self::Io(e)
83 }
84}
85
86/// What the protocol floor is checked against.
87///
88/// A device reports the highest protocol version it can decode in its hello.
89pub(crate) trait ProtocolTarget {
90 /// Serial number, for the error message.
91 fn serial(&self) -> &str;
92 /// The version the device reports, or zero when it has not said.
93 fn supported_protocol(&self) -> u16;
94}
95
96/// Who a frame is addressed to.
97///
98/// Naming the recipient is a separate decision from building the payload,
99/// because the addressed device is a payload uid at an offset that differs per
100/// opcode while the opcode itself sits at a fixed place in the header. This is
101/// an enumeration rather than an `Option` so that a frame with no single
102/// recipient says so, instead of looking like one whose sender did not bother.
103#[derive(Clone, Debug)]
104pub(crate) enum Addressee {
105 /// Addressed to one device, whose protocol floor is checked.
106 Device {
107 /// Serial number, for the error message.
108 serial: String,
109 /// The version the device reports.
110 protocol: u16,
111 },
112 /// No single recipient: discovery, hello, the monitoring pulse and the
113 /// signal-status request that asks every bay on the network to report.
114 Broadcast,
115}
116
117impl Addressee {
118 /// Addresses a frame to a device.
119 pub(crate) fn device(target: &dyn ProtocolTarget) -> Self {
120 Self::Device {
121 serial: target.serial().to_owned(),
122 protocol: target.supported_protocol(),
123 }
124 }
125}
126
127/// The transmit side of a client: the socket, and this client's own identifier.
128///
129/// [`Tx::send`] is the only path from an opcode to the wire. It is the gate
130/// that refuses a frame the target cannot decode, and it can be the gate
131/// because the two things it sits between - the frame constructor and the
132/// socket write - are both private to this module and unreachable from
133/// anywhere else in the crate.
134/// A tap on the transmit path, for a test reading back what would go on the
135/// wire.
136#[cfg(test)]
137pub(crate) type TxTap = std::sync::Arc<dyn Fn(&[u8]) + Send + Sync>;
138
139#[derive(Default)]
140pub(crate) struct Tx {
141 conn: Option<std::sync::Arc<Conn>>,
142 /// Captures each frame that passes the gate. Frames are assembled inside
143 /// the method that sends them and cannot be reached any other way, so this
144 /// is how a test reads back what would go on the wire.
145 #[cfg(test)]
146 tap: Option<TxTap>,
147}
148
149impl Tx {
150 /// Replaces the socket.
151 pub(crate) fn set_conn(&mut self, conn: Option<Conn>) {
152 self.conn = conn.map(std::sync::Arc::new);
153 }
154
155 /// A handle on the socket that outlives this lock.
156 ///
157 /// The receive thread parks in the kernel for as long as its read timeout,
158 /// and must not hold the transmit lock while it does or every send would
159 /// wait behind it. Holding a share of the socket instead also settles what
160 /// a reconfiguration does to a thread already reading: the old socket stays
161 /// open until that read returns, so its descriptor cannot be reissued to
162 /// something else underneath it.
163 pub(crate) fn conn(&self) -> Option<std::sync::Arc<Conn>> {
164 self.conn.clone()
165 }
166
167 #[cfg(test)]
168 pub(crate) fn set_tap(&mut self, tap: TxTap) {
169 self.tap = Some(tap);
170 }
171
172 /// Builds a frame and writes it to the wire, unless `to` cannot decode it.
173 ///
174 /// The frame is stamped with the version the opcode itself needs rather
175 /// than the version this library speaks, so a device that caps lower still
176 /// accepts every opcode it does understand. The gate compares against that
177 /// same stamp: a receiver drops what is stamped above its own version, so
178 /// checking anything else would leave the hole the gate exists to close.
179 pub(crate) fn send(
180 &self,
181 to: &Addressee,
182 uid: DeviceUid,
183 opcode: Opcode,
184 payload: &[u8],
185 ) -> Result<usize, SendError> {
186 let need = stamp_for(opcode).ok_or(SendError::UnknownOpcode { opcode: opcode.0 })?;
187 if let Addressee::Device { serial, protocol } = to {
188 // A device that has not reported a version is let through: not
189 // knowing is not the same as knowing it is too old.
190 if *protocol != 0 && *protocol < need {
191 return Err(SendError::ProtocolTooOld {
192 serial: serial.clone(),
193 opcode: opcode.0,
194 have: *protocol,
195 need,
196 });
197 }
198 }
199
200 let frame = build_frame(uid, opcode, need, payload);
201 #[cfg(test)]
202 if let Some(tap) = &self.tap {
203 tap(&frame);
204 }
205 let conn = self.conn.as_ref().ok_or(SendError::NotConnected)?;
206 Ok(conn.send(&frame)?)
207 }
208}