inverter 0.1.0

Hybrid solar/battery inverter control: telemetry, modes, and honest command-expiry semantics
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
//! Control hybrid solar/battery inverters over Modbus.
//!
//! The crate exists to make one distinction impossible to ignore: **how a
//! command ends**. Telling an inverter to charge for twenty minutes and
//! programming a daily window that starts at the same moment look identical
//! at most APIs, and they are not the same thing. The first stops on its own
//! if the controller dies; the second repeats tomorrow, and every day after,
//! with nobody left to cancel it. See [`Expiry`].
//!
//! # Sign conventions
//!
//! Every driver reports telemetry with these signs, whatever the inverter's
//! own convention is:
//!
//! * `battery_kw > 0` — charging (power into the cells)
//! * `grid_kw > 0` — importing; `< 0` — exporting
//! * `load_kw >= 0` — household consumption
//! * `solar_kw >= 0` — PV generation, `0.0` when the model cannot report it
//!
//! All powers are kilowatts and energies kilowatt-hours. Anywhere a power is
//! taken, any numeric type convertible to `f64` is accepted: `charge(2)` and
//! `charge(2.5)` both work.
//!
//! # Example
//!
//! ```
//! # #[cfg(feature = "mock")]
//! # fn main() -> Result<(), inverter::Error> {
//! use inverter::{Inverter, InverterExt, Mode, mock::MockInverter};
//!
//! let mut inv = MockInverter::new();
//! let caps = inv.capabilities();
//! assert!(caps.can_write);
//!
//! let telemetry = inv.read_telemetry()?;
//! println!("battery at {}%", telemetry.soc_pct);
//!
//! // Or single values, and the mode currently in force:
//! let soc = inv.soc_pct()?;
//! assert_eq!(inv.mode()?, Mode::Passive);
//!
//! if caps.supports(Mode::ForceCharge) {
//!     // Sugar for inv.apply(Command::charge(2)). Powers are kilowatts.
//!     let applied = inv.charge(2)?;
//!     // How this command ends is data, not an assumption.
//!     println!("expires: {:?}", applied.expiry);
//! }
//! # Ok(())
//! # }
//! # #[cfg(not(feature = "mock"))]
//! # fn main() {}
//! ```

#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]

use std::time::{Duration, Instant, SystemTime};

pub mod register;

pub mod modbus;

#[cfg(feature = "foxess")]
pub mod foxess;

#[cfg(feature = "mock")]
pub mod mock;

/// Everything that can go wrong talking to an inverter.
///
/// Drivers report failures rather than returning plausible-looking data: a
/// zero that came from a dropped frame is far more dangerous than an error.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The transport failed, or the inverter's reply was unusable.
    #[error("communication error: {0}")]
    Comm(String),

    /// A write succeeded but reading the register back returned another value.
    #[error("read-back mismatch: {0}")]
    Readback(String),

    /// A value could not be represented in the target register.
    #[error("value out of range: {0}")]
    Range(String),

    /// The driver does not implement this operation for this model.
    ///
    /// Prefer checking [`Capabilities`] first — this exists for the case where
    /// a caller commands something the inverter turned out not to accept.
    #[error("unsupported: {0}")]
    Unsupported(String),
}

/// What the inverter should be doing.
///
/// [`Passive`](Mode::Passive) is the inverter's own behaviour; the other two
/// override it. The overrides are what need [`Expiry`] semantics — passive
/// has no power level and nothing to expire, which is what makes it the safe
/// fallback.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Mode {
    /// The controller steps out of the way: the inverter runs its own
    /// self-use logic, exactly as it would with no controller attached.
    ///
    /// Concretely: solar powers the house; surplus charges the battery, then
    /// exports once the battery is full; after dark the battery covers the
    /// house down to the inverter's configured minimum state of charge, then
    /// the grid takes over. "Passive" describes the *controller's* stance —
    /// the hardware is busy. Vendors call this "self-use",
    /// "self-consumption" or "general" mode.
    ///
    /// This is the state every writable driver must be able to return to,
    /// the state a caller should fall back to when unsure, and the state a
    /// dead controller's hardware should decay to.
    Passive,
    /// Force energy into the battery now, importing from the grid when solar
    /// cannot cover the requested power.
    ///
    /// Overrides the self-use economics — this is how a controller buys a
    /// cheap tariff window.
    ForceCharge,
    /// Force energy out of the battery now.
    ///
    /// Where the energy goes — household load only, or deliberately out past
    /// the meter — is the command's [`DischargeTarget`].
    ForceDischarge,
}

