Skip to content

Commit 4f06b21

Browse files
committed
blocks[battery[apc_ups]]: Deserialize APC UPS data
Created a new `Value` enum to represent the different types of values that are used. Created a new `Properties` struct to represent the properties of the APC UPS data. Use `Framed` with `LengthDelimitedCodec` to decode the frames of data, before deserializing them into the `Properties` struct. Added the ability to decode not only the time remaining in minutes, but also seconds or hours.
1 parent fd6b17a commit 4f06b21

File tree

3 files changed

+93
-120
lines changed

3 files changed

+93
-120
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ rustdoc-args = ["--cfg", "docsrs"]
3232
async-trait = "0.1"
3333
backon = { version = "1.2", default-features = false, features = ["tokio-sleep"] }
3434
base64 = { version = "0.22.1" }
35+
bytes = "1.8"
3536
calibright = { version = "0.1.13", features = ["watch"] }
3637
chrono = { version = "0.4", default-features = false, features = ["clock", "unstable-locales"] }
3738
chrono-tz = { version = "0.10", features = ["serde"] }
@@ -74,6 +75,7 @@ smart-default = "0.7"
7475
sunrise = "2.1"
7576
swayipc-async = "2.0.1"
7677
thiserror = "2.0"
78+
tokio-util = { version = "0.7", features = ["codec"] }
7779
toml = { version = "0.8", features = ["preserve_order"] }
7880
unicode-segmentation = "1.10.1"
7981
wayrs-client = { version = "1.0", features = ["tokio"] }

src/blocks/battery/apc_ups.rs

Lines changed: 89 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,58 @@
1-
use std::str::FromStr;
1+
use bytes::Bytes;
2+
use futures::SinkExt as _;
3+
4+
use serde::de;
25
use tokio::net::TcpStream;
36
use tokio::time::Interval;
7+
use tokio_util::codec::{Framed, LengthDelimitedCodec};
48

59
use super::{BatteryDevice, BatteryInfo, BatteryStatus, DeviceName};
610
use crate::blocks::prelude::*;
711

8-
#[derive(Debug, Default)]
9-
struct PropertyMap(HashMap<String, String>);
10-
1112
make_log_macro!(debug, "battery[apc_ups]");
1213

