Skip to content

Commit cc0c0d4

Browse files
committed
autosave (pavel-am5 / Linux)
1 parent 795c8ab commit cc0c0d4

File tree

4 files changed

+277
-56
lines changed

4 files changed

+277
-56
lines changed

flake.lock

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

pkg/mqtt-controller/crates/mqtt-controller/src/config/time_expr.rs

Lines changed: 221 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
//! Time expression: either a fixed `HH:MM` or a sun-relative
2-
//! `sunrise/sunset ± HH:MM`.
1+
//! Time expression: either a fixed `HH:MM`, a sun-relative
2+
//! `sunrise/sunset ± HH:MM`, or `max(a, b)` / `min(a, b)` of two
3+
//! sub-expressions.
34
//!
45
//! Parsed from a string in the JSON config. Examples:
5-
//! - `"06:00"` → Fixed 06:00
6-
//! - `"23:00"` → Fixed 23:00
7-
//! - `"24:00"` → Fixed 24:00 (exclusive end-of-day sentinel)
8-
//! - `"sunset"` → Sun-relative, sunset + 0
9-
//! - `"sunset-01:00"` → Sun-relative, sunset − 60 min
10-
//! - `"sunrise+01:30"` → Sun-relative, sunrise + 90 min
6+
//! - `"06:00"` → Fixed 06:00
7+
//! - `"23:00"` → Fixed 23:00
8+
//! - `"24:00"` → Fixed 24:00 (exclusive end-of-day sentinel)
9+
//! - `"sunset"` → Sun-relative, sunset + 0
10+
//! - `"sunset-01:00"` → Sun-relative, sunset − 60 min
11+
//! - `"sunrise+01:30"` → Sun-relative, sunrise + 90 min
12+
//! - `"max(sunset+01:00, 23:00)"` → The later of two times
13+
//! - `"min(sunrise-01:00, 05:00)"` → The earlier of two times
1114
1215
use std::fmt;
1316
use std::str::FromStr;
@@ -24,6 +27,10 @@ pub enum TimeExpr {
2427
Fixed { minute_of_day: u16 },
2528
/// A time relative to a solar event.
2629
SunRelative { event: SunEvent, offset_minutes: i16 },
30+
/// The maximum (later) of two sub-expressions.
31+
Max(Box<TimeExpr>, Box<TimeExpr>),
32+
/// The minimum (earlier) of two sub-expressions.
33+
Min(Box<TimeExpr>, Box<TimeExpr>),
2734
}
2835