impl Mode {
    /// Stable lowercase identifier, for logs and configuration.
    ///
    /// [`Display`](std::fmt::Display) prints the same identifier; parse it
    /// back with [`str::parse`].
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            Mode::Passive => "passive",
            Mode::ForceCharge => "force_charge",
            Mode::ForceDischarge => "force_discharge",
        }
    }
}

impl std::fmt::Display for Mode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The error from parsing a string that names no [`Mode`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(r#"unrecognised mode {0:?}: expected "passive", "force_charge" or "force_discharge""#)]
pub struct ParseModeError(String);

impl std::str::FromStr for Mode {
    type Err = ParseModeError;

    /// Parse the identifier produced by [`Mode::as_str`].
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "passive" => Ok(Mode::Passive),
            "force_charge" => Ok(Mode::ForceCharge),
            "force_discharge" => Ok(Mode::ForceDischarge),
            _ => Err(ParseModeError(s.to_string())),
        }
    }
}

/// Where discharged energy is meant to go.
///
/// This is a target, not a permission: the caller decides policy. It is part
/// of the command because some inverters reach the two behaviours through
/// different work modes rather than through a power limit.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum DischargeTarget {
    /// Cover household load only; do not push power out to the grid.
    HouseOnly,
    /// Deliberately export to the grid, for a grid-services event.
    GridExport,
}

/// A single instruction to the inverter.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Command {
    /// What the inverter should do.
    pub mode: Mode,
    /// Requested power in kilowatts. Ignored for [`Mode::Passive`].
    pub power_kw: f64,
    /// Where discharged energy should go. Ignored unless discharging.
    pub target: DischargeTarget,
    /// How long the caller wants the command to last.
    ///
    /// This is a *request*. What the inverter actually commits to comes back
    /// in [`Applied::expiry`], and it may be weaker than what you asked for.
    pub hold: Duration,
}

impl Command {
    /// Hold requested by the convenience constructors: long enough to survive
    /// a missed control tick, short enough that a dead controller stops
    /// mattering. Override it with [`Command::holding_for`].
    pub const DEFAULT_HOLD: Duration = Duration::from_secs(300);

    /// Return to the inverter's own self-use behaviour ([`Mode::Passive`]).
    #[must_use]
    pub fn passive() -> Self {
        Command {
            mode: Mode::Passive,
            power_kw: 0.0,
            target: DischargeTarget::HouseOnly,
            hold: Self::DEFAULT_HOLD,
        }
    }

    /// Charge at `power_kw`, importing if necessary.
    #[must_use]
    pub fn charge(power_kw: impl Into<f64>) -> Self {
        Command {
            mode: Mode::ForceCharge,
            power_kw: power_kw.into(),
            target: DischargeTarget::HouseOnly,
            hold: Self::DEFAULT_HOLD,
        }
    }

    /// Discharge at `power_kw` to cover household load, without exporting.
    #[must_use]
    pub fn discharge(power_kw: impl Into<f64>) -> Self {
        Command {
            mode: Mode::ForceDischarge,
            power_kw: power_kw.into(),
            target: DischargeTarget::HouseOnly,
            hold: Self::DEFAULT_HOLD,
        }
    }

    /// Discharge at `power_kw`, deliberately exporting to the grid.
    #[must_use]
    pub fn export(power_kw: impl Into<f64>) -> Self {
        Command {
            mode: Mode::ForceDischarge,
            power_kw: power_kw.into(),
            target: DischargeTarget::GridExport,
            hold: Self::DEFAULT_HOLD,
        }
    }

