Skip to main content

fennec_modbus/contrib/mini_qube/
schedule.rs

1use core::{
2    fmt::{Display, Formatter},
3    ops::RangeInclusive,
4};
5
6use bytes::{Buf, BufMut};
7
8use crate::{
9    Error,
10    contrib::types::{Percentage, Watts},
11    protocol::{
12        Address,
13        address,
14        codec::{BitSize, Decode, Encode},
15        function::{ReadHoldingRegisters, ReadWriteRegisters, WriteMultipleRegisters},
16    },
17};
18
19/// Number of slots per schedule block.
20///
21/// There are [`N_BLOCKS`] such blocks.
22pub const N_SLOTS_PER_BLOCK: u16 = 12;
23
24/// Number of schedule blocks, each consisting of [`N_SLOTS_PER_BLOCK`] slots.
25pub const N_BLOCKS: u16 = 8;
26
27/// Type alias for a full schedule of [`Slot::N_TOTAL`] slots.
28///
29/// Note that this is not encodable nor decodable as it doesn't fit the Modbus payload size.
30/// The type alias is provided solely for convenience.
31pub type Full = [Slot; Slot::N_TOTAL as usize];
32
33/// Schedule block consisting of [`N_SLOTS_PER_BLOCK`] slots.
34pub type Block = [Slot; N_SLOTS_PER_BLOCK as usize];
35
36/// Starting address for the schedule slots.
37pub const START_ADDRESS: u16 = 48010;
38
39/// Stride of schedule slot blocks.
40///
41/// There are [`Slot::N_TOTAL`] schedule slots starting from here.
42pub type BlockStride = address::Stride<START_ADDRESS, N_BLOCKS, Block>;
43
44/// Block index for batch-reading [`N_SLOTS_PER_BLOCK`] schedule slots at a time.
45///
46/// There are [`N_BLOCKS`] blocks (indices 0–7), covering all [`Slot::N_TOTAL`] slots.
47#[must_use]
48#[derive(Copy, Clone)]
49pub struct BlockIndex(pub u16);
50
51impl BlockIndex {
52    /// Last valid schedule block index.
53    pub const LAST: u16 = (N_BLOCKS - 1);
54}
55
56impl Address for BlockIndex {}
57
58impl Encode for BlockIndex {
59    fn encode_to(&self, buf: &mut impl BufMut) {
60        BlockStride::new(self.0).encode_to(buf);
61    }
62}
63
64#[derive(Copy, Clone, Debug, Eq, PartialEq)]
65#[repr(u16)]
66#[must_use]
67pub enum WorkingMode {
68    /// Charge on PV excess, discharge on deficit.
69    ///
70    /// This is basically a combination of [`Self::FeedInPriority`] and [`Self::BackUp`].
71    SelfUse = 1_u16,
72
73    /// Discharge on PV deficit.
74    FeedInPriority = 2_u16,
75
76    /// Charge in PV excess.
77    BackUp = 3_u16,
78
79    PeakShaving = 4_u16,
80
81    /// Forcibly charge, no power meter needed.
82    ForceCharge = 6_u16,
83
84    /// Forcibly discharge, no power meter needed.
85    ForceDischarge = 7_u16,
86
87    Unknown(u16),
88}
89
90impl BitSize for WorkingMode {
91    const N_BITS: u16 = u16::N_BITS;
92    const N_BYTES: u8 = u16::N_BYTES;
93    const N_WORDS: u16 = u16::N_WORDS;
94}
95
96impl Encode for WorkingMode {
97    fn encode_to(&self, buf: &mut impl BufMut) {
98        buf.put_u16(match self {
99            Self::SelfUse => 1,
100            Self::FeedInPriority => 2,
101            Self::BackUp => 3,
102            Self::PeakShaving => 4,
103            Self::ForceCharge => 6,
104            Self::ForceDischarge => 7,
105            Self::Unknown(working_mode) => *working_mode,
106        });
107    }
108}
109
110impl Decode for WorkingMode {
111    fn decode_from(buf: &mut impl Buf) -> Result<Self, Error> {
112        Ok(match buf.try_get_u16()? {
113            1 => Self::SelfUse,
114            2 => Self::FeedInPriority,
115            3 => Self::BackUp,
116            4 => Self::PeakShaving,
117            6 => Self::ForceCharge,
118            7 => Self::ForceDischarge,
119            working_mode => Self::Unknown(working_mode),
120        })
121    }
122}
123
124/// Scheduler slot start or end time.
125#[derive(Copy, Clone, Debug, Eq, PartialEq)]
126#[must_use]
127pub struct NaiveTime {
128    pub hour: u8,
129    pub minute: u8,
130}
131
132impl Display for NaiveTime {
133    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
134        write!(f, "{:02}:{:02}", self.hour, self.minute)
135    }
136}
137
138impl NaiveTime {
139    /// The first minute of a day.
140    pub const MIN: Self = Self { hour: 0, minute: 0 };
141
142    /// The last minute of a day.
143    ///
144    /// Note that it is always _inclusive_.
145    pub const MAX: Self = Self { hour: 23, minute: 59 };
146}
147
148impl BitSize for NaiveTime {
149    const N_BITS: u16 = u8::N_BITS * 2;
150    const N_BYTES: u8 = u8::N_BYTES * 2;
151    const N_WORDS: u16 = 1;
152}
153
154impl Encode for NaiveTime {
155    fn encode_to(&self, buf: &mut impl BufMut) {
156        buf.put_u8(self.hour);
157        buf.put_u8(self.minute);
158    }
159}
160
161impl Decode for NaiveTime {
162    fn decode_from(buf: &mut impl Buf) -> Result<Self, Error> {
163        Ok(Self { hour: buf.try_get_u8()?, minute: buf.try_get_u8()? })
164    }
165}
166
167/// Range of allowed state-of-charge values.
168///
169/// The minimum and maximum bounds are both inclusive.
170#[must_use]
171#[derive(Copy, Clone, Eq, PartialEq, Debug)]
172pub struct StateOfChargeRange {
173    pub min: Percentage<u8>,
174    pub max: Percentage<u8>,
175}
176
177impl From<RangeInclusive<Percentage<u8>>> for StateOfChargeRange {
178    fn from(value: RangeInclusive<Percentage<u8>>) -> Self {
179        Self { min: *value.start(), max: *value.end() }
180    }
181}
182
183impl BitSize for StateOfChargeRange {
184    const N_BITS: u16 = u8::N_BITS * 2;
185    const N_BYTES: u8 = u8::N_BYTES * 2;
186    const N_WORDS: u16 = 1;
187}
188
189impl Encode for StateOfChargeRange {
190    fn encode_to(&self, buf: &mut impl BufMut) {
191        buf.put_u8(self.max.0);
192        buf.put_u8(self.min.0);
193    }
194}
195
196impl Decode for StateOfChargeRange {
197    fn decode_from(buf: &mut impl Buf) -> Result<Self, Error> {
198        Ok(Self { max: Percentage(buf.try_get_u8()?), min: Percentage(buf.try_get_u8()?) })
199    }
200}
201
202/// Single schedule slot.
203#[derive(Copy, Clone, Debug, Eq, PartialEq)]
204#[must_use]
205pub struct Slot {
206    pub is_enabled: bool,
207
208    /// Time slot start time, inclusive.
209    pub start_time: NaiveTime,
210
211    /// Time slot end time, exclusive.
212    ///
213    /// Note that 23:59 is special as it is *inclusive*. 00:00 cannot be set as end time.
214    /// Confirmed with Fox ESS support that this the intended behaviour.
215    pub end_time: NaiveTime,
216
217    pub working_mode: WorkingMode,
218
219    pub state_of_charge_range: StateOfChargeRange,
220
221    /// This is called "feed SoC" or "fdSoC", but in reality, it is a target SoC
222    /// for charging or discharging.
223    #[allow(clippy::doc_markdown)]
224    pub target_state_of_charge: Percentage<u16>,
225
226    pub power: Watts<u16>,
227
228    /// Reserved, set to zero.
229    pub reserved_1: u16,
230
231    /// Reserved, set to zero.
232    pub reserved_2: u16,
233
234    /// Reserved, set to zero.
235    pub reserved_3: u16,
236}
237
238impl Slot {
239    /// Total number of schedule slots in the register space.
240    pub const N_TOTAL: u16 = N_BLOCKS * N_SLOTS_PER_BLOCK;
241}
242
243impl BitSize for Slot {
244    const N_BITS: u16 = 20 * 8;
245}
246
247impl Encode for Slot {
248    fn encode_to(&self, buf: &mut impl BufMut) {
249        buf.put_u16(u16::from(self.is_enabled));
250        self.start_time.encode_to(buf);
251        self.end_time.encode_to(buf);
252        self.working_mode.encode_to(buf);
253        self.state_of_charge_range.encode_to(buf);
254        self.target_state_of_charge.encode_to(buf);
255        self.power.encode_to(buf);
256        self.reserved_1.encode_to(buf);
257        self.reserved_2.encode_to(buf);
258        self.reserved_3.encode_to(buf);
259    }
260}
261
262impl Decode for Slot {
263    fn decode_from(buf: &mut impl Buf) -> Result<Self, Error> {
264        Ok(Self {
265            is_enabled: buf.try_get_u16()? != 0,
266            start_time: NaiveTime::decode_from(buf)?,
267            end_time: NaiveTime::decode_from(buf)?,
268            working_mode: WorkingMode::decode_from(buf)?,
269            state_of_charge_range: StateOfChargeRange::decode_from(buf)?,
270            target_state_of_charge: Percentage::decode_from(buf)?,
271            power: Watts::decode_from(buf)?,
272            reserved_1: u16::decode_from(buf)?,
273            reserved_2: u16::decode_from(buf)?,
274            reserved_3: u16::decode_from(buf)?,
275        })
276    }
277}
278
279/// Read a single schedule slot.
280///
281/// This function accepts the slot index as the argument.
282///
283/// If you're reading the complete schedule, consider calling [`ReadBlock`] instead.
284pub type ReadSlot =
285    ReadHoldingRegisters<address::Stride<START_ADDRESS, { Slot::N_TOTAL }, Slot>, Slot>;
286
287/// Read 12 schedule slots at a time.
288pub type ReadBlock = ReadHoldingRegisters<BlockIndex, Block>;
289
290/// Write a single schedule slot.
291///
292/// This function accepts the slot index as the argument.
293///
294/// If you're writing the complete schedule, consider calling [`WriteBlock`] instead.
295pub type WriteSlot =
296    WriteMultipleRegisters<address::Stride<START_ADDRESS, { Slot::N_TOTAL }, Slot>, Slot>;
297
298/// Write 12 schedule slots at a time.
299pub type WriteBlock = WriteMultipleRegisters<BlockIndex, Block>;
300
301/// Write and read 12 schedule slots at a time.
302///
303/// Note: Fox ESS MQ2200 returns "illegal function" with incorrect function code for this one.
304pub type ReadWriteBlock = ReadWriteRegisters<BlockIndex, Block, BlockIndex, Block>;