1use std::collections::HashSet;
4use std::io;
5use std::io::ErrorKind;
6use std::net::{IpAddr, Ipv4Addr, SocketAddr};
7use std::time::{Duration, Instant};
8
9use bytes::{BufMut, BytesMut};
10use tokio::net::UdpSocket;
11use tokio::time;
12use tracing::{debug, info, trace, warn};
13
14use crate::gvcp::{GVCP_PORT, GvcpAckHeader, GvcpRequestHeader, consts};
15
16const ACTION_PAYLOAD: usize = 12;
21const ACTION_PAYLOAD_SCHEDULED: usize = ACTION_PAYLOAD + 8;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ActionParams {
30 pub device_key: u32,
32 pub group_key: u32,
34 pub group_mask: u32,
36 pub scheduled_time: Option<u64>,
43}
44
45#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
47pub struct AckSummary {
48 pub sent: usize,
50 pub acks: usize,
52}
53
54fn encode_payload(params: &ActionParams) -> BytesMut {
55 let mut buf = BytesMut::with_capacity(ACTION_PAYLOAD_SCHEDULED);
56 buf.put_u32(params.device_key);
57 buf.put_u32(params.group_key);
58 buf.put_u32(params.group_mask);
59 if let Some(ticks) = params.scheduled_time {
63 buf.put_u64(ticks);
64 }
65 buf
66}
67
68fn parse_ack(buf: &[u8]) -> io::Result<GvcpAckHeader> {
69 if buf.len() < 8 {
70 return Err(io::Error::new(
71 ErrorKind::InvalidData,
72 "acknowledgement shorter than GVCP header",
73 ));
74 }
75 let status = u16::from_be_bytes([buf[0], buf[1]]);
76 let opcode = u16::from_be_bytes([buf[2], buf[3]]);
77 let length = u16::from_be_bytes([buf[4], buf[5]]);
78 let request_id = u16::from_be_bytes([buf[6], buf[7]]);
79 Ok(GvcpAckHeader {
80 status: viva_gencp::StatusCode::from_raw(status),
81 command: opcode,
82 length,
83 request_id,
84 })
85}
86
87fn is_broadcast(addr: &SocketAddr) -> bool {
88 matches!(addr.ip(), IpAddr::V4(ip) if ip == Ipv4Addr::BROADCAST)
89}
90
91pub async fn send_action(
93 broadcast: SocketAddr,
94 params: &ActionParams,
95 timeout_ms: u64,
96) -> io::Result<AckSummary> {
97 let destination = SocketAddr::new(broadcast.ip(), GVCP_PORT);
98 let local_ip = match destination.ip() {
99 IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
100 IpAddr::V6(_) => {
101 return Err(io::Error::new(
102 ErrorKind::InvalidInput,
103 "IPv6 destinations are not supported for actions",
104 ));
105 }
106 };
107 let socket = UdpSocket::bind(SocketAddr::new(local_ip, 0)).await?;
108 if is_broadcast(&destination) {
109 socket.set_broadcast(true)?;
110 }
111
112 let mut summary = AckSummary::default();
113 let payload = encode_payload(params);
114 let request_id = fastrand::u16(0x8000..=0xFFFE);
115 let mut flags = viva_gencp::CommandFlags::ACK_REQUIRED;
116 if is_broadcast(&destination) {
117 flags |= viva_gencp::CommandFlags::BROADCAST;
118 }
119 if params.scheduled_time.is_some() {
120 flags |= viva_gencp::CommandFlags::SCHEDULED_ACTION;
121 }
122 let header = GvcpRequestHeader {
123 flags,
124 command: consts::ACTION_COMMAND,
125 length: payload.len() as u16,
126 request_id,
127 };
128 let packet = header.encode(&payload);
129 trace!(bytes = packet.len(), %destination, request_id, "sending action command");
130 socket.send_to(&packet, destination).await?;
131 summary.sent = 1;
132
133 let timeout = Duration::from_millis(timeout_ms);
134 if timeout.is_zero() {
135 info!(acks = 0, "action command sent (no wait)");
136 return Ok(summary);
137 }
138
139 let start = Instant::now();
140 let mut buf = vec![0u8; 512];
141 let mut seen = HashSet::new();
142 while let Some(remaining) = timeout.checked_sub(start.elapsed()) {
143 if remaining.is_zero() {
144 break;
145 }
146 match time::timeout(remaining, socket.recv_from(&mut buf)).await {
147 Ok(Ok((len, src))) => {
148 trace!(bytes = len, %src, "received acknowledgement");
149 let header = parse_ack(&buf[..len])?;
150 if header.command != consts::ACTION_ACK {
151 debug!(
152 opcode = header.command,
153 "ignoring unrelated acknowledgement"
154 );
155 continue;
156 }
157 if header.request_id != request_id {
158 debug!(
159 expected = request_id,
160 got = header.request_id,
161 "acknowledgement id mismatch"
162 );
163 continue;
164 }
165 if header.status != viva_gencp::StatusCode::Success {
166 warn!(status = ?header.status, %src, "device reported action failure");
167 continue;
168 }
169 if seen.insert(src.ip()) {
170 summary.acks += 1;
171 }
172 }
173 Ok(Err(err)) => {
174 warn!(?err, "error receiving acknowledgement");
175 break;
176 }
177 Err(_) => break,
178 }
179 }
180
181 info!(acks = summary.acks, "action command completed");
182 Ok(summary)
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn params() -> ActionParams {
190 ActionParams {
191 device_key: 0x1122_3344,
192 group_key: 0x5566_7788,
193 group_mask: 0xFFFF_0000,
194 scheduled_time: None,
195 }
196 }
197
198 #[test]
205 fn unscheduled_action_matches_spec_bytes() {
206 let payload = encode_payload(¶ms());
207 let packet = GvcpRequestHeader {
208 flags: viva_gencp::CommandFlags::ACK_REQUIRED,
209 command: consts::ACTION_COMMAND,
210 length: payload.len() as u16,
211 request_id: 0xBEEF,
212 }
213 .encode(&payload);
214
215 #[rustfmt::skip]
216 let expected: [u8; 20] = [
217 0x42, 0x01, 0x01, 0x00, 0x00, 0x0C, 0xBE, 0xEF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0xFF, 0xFF, 0x00, 0x00, ];
226 assert_eq!(&packet[..], &expected[..]);
227 }
228
229 #[test]
232 fn scheduled_action_sets_flag_and_appends_time() {
233 let mut p = params();
234 p.scheduled_time = Some(0x0102_0304_0506_0708);
235 let payload = encode_payload(&p);
236 let mut flags = viva_gencp::CommandFlags::ACK_REQUIRED;
237 flags |= viva_gencp::CommandFlags::SCHEDULED_ACTION;
238 let packet = GvcpRequestHeader {
239 flags,
240 command: consts::ACTION_COMMAND,
241 length: payload.len() as u16,
242 request_id: 0xBEEF,
243 }
244 .encode(&payload);
245
246 #[rustfmt::skip]
247 let expected: [u8; 28] = [
248 0x42,
249 0x81, 0x01, 0x00,
251 0x00, 0x14, 0xBE, 0xEF,
253 0x11, 0x22, 0x33, 0x44,
254 0x55, 0x66, 0x77, 0x88,
255 0xFF, 0xFF, 0x00, 0x00,
256 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
258 ];
259 assert_eq!(&packet[..], &expected[..]);
260 assert_eq!(payload.len(), ACTION_PAYLOAD_SCHEDULED);
261 }
262
263 #[test]
264 fn unscheduled_payload_stops_at_twelve_bytes() {
265 assert_eq!(encode_payload(¶ms()).len(), ACTION_PAYLOAD);
266 }
267
268 #[test]
270 fn action_opcodes_do_not_collide_with_register_access() {
271 assert_eq!(consts::ACTION_COMMAND, 0x0100);
272 assert_eq!(consts::ACTION_ACK, 0x0101);
273 assert_ne!(consts::ACTION_COMMAND, 0x0080); assert_ne!(consts::ACTION_ACK, 0x0081); }
276
277 #[test]
278 fn ack_parser() {
279 let mut buf = BytesMut::with_capacity(8);
280 buf.put_u16(viva_gencp::StatusCode::Success.to_raw());
281 buf.put_u16(consts::ACTION_ACK);
282 buf.put_u16(0);
283 buf.put_u16(0xBEEF);
284 let ack = parse_ack(&buf).expect("ack");
285 assert_eq!(ack.command, consts::ACTION_ACK);
286 assert_eq!(ack.request_id, 0xBEEF);
287 }
288}