    /// Ask the inverter to hold this command for `hold` rather than the default.
    #[must_use]
    pub fn holding_for(mut self, hold: Duration) -> Self {
        self.hold = hold;
        self
    }
}

impl std::fmt::Display for Command {
    /// Log-friendly form: `passive`, `force_charge@2kW`,
    /// `force_discharge@3kW(grid-export)`.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match (self.mode, self.target) {
            (Mode::Passive, _) => f.write_str("passive"),
            (Mode::ForceDischarge, DischargeTarget::GridExport) => {
                write!(f, "force_discharge@{}kW(grid-export)", self.power_kw)
            }
            _ => write!(f, "{}@{}kW", self.mode, self.power_kw),
        }
    }
}

/// How a non-passive command stops.
///
/// The reason this crate exists. A caller that treats
/// [`Expiry::RecurringWindow`] as though it were
/// [`Expiry::InverterTimeout`] has built a system that keeps forcing a
/// battery after the controller is gone — every day, at the same time, until
/// someone notices.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Expiry {
    /// The inverter reverts by itself after this long, once, without repeating.
    ///
    /// The only variant that is a true fail-safe against a dead controller.
    InverterTimeout(Duration),

    /// The inverter reverts when a condition it evaluates is met — a target
    /// state of charge, for instance.
    ///
    /// Bounded, but not bounded in *time*: a battery that never reaches the
    /// threshold never reverts.
    InverterCondition(&'static str),

    /// A schedule that repeats on the inverter's own clock.
    ///
    /// **Not a fail-safe.** It outlives the controller and fires again
    /// tomorrow. Anything relying on it must have another way to revert.
    RecurringWindow,

    /// Applies until something changes it.
    ///
    /// **Not a fail-safe.** If the controller stops, the command stands.
    UntilChanged,
}

impl Expiry {
    /// Whether a dead controller leaves the inverter safely reverting on its own.
    ///
    /// Callers that can only tolerate a genuine dead-man's handle should refuse
    /// to issue non-passive commands when this is `false`.
    #[must_use]
    pub fn is_dead_controller_safe(&self) -> bool {
        matches!(self, Expiry::InverterTimeout(_))
    }
}

/// What a driver can actually do with the connected hardware.
///
/// Ask before you command. Feature support varies by model *and* by how the
/// inverter is connected — the same unit over RS485 and over its own network
/// module does not expose the same registers.
///
/// The struct is `#[non_exhaustive]` so capabilities can grow without
/// breaking callers. Drivers outside this crate therefore build it through
/// [`Capabilities::read_only`] or [`Capabilities::writable`] and then set the
/// public reporting fields directly.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub struct Capabilities {
    /// Human-readable model or map identifier, for logs and diagnostics.
    pub model: &'static str,
    /// Whether this driver writes to the inverter at all.
    ///
    /// A read-only driver still reports telemetry; it just refuses commands.
    pub can_write: bool,
    /// Modes this driver can command. Always contains [`Mode::Passive`] when
    /// `can_write` is true.
    pub modes: &'static [Mode],
    /// How commands issued by this driver end.
    pub expiry: Expiry,
    /// Whether the driver can report PV generation.
    pub reports_solar: bool,
    /// Whether [`Inverter::mode`] can answer, rather than returning
    /// [`Error::Unsupported`].
    pub reports_mode: bool,
    /// Why writes are unavailable, when `can_write` is false.
    pub write_blocked_reason: Option<&'static str>,
}