13-
impl PropertyMap {
14-
fn insert(&mut self, k: String, v: String) -> Option<String> {
15-
self.0.insert(k, v)
16-
}
17-
18-
fn get(&self, k: &str) -> Option<&str> {
19-
self.0.get(k).map(|v| v.as_str())
20-
}
14+
#[derive(Debug, SmartDefault)]
15+
enum Value {
16+
String(String),
17+
// The value is a percentage (0-100)
18+
Percent(f64),
19+
Watts(f64),
20+
Seconds(f64),
21+
#[default]
22+
None,
23+
}
2124

22-
fn get_property<T: FromStr + Send + Sync>(
23-
&self,
24-
property_name: &str,
25-
required_unit: &str,
26-
) -> Result<T> {
27-
let stat = self
28-
.get(property_name)
29-
.or_error(|| format!("{property_name} not in apc ups data"))?;
30-
let (value, unit) = stat
31-
.split_once(' ')
32-
.or_error(|| format!("could not split {property_name}"))?;
33-
if unit == required_unit {
34-
value
35-
.parse::<T>()
36-
.map_err(|_| Error::new("Could not parse data"))
37-
} else {
38-
Err(Error::new(format!(
39-
"Expected unit for {property_name} are {required_unit}, but got {unit}"
40-
)))
25+
impl<'de> Deserialize<'de> for Value {
26+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
27+
where
28+
D: de::Deserializer<'de>,
29+
{
30+
let s = String::deserialize(deserializer)?;
31+
for unit in ["Percent", "Watts", "Seconds", "Minutes", "Hours"] {
32+
if let Some(stripped) = s.strip_suffix(unit) {
33+
let value = stripped.trim().parse::<f64>().map_err(de::Error::custom)?;
34+
return Ok(match unit {
35+
"Percent" => Value::Percent(value),
36+
"Watts" => Value::Watts(value),
37+
"Seconds" => Value::Seconds(value),
38+
"Minutes" => Value::Seconds(value * 60.0),
39+
"Hours" => Value::Seconds(value * 3600.0),
40+
_ => unreachable!(),
41+
});
42+
}
4143
}
44+
Ok(Value::String(s))
4245
}
4346
}
4447

45-
#[derive(Debug)]
46-
struct ApcConnection(TcpStream);
47-
48-
impl ApcConnection {
49-
async fn connect(addr: &str) -> Result<Self> {
50-
Ok(Self(
51-
TcpStream::connect(addr)
52-
.await
53-
.error("Failed to connect to socket")?,
54-
))
55-
}
56-
57-
async fn write(&mut self, msg: &[u8]) -> Result<()> {
58-
let msg_len = u16::try_from(msg.len())
59-
.error("msg is too long, it must be less than 2^16 characters long")?;
60-
61-
self.0
62-
.write_u16(msg_len)
63-
.await
64-
.error("Could not write message length to socket")?;
65-
self.0
66-
.write_all(msg)
67-
.await
68-
.error("Could not write message to socket")?;
69-
Ok(())
70-
}
71-
72-
async fn read_line<'a>(&'_ mut self, buf: &'a mut Vec<u8>) -> Result<Option<&'a str>> {
73-
let read_size = self
74-
.0
75-
.read_u16()
76-
.await
77-
.error("Could not read response length from socket")?
78-
.into();
79-
if read_size == 0 {
80-
return Ok(None);
81-
}
82-
83-
buf.resize(read_size, 0);
84-
self.0
85-
.read_exact(buf)
86-
.await
87-
.error("Could not read from socket")?;
88-
89-
std::str::from_utf8(buf).error("invalid UTF8").map(Some)
90-
}
48+
#[derive(Debug, Deserialize, Default)]
49+
#[serde(rename_all = "UPPERCASE", default)]
50+
struct Properties {
51+
status: Value,
52+
bcharge: Value,
53+
nompower: Value,
54+
loadpct: Value,
55+
timeleft: Value,
9156
}
9257

9358
pub(super) struct Device {
@@ -104,21 +69,40 @@ impl Device {
10469
})
10570
}
10671

107-
async fn get_status(&mut self) -> Result<PropertyMap> {
108-
let mut conn = ApcConnection::connect(&self.addr).await?;
72+
async fn get_status(&mut self) -> Result<Properties> {
73+
let mut conn = Framed::new(
74+
TcpStream::connect(&self.addr)
75+
.await
76+
.error("Failed to connect to socket")?,
77+
LengthDelimitedCodec::builder()
78+
.length_field_type::<u16>()
79+
.new_codec(),
80+
);
10981

110-
conn.write(b"status").await?;
82+
conn.send(Bytes::from_static(b"status"))
83+
.await
84+
.error("Could not send message to socket")?;
85+
conn.close().await.error("Could not close socket sink")?;
11186

112-
let mut buf = vec![];
113-
let mut property_map = PropertyMap::default();
87+
let mut map = serde_json::Map::new();
11488

115-
while let Some(line) = conn.read_line(&mut buf).await? {
116-
if let Some((key, value)) = line.split_once(':') {
117-
property_map.insert(key.trim().to_string(), value.trim().to_string());
89+
while let Some(frame) = conn.next().await {
90+
let frame = frame.error("Failed to read from socket")?;
91+
if frame.is_empty() {
92+
continue;
11893
}
94+
let line = std::str::from_utf8(&frame).error("Failed to convert to UTF-8")?;
95+
let Some((key, value)) = line.split_once(':') else {
96+
debug!("Invalid field format: {line:?}");
97+
continue;
98+
};
99+
map.insert(
100+
key.trim().to_uppercase(),
101+
serde_json::Value::String(value.trim().to_string()),
102+
);
119103
}
120104

121-
Ok(property_map)
105+
serde_json::from_value(serde_json::Value::Object(map)).error("Failed to deserialize")
122106
}
123107
}
124108

@@ -134,48 +118,33 @@ impl BatteryDevice for Device {
134118
})
135119
.unwrap_or_default();
136120

137-
let status_str = status_data.get("STATUS").unwrap_or("COMMLOST");
121+
let Value::String(status_str) = status_data.status else {
122+
return Ok(None);
123+
};
124+
125+
let status = match &*status_str {
126+
"ONBATT" => BatteryStatus::Discharging,
127+
"ONLINE" => BatteryStatus::Charging,
128+
_ => BatteryStatus::Unknown,
129+
};
138130

139131
// Even if the connection is valid, in the first few seconds
140132
// after apcupsd starts BCHARGE may not be present
141-
let capacity = status_data
142-
.get_property::<f64>("BCHARGE", "Percent")
143-
.unwrap_or(f64::MIN);
144-
145-
if status_str == "COMMLOST" || capacity == f64::MIN {
133+
let Value::Percent(capacity) = status_data.bcharge else {
146134
return Ok(None);
147-
}
135+
};
148136

149-
let status = if status_str == "ONBATT" {
150-
if capacity == 0.0 {
151-
BatteryStatus::Empty
152-
} else {
153-
BatteryStatus::Discharging
137+
let power = match (status_data.nompower, status_data.loadpct) {
138+
(Value::Watts(nominal_power), Value::Percent(load_percent)) => {
139+
Some(nominal_power * load_percent / 100.0)
154140
}
155-
} else if status_str == "ONLINE" {
156-
if capacity == 100.0 {
157-
BatteryStatus::Full
158-
} else {
159-
BatteryStatus::Charging
160-
}
161-
} else {
162-
BatteryStatus::Unknown
141+
_ => None,
163142
};
164143

165-
let power = status_data
166-
.get_property::<f64>("NOMPOWER", "Watts")
167-
.ok()
168-
.and_then(|nominal_power| {
169-
status_data
170-
.get_property::<f64>("LOADPCT", "Percent")
171-
.ok()
172-
.map(|load_percent| nominal_power * load_percent / 100.0)
173-
});
174-
175-
let time_remaining = status_data
176-
.get_property::<f64>("TIMELEFT", "Minutes")
177-
.ok()
178-
.map(|e| e * 60_f64);
144+
let time_remaining = match status_data.timeleft {
145+
Value::Seconds(time_left) => Some(time_left),
146+
_ => None,
147+
};
179148

180149
Ok(Some(BatteryInfo {
181150
status,

0 commit comments

Comments
 (0)