2936
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -35,7 +42,8 @@ pub enum SunEvent {
3542
impl TimeExpr {
3643
/// Resolve to minutes-since-midnight (0..=1440). For `Fixed`, returns
3744
/// the stored value directly. For `SunRelative`, computes base + offset
38-
/// using the provided sun times.
45+
/// using the provided sun times. For `Max`/`Min`, resolves both operands
46+
/// and returns the later/earlier.
3947
pub fn resolve(&self, sun: Option<&SunTimes>) -> u16 {
4048
match self {
4149
TimeExpr::Fixed { minute_of_day } => *minute_of_day,
@@ -48,12 +56,18 @@ impl TimeExpr {
4856
let raw = base as i32 + *offset_minutes as i32;
4957
raw.clamp(0, 1440) as u16
5058
}
59+
TimeExpr::Max(a, b) => a.resolve(sun).max(b.resolve(sun)),
60+
TimeExpr::Min(a, b) => a.resolve(sun).min(b.resolve(sun)),
5161
}
5262
}
5363

5464
/// True if this expression depends on solar position.
5565
pub fn uses_sun(&self) -> bool {
56-
matches!(self, TimeExpr::SunRelative { .. })
66+
match self {
67+
TimeExpr::Fixed { .. } => false,
68+
TimeExpr::SunRelative { .. } => true,
69+
TimeExpr::Max(a, b) | TimeExpr::Min(a, b) => a.uses_sun() || b.uses_sun(),
70+
}
5771
}
5872
}
5973

@@ -89,61 +103,135 @@ pub(crate) fn parse_hhmm(s: &str) -> Result<(u8, u8), ParseTimeExprError> {
89103
Ok((h, m))
90104
}
91105

106+
/// Parse a single atomic time expression (fixed or sun-relative, no max/min).
107+
fn parse_atom(s: &str) -> Result<TimeExpr, ParseTimeExprError> {
108+
for (prefix, event) in [("sunrise", SunEvent::Sunrise), ("sunset", SunEvent::Sunset)] {
109+
if let Some(rest) = s.strip_prefix(prefix) {
110+
if rest.is_empty() {
111+
return Ok(TimeExpr::SunRelative { event, offset_minutes: 0 });
112+
}
113+
let (sign, hhmm) = if let Some(hhmm) = rest.strip_prefix('+') {
114+
(1i16, hhmm)
115+
} else if let Some(hhmm) = rest.strip_prefix('-') {
116+
(-1i16, hhmm)
117+
} else {
118+
return Err(ParseTimeExprError(format!(
119+
"{s:?}: expected +HH:MM or -HH:MM after {prefix}"
120+
)));
121+
};
122+
let (h, m) = parse_hhmm(hhmm)?;
123+
let offset = sign * (h as i16 * 60 + m as i16);
124+
return Ok(TimeExpr::SunRelative { event, offset_minutes: offset });
125+
}
126+
}
127+
let (h, m) = parse_hhmm(s)?;
128+
Ok(TimeExpr::Fixed { minute_of_day: h as u16 * 60 + m as u16 })
129+
}
130+
131+
/// Parse `max(a, b)` or `min(a, b)`. Returns `None` if the string doesn't
132+
/// start with `max(` or `min(`.
133+
fn parse_minmax(s: &str) -> Option<Result<TimeExpr, ParseTimeExprError>> {
134+
let (func, rest) = if let Some(rest) = s.strip_prefix("max(") {
135+
("max", rest)
136+
} else if let Some(rest) = s.strip_prefix("min(") {
137+
("min", rest)
138+
} else {
139+
return None;
140+
};
141+
142+
let inner = match rest.strip_suffix(')') {
143+
Some(inner) => inner,
144+
None => return Some(Err(ParseTimeExprError(format!(
145+
"{s:?}: missing closing parenthesis"
146+
)))),
147+
};
148+
149+
// Split on ", " (comma + space). Both operands are atoms (no nesting).
150+
let (left, right) = match inner.split_once(", ") {
151+
Some(pair) => pair,
152+
None => return Some(Err(ParseTimeExprError(format!(
153+
"{s:?}: expected two comma-separated arguments inside {func}()"
154+
)))),
155+
};
156+
157+
let left = match left.trim().parse::<TimeExpr>() {
158+
Ok(e) => e,
159+
Err(e) => return Some(Err(e)),
160+
};
161+
let right = match right.trim().parse::<TimeExpr>() {
162+
Ok(e) => e,
163+
Err(e) => return Some(Err(e)),
164+
};
165+
166+
Some(Ok(match func {
167+
"max" => TimeExpr::Max(Box::new(left), Box::new(right)),
168+
"min" => TimeExpr::Min(Box::new(left), Box::new(right)),
169+
_ => unreachable!(),
170+
}))
171+
}
172+
92173
impl FromStr for TimeExpr {
93174
type Err = ParseTimeExprError;
94175

95176
fn from_str(s: &str) -> Result<Self, Self::Err> {
96-
// Try sun-relative first.
97-
for (prefix, event) in [("sunrise", SunEvent::Sunrise), ("sunset", SunEvent::Sunset)] {
98-
if let Some(rest) = s.strip_prefix(prefix) {
99-
if rest.is_empty() {
100-
return Ok(TimeExpr::SunRelative { event, offset_minutes: 0 });
101-
}
102-
let (sign, hhmm) = if let Some(hhmm) = rest.strip_prefix('+') {
103-
(1i16, hhmm)
104-
} else if let Some(hhmm) = rest.strip_prefix('-') {
105-
(-1i16, hhmm)
106-
} else {
107-
return Err(ParseTimeExprError(format!(
108-
"{s:?}: expected +HH:MM or -HH:MM after {prefix}"
109-
)));
110-
};
111-
let (h, m) = parse_hhmm(hhmm)?;
112-
let offset = sign * (h as i16 * 60 + m as i16);
113-
return Ok(TimeExpr::SunRelative { event, offset_minutes: offset });
114-
}
177+
// Try max/min wrapper first.
178+
if let Some(result) = parse_minmax(s) {
179+
return result;
115180
}
116-
// Fixed time.
117-
let (h, m) = parse_hhmm(s)?;
118-
Ok(TimeExpr::Fixed { minute_of_day: h as u16 * 60 + m as u16 })
181+
// Atom: fixed or sun-relative.
182+
parse_atom(s)
119183
}
120184
}
121185

122186
// ---- Display ---------------------------------------------------------------
123187

188+
fn fmt_atom(f: &mut fmt::Formatter<'_>, expr: &TimeExpr) -> fmt::Result {
189+
match expr {
190+
TimeExpr::Fixed { minute_of_day } => {
191+
let h = minute_of_day / 60;
192+
let m = minute_of_day % 60;
193+
write!(f, "{h:02}:{m:02}")
194+
}
195+
TimeExpr::SunRelative { event, offset_minutes } => {
196+
let name = match event {
197+
SunEvent::Sunrise => "sunrise",
198+
SunEvent::Sunset => "sunset",
199+
};
200+
if *offset_minutes == 0 {
201+
write!(f, "{name}")
202+
} else {
203+
let sign = if *offset_minutes > 0 { '+' } else { '-' };
204+
let abs = offset_minutes.unsigned_abs();
205+
let h = abs / 60;
206+
let m = abs % 60;
207+
write!(f, "{name}{sign}{h:02}:{m:02}")
208+
}
209+
}
210+
// Max/Min are handled by the main Display impl, not here.
211+
TimeExpr::Max(_, _) | TimeExpr::Min(_, _) => {
212+
write!(f, "{expr}")
213+
}
214+
}
215+
}
216+
124217
impl fmt::Display for TimeExpr {
125218
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126219
match self {
127-
TimeExpr::Fixed { minute_of_day } => {
128-
let h = minute_of_day / 60;
129-
let m = minute_of_day % 60;
130-
write!(f, "{h:02}:{m:02}")
220+
TimeExpr::Max(a, b) => {
221+
write!(f, "max(")?;
222+
fmt_atom(f, a)?;
223+
write!(f, ", ")?;
224+
fmt_atom(f, b)?;
225+
write!(f, ")")
131226
}
132-
TimeExpr::SunRelative { event, offset_minutes } => {
133-
let name = match event {
134-
SunEvent::Sunrise => "sunrise",
135-
SunEvent::Sunset => "sunset",
136-
};
137-
if *offset_minutes == 0 {
138-
write!(f, "{name}")
139-
} else {
140-
let sign = if *offset_minutes > 0 { '+' } else { '-' };
141-
let abs = offset_minutes.unsigned_abs();
142-
let h = abs / 60;
143-
let m = abs % 60;
144-
write!(f, "{name}{sign}{h:02}:{m:02}")
145-
}
227+
TimeExpr::Min(a, b) => {
228+
write!(f, "min(")?;
229+
fmt_atom(f, a)?;
230+
write!(f, ", ")?;
231+
fmt_atom(f, b)?;
232+
write!(f, ")")
146233
}
234+
other => fmt_atom(f, other),
147235
}
148236
}
149237
}
@@ -200,19 +288,49 @@ mod tests {
200288
);
201289
}
202290

291+
#[test]
292+
fn parse_max() {
293+
let expr = "max(sunset+01:00, 23:00)".parse::<TimeExpr>().unwrap();
294+
assert_eq!(
295+
expr,
296+
TimeExpr::Max(
297+
Box::new(TimeExpr::SunRelative { event: SunEvent::Sunset, offset_minutes: 60 }),
298+
Box::new(TimeExpr::Fixed { minute_of_day: 1380 }),
299+
)
300+
);
301+
}
302+
303+
#[test]
304+
fn parse_min() {
305+
let expr = "min(sunrise-01:00, 05:00)".parse::<TimeExpr>().unwrap();
306+
assert_eq!(
307+
expr,
308+
TimeExpr::Min(
309+
Box::new(TimeExpr::SunRelative { event: SunEvent::Sunrise, offset_minutes: -60 }),
310+
Box::new(TimeExpr::Fixed { minute_of_day: 300 }),
311+
)
312+
);
313+
}
314+
203315
#[test]
204316
fn parse_rejects_invalid() {
205317
assert!("25:00".parse::<TimeExpr>().is_err());
206318
assert!("12:60".parse::<TimeExpr>().is_err());
207319
assert!("sunset*01:00".parse::<TimeExpr>().is_err());
208320
assert!("noon".parse::<TimeExpr>().is_err());
321+
assert!("max(23:00)".parse::<TimeExpr>().is_err()); // missing second arg
322+
assert!("max(23:00, ".parse::<TimeExpr>().is_err()); // missing closing paren
209323
}
210324

211325
#[test]
212326
fn display_roundtrip() {
213-
for s in ["06:00", "23:00", "24:00", "00:00", "sunrise", "sunset-01:00", "sunrise+01:30"] {
327+
for s in [
328+
"06:00", "23:00", "24:00", "00:00",
329+
"sunrise", "sunset-01:00", "sunrise+01:30",
330+
"max(sunset+01:00, 23:00)", "min(sunrise-01:00, 05:00)",
331+
] {
214332
let expr: TimeExpr = s.parse().unwrap();
215-
assert_eq!(expr.to_string(), s);
333+
assert_eq!(expr.to_string(), s, "display roundtrip failed for {s}");
216334
}
217335
}
218336

@@ -225,6 +343,14 @@ mod tests {
225343
assert_eq!(back, expr);
226344
}
227345

346+
#[test]
347+
fn serde_roundtrip_max() {
348+
let expr: TimeExpr = "max(sunset+01:00, 23:00)".parse().unwrap();
349+
let json = serde_json::to_string(&expr).unwrap();
350+
let back: TimeExpr = serde_json::from_str(&json).unwrap();
351+
assert_eq!(back, expr);
352+
}
353+
228354
#[test]
229355
fn resolve_fixed() {
230356
let expr = TimeExpr::Fixed { minute_of_day: 360 };
@@ -244,4 +370,47 @@ mod tests {
244370
let expr = TimeExpr::SunRelative { event: SunEvent::Sunrise, offset_minutes: -60 };
245371
assert_eq!(expr.resolve(Some(&sun)), 0); // clamped, not underflow
246372
}
373+
374+
#[test]
375+
fn resolve_max_picks_later() {
376+
// Summer: sunset at 22:10 (1330), +1h = 23:10 (1390)
377+
// max(sunset+01:00, 23:00) = max(1390, 1380) = 1390
378+
let sun = SunTimes { sunrise_minute_of_day: 260, sunset_minute_of_day: 1330 };
379+
let expr: TimeExpr = "max(sunset+01:00, 23:00)".parse().unwrap();
380+
assert_eq!(expr.resolve(Some(&sun)), 1390);
381+
382+
// Winter: sunset at 16:30 (990), +1h = 17:30 (1050)
383+
// max(sunset+01:00, 23:00) = max(1050, 1380) = 1380
384+
let sun = SunTimes { sunrise_minute_of_day: 530, sunset_minute_of_day: 990 };
385+
assert_eq!(expr.resolve(Some(&sun)), 1380);
386+
}
387+
388+
#[test]
389+
fn resolve_min_picks_earlier() {
390+
// Summer: sunrise at 04:20 (260), -1h = 03:20 (200)
391+
// min(sunrise-01:00, 05:00) = min(200, 300) = 200
392+
let sun = SunTimes { sunrise_minute_of_day: 260, sunset_minute_of_day: 1330 };
393+
let expr: TimeExpr = "min(sunrise-01:00, 05:00)".parse().unwrap();
394+
assert_eq!(expr.resolve(Some(&sun)), 200);
395+
396+
// Winter: sunrise at 08:50 (530), -1h = 07:50 (470)
397+
// min(sunrise-01:00, 05:00) = min(470, 300) = 300
398+
let sun = SunTimes { sunrise_minute_of_day: 530, sunset_minute_of_day: 990 };
399+
assert_eq!(expr.resolve(Some(&sun)), 300);
400+
}
401+
402+
#[test]
403+
fn uses_sun_propagates_through_max_min() {
404+
let fixed: TimeExpr = "23:00".parse().unwrap();
405+
assert!(!fixed.uses_sun());
406+
407+
let sun_expr: TimeExpr = "sunset+01:00".parse().unwrap();
408+
assert!(sun_expr.uses_sun());
409+
410+
let max_expr: TimeExpr = "max(sunset+01:00, 23:00)".parse().unwrap();
411+
assert!(max_expr.uses_sun());
412+
413+
let min_fixed: TimeExpr = "min(05:00, 06:00)".parse().unwrap();
414+
assert!(!min_fixed.uses_sun());
415+
}
247416
}

private

0 commit comments

Comments
 (0)