impl Capabilities {
    /// A driver that reports telemetry but refuses every command.
    ///
    /// `reason` is surfaced through [`Capabilities::write_blocked_reason`] so
    /// a caller can log *why* writes are unavailable instead of a bare
    /// "unsupported". The reporting flags start `false`; set the public
    /// fields for whatever the driver can do:
    ///
    /// ```
    /// use inverter::Capabilities;
    ///
    /// let mut caps = Capabilities::read_only("Acme X1 (RS485)", "map unverified on hardware");
    /// caps.reports_solar = true;
    /// assert!(!caps.can_write);
    /// ```
    #[must_use]
    pub fn read_only(model: &'static str, reason: &'static str) -> Self {
        Capabilities {
            model,
            can_write: false,
            modes: &[],
            // Nothing can be commanded, so nothing this driver does expires.
            expiry: Expiry::UntilChanged,
            reports_solar: false,
            reports_mode: false,
            write_blocked_reason: Some(reason),
        }
    }

    /// A driver that can command `modes`, each ending the way `expiry` says.
    ///
    /// The reporting flags start `false`; set the public fields for whatever
    /// the driver can do.
    ///
    /// # Panics
    ///
    /// Panics unless `modes` contains [`Mode::Passive`]: a writable driver
    /// that cannot step out of the way leaves callers with no safe fallback.
    #[must_use]
    pub fn writable(model: &'static str, modes: &'static [Mode], expiry: Expiry) -> Self {
        assert!(
            modes.contains(&Mode::Passive),
            "a writable driver must support Mode::Passive"
        );
        Capabilities {
            model,
            can_write: true,
            modes,
            expiry,
            reports_solar: false,
            reports_mode: false,
            write_blocked_reason: None,
        }
    }

    /// Whether `mode` can be commanded.
    #[must_use]
    pub fn supports(&self, mode: Mode) -> bool {
        self.can_write && self.modes.contains(&mode)
    }
}

/// A reading from the inverter.
///
/// See the [crate] docs for sign conventions.
#[derive(Clone, Copy, Debug)]
pub struct Telemetry {
    /// Battery state of charge, percent.
    pub soc_pct: f64,
    /// Battery power, kilowatts. Positive means charging.
    pub battery_kw: f64,
    /// Grid power, kilowatts. Positive means importing.
    pub grid_kw: f64,
    /// Household consumption, kilowatts.
    pub load_kw: f64,
    /// PV generation, kilowatts. `0.0` when the model cannot report it.
    pub solar_kw: f64,
    /// Wall-clock time of the reading, for display and storage.
    pub at: SystemTime,
    /// Monotonic time of the reading.
    ///
    /// Use this for staleness checks: unlike [`Telemetry::at`] it cannot be
    /// dragged backwards by an NTP step or a daylight-saving change.
    pub read_at: Instant,
}

impl Telemetry {
    /// Power flowing out to the grid, kilowatts. Zero while importing.
    #[must_use]
    pub fn export_kw(&self) -> f64 {
        (-self.grid_kw).max(0.0)
    }

    /// How long ago this reading was taken.
    #[must_use]
    pub fn age(&self) -> Duration {
        self.read_at.elapsed()
    }
}

/// What the inverter accepted, which may be less than what was asked for.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Applied {
    /// How this command will actually end.
    ///
    /// Compare against what the caller needs. A driver is allowed to return a
    /// weaker guarantee than requested; silently assuming otherwise is the
    /// mistake this type exists to prevent.
    pub expiry: Expiry,
    /// Power the driver actually commanded, kilowatts, after any
    /// model-specific clamping.
    pub power_kw: f64,
}

/// An inverter this crate can talk to.
///
/// Implementors must report failures rather than returning plausible data,
/// and must honour the crate's sign conventions.
pub trait Inverter: Send {
    /// What this driver can do with the connected hardware.
    ///
    /// Cheap and side-effect free; callers may call it on every tick.
    fn capabilities(&self) -> Capabilities;

    /// Read the current state of the system.
    fn read_telemetry(&mut self) -> Result<Telemetry, Error>;

    /// Command the inverter.
    ///
    /// Returns [`Error::Unsupported`] when [`Capabilities`] says the mode is
    /// unavailable. Implementors should verify writes by reading them back.
    fn apply(&mut self, command: Command) -> Result<Applied, Error>;

    /// The [`Mode`] currently in force, as far as this driver can know it.
    ///
    /// Drivers must not guess. A driver that cannot read the imposed state
    /// back from the hardware returns [`Error::Unsupported`] rather than
    /// repeating what it last commanded — a stale belief is exactly the
    /// mistake that hides an expired or externally-changed command.
    /// [`Capabilities::reports_mode`] says up front whether this can answer.
    fn mode(&mut self) -> Result<Mode, Error>;

    /// Release the transport. Called once, on shutdown.
    fn close(&mut self) {}
}

/// Partial applications of the [`Inverter`] operations.
///
/// Sugar only, in two groups. The command methods each build the matching
/// [`Command`] with [`Command::DEFAULT_HOLD`] and call [`Inverter::apply`]; for a
/// non-default hold, build the [`Command`] and call `apply` directly. The
/// telemetry methods each perform a **full** [`Inverter::read_telemetry`]
/// and return one field — convenient for a one-off check, wasteful in a
/// loop; when you need several values, read once and use the fields.
///
/// The blanket implementation is the only one the coherence rules allow, so
/// no driver can override these — every spelling reaches hardware through
/// `apply` and `read_telemetry`.
pub trait InverterExt: Inverter {
    /// Return to the inverter's own self-use behaviour ([`Mode::Passive`]).
    fn passive(&mut self) -> Result<Applied, Error> {
        self.apply(Command::passive())
    }

    /// Charge at `power_kw`, importing if necessary.
    fn charge(&mut self, power_kw: impl Into<f64>) -> Result<Applied, Error> {
        self.apply(Command::charge(power_kw))
    }

    /// Discharge at `power_kw` to cover household load, without exporting.
    fn discharge(&mut self, power_kw: impl Into<f64>) -> Result<Applied, Error> {
        self.apply(Command::discharge(power_kw))
    }

    /// Discharge at `power_kw`, deliberately exporting to the grid.
    fn export(&mut self, power_kw: impl Into<f64>) -> Result<Applied, Error> {
        self.apply(Command::export(power_kw))
    }

    /// Battery state of charge, percent. Performs a full telemetry read.
    fn soc_pct(&mut self) -> Result<f64, Error> {
        Ok(self.read_telemetry()?.soc_pct)
    }

    /// Battery power, kilowatts; positive means charging. Performs a full
    /// telemetry read.
    fn battery_kw(&mut self) -> Result<f64, Error> {
        Ok(self.read_telemetry()?.battery_kw)
    }

    /// Grid power, kilowatts; positive means importing. Performs a full
    /// telemetry read.
    fn grid_kw(&mut self) -> Result<f64, Error> {
        Ok(self.read_telemetry()?.grid_kw)
    }

    /// Household consumption, kilowatts. Performs a full telemetry read.
    fn load_kw(&mut self) -> Result<f64, Error> {
        Ok(self.read_telemetry()?.load_kw)
    }

    /// PV generation, kilowatts. Performs a full telemetry read.
    fn solar_kw(&mut self) -> Result<f64, Error> {
        Ok(self.read_telemetry()?.solar_kw)
    }

    /// Grid export, kilowatts; zero while importing. Performs a full
    /// telemetry read.
    fn export_kw(&mut self) -> Result<f64, Error> {
        Ok(self.read_telemetry()?.export_kw())
    }
}

impl<I: Inverter + ?Sized> InverterExt for I {}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mode_round_trips_through_its_identifier() {
        for mode in [Mode::Passive, Mode::ForceCharge, Mode::ForceDischarge] {
            assert_eq!(mode.as_str().parse(), Ok(mode));
            assert_eq!(mode.to_string(), mode.as_str(), "Display matches as_str");
        }
        let err = "nonsense".parse::<Mode>().unwrap_err();
        assert!(err.to_string().contains("nonsense"), "{err}");
    }

    #[test]
    fn only_a_one_shot_inverter_timeout_survives_a_dead_controller() {
        assert!(Expiry::InverterTimeout(Duration::from_secs(60)).is_dead_controller_safe());
        assert!(!Expiry::InverterCondition("target soc").is_dead_controller_safe());
        assert!(!Expiry::RecurringWindow.is_dead_controller_safe());
        assert!(!Expiry::UntilChanged.is_dead_controller_safe());
    }

    #[test]
    fn export_is_distinguishable_from_house_only_discharge() {
        assert_eq!(Command::discharge(1.5).target, DischargeTarget::HouseOnly);
        assert_eq!(Command::export(3).target, DischargeTarget::GridExport);
        assert!(Command::export(3).to_string().contains("grid-export"));
    }

    #[test]
    fn display_names_the_mode_power_and_export_intent() {
        assert_eq!(Command::passive().to_string(), "passive");
        assert_eq!(Command::charge(2).to_string(), "force_charge@2kW");
        assert_eq!(Command::discharge(1.5).to_string(), "force_discharge@1.5kW");
        assert_eq!(
            Command::export(3).to_string(),
            "force_discharge@3kW(grid-export)"
        );
    }

    #[test]
    fn constructors_request_the_default_hold_unless_overridden() {
        assert_eq!(Command::charge(1.0).hold, Command::DEFAULT_HOLD);
        let short = Command::charge(1.0).holding_for(Duration::from_secs(60));
        assert_eq!(short.hold, Duration::from_secs(60));
    }

    #[test]
    fn export_kw_is_the_positive_part_of_negative_grid_flow() {
        let mut t = Telemetry {
            soc_pct: 50.0,
            battery_kw: 0.0,
            grid_kw: -0.3,
            load_kw: 0.0,
            solar_kw: 0.0,
            at: SystemTime::now(),
            read_at: Instant::now(),
        };
        assert_eq!(t.export_kw(), 0.3);
        t.grid_kw = 0.2;
        assert_eq!(t.export_kw(), 0.0);
    }

    #[test]
    fn integer_and_float_powers_build_the_same_command() {
        assert_eq!(Command::charge(2), Command::charge(2.0));
        assert_eq!(Command::discharge(1), Command::discharge(1.0));
        assert_eq!(Command::export(3), Command::export(3.0));
    }

    #[test]
    fn a_writable_driver_supports_only_its_listed_modes() {
        let caps = Capabilities::writable(
            "test",
            &[Mode::Passive, Mode::ForceCharge],
            Expiry::UntilChanged,
        );
        assert!(caps.supports(Mode::Passive));
        assert!(caps.supports(Mode::ForceCharge));
        assert!(!caps.supports(Mode::ForceDischarge));
        assert_eq!(caps.write_blocked_reason, None);
    }

    #[test]
    #[should_panic(expected = "must support Mode::Passive")]
    fn a_writable_driver_without_passive_is_rejected_outright() {
        let _ = Capabilities::writable("test", &[Mode::ForceCharge], Expiry::UntilChanged);
    }

    #[test]
    fn capabilities_refuse_every_mode_when_the_driver_cannot_write() {
        let caps = Capabilities {
            model: "test",
            can_write: false,
            // Listed modes must not leak through while writes are off.
            modes: &[Mode::Passive, Mode::ForceCharge],
            expiry: Expiry::UntilChanged,
            reports_solar: false,
            reports_mode: false,
            write_blocked_reason: Some("unverified map"),
        };
        assert!(!caps.supports(Mode::Passive));
        assert!(!caps.supports(Mode::ForceCharge));
    }

    #[test]
    fn a_read_only_driver_carries_its_reason_and_reports_nothing_extra() {
        let caps = Capabilities::read_only("test", "map unverified");
        assert!(!caps.can_write);
        assert_eq!(caps.write_blocked_reason, Some("map unverified"));
        assert!(!caps.reports_solar && !caps.reports_mode);
        assert!(!caps.supports(Mode::Passive));
    }
}