Skip to main content

moddef_core/
device.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Runtime device facade (spec §32.4): binds a [`Transport`] to one parsed
4//! device profile for point- and measurand-based reads/writes, with no
5//! codegen. Port of moddef-ts `Device` / go/client/client.go.
6//!
7//! SunSpec `model_relative_offset` is resolved against the model *ID
8//! register* (offset 0 = model id, 1 = length, data at 2+) per spec §7.3 —
9//! the same convention as the Go/TS clients and the profiles in devices/.
10//!
11//! Shared limitation kept in lockstep with Go/TS: composed (multi-register
12//! mantissa/exponent) points decode via the codec directly, not through the
13//! facade.
14
15use alloc::collections::BTreeMap;
16use alloc::string::String;
17use alloc::vec;
18use alloc::vec::Vec;
19
20use crate::codec::decode::{decode, decode_bytes, decode_raw, decode_str, Ctx};
21use crate::codec::encode::{encode, encode_str, validate_write};
22use crate::codec::mask_for;
23use crate::convert::{desc_bufs, point_desc, point_words};
24use crate::desc::{DateTimeEncoding, ValueKind};
25use crate::error::{DecodeError, Error};
26use crate::measurand::{measurand_matches, MeasurandQuery};
27use crate::schema;
28use crate::transport::Transport;
29use crate::value::{field_value, flag_names, DecodedValue, Value};
30
31/// "SunS" marker as two big-endian 16-bit words.
32const SUNS_MARKER: [u16; 2] = [0x5375, 0x6e53];
33
34/// The untyped runtime facade over one device profile. Generated typed
35/// clients (moddef-codegen) skip this and use `static` descriptor tables.
36pub struct Device<'d, T: Transport> {
37    profile: &'d schema::DeviceProfile,
38    transport: T,
39    /// Resolved SunSpec model ID-register offsets, cached per block (§7.3).
40    model_base: BTreeMap<&'d str, u16>,
41}
42
43impl<'d, T: Transport> Device<'d, T> {
44    /// Bind a transport to the named device profile in `doc` (or the only one).
45    pub fn new(
46        doc: &'d schema::ModDefDocument,
47        device_id: Option<&str>,
48        transport: T,
49    ) -> Result<Self, Error<T::Error>> {
50        let profile = doc
51            .devices
52            .iter()
53            .find(|d| device_id.is_none_or(|id| d.device_id == id))
54            .ok_or(Error::DeviceNotFound)?;
55        Ok(Device::from_profile(profile, transport))
56    }
57
58    pub fn from_profile(profile: &'d schema::DeviceProfile, transport: T) -> Self {
59        Device {
60            profile,
61            transport,
62            model_base: BTreeMap::new(),
63        }
64    }
65
66    pub fn profile(&self) -> &'d schema::DeviceProfile {
67        self.profile
68    }
69
70    pub fn transport_mut(&mut self) -> &mut T {
71        &mut self.transport
72    }
73
74    pub fn into_transport(self) -> T {
75        self.transport
76    }
77
78    /// All points in block order (spec §32.1).
79    pub fn points(&self) -> impl Iterator<Item = &'d schema::Point> {
80        self.profile.blocks.iter().flat_map(|b| b.points.iter())
81    }
82
83    /// Look up a point and its owning block by id.
84    pub fn point(
85        &self,
86        id: &str,
87    ) -> Result<(&'d schema::Point, &'d schema::RegisterBlock), Error<T::Error>> {
88        for b in &self.profile.blocks {
89            if let Some(p) = b.points.iter().find(|p| p.point_id == id) {
90                return Ok((p, b));
91            }
92        }
93        Err(Error::PointNotFound)
94    }
95
96    /// Read and decode a single point by id.
97    pub async fn read_point(&mut self, id: &str) -> Result<DecodedValue, Error<T::Error>> {
98        let (p, b) = self.point(id)?;
99        let regs = self.read_registers(p, b).await?;
100        let refs = self.ref_context(p).await?;
101        decode_owned(p, b.space(), &regs, &Ctx { refs: &refs }).map_err(Error::Decode)
102    }
103
104    /// Read a point by its semantic measurand tuple (spec §26.1).
105    pub async fn read_measurand(
106        &mut self,
107        q: &MeasurandQuery<'_>,
108    ) -> Result<DecodedValue, Error<T::Error>> {
109        let mut found: Option<&'d schema::Point> = None;
110        for p in self.points() {
111            if measurand_matches(p.measurand.as_ref(), q) {
112                if found.is_some() {
113                    return Err(Error::AmbiguousMeasurand);
114                }
115                found = Some(p);
116            }
117        }
118        let p = found.ok_or(Error::MeasurandNotSupported)?;
119        self.read_point(&p.point_id).await
120    }
121
122    /// Encode and write a value, validating access mode and §11.4 constraints.
123    pub async fn write_point(&mut self, id: &str, v: Value) -> Result<(), Error<T::Error>> {
124        let (p, b) = self.point(id)?;
125        let bufs = desc_bufs(p);
126        let d = point_desc(p, b.space(), &bufs);
127        if !d.writable() {
128            return Err(Error::WriteAccess);
129        }
130        validate_write(&d, &v).map_err(Error::WriteConstraint)?;
131
132        let space = effective_space(p, b);
133        let off = self.offset_of(p, b).await?;
134
135        if space == schema::AddressSpace::Coil {
136            let on = v.as_f64().map(|f| f != 0.0).unwrap_or(false);
137            return self
138                .transport
139                .write_coil(off, on)
140                .await
141                .map_err(Error::Transport);
142        }
143        if space != schema::AddressSpace::HoldingRegister {
144            return Err(Error::UnsupportedMapping("cannot write this address space"));
145        }
146        let refs = self.ref_context(p).await?;
147        let mut regs = vec![0u16; d.words()];
148        encode(&d, &v, &Ctx { refs: &refs }, &mut regs)?;
149        self.transport
150            .write_holding(off, &regs)
151            .await
152            .map_err(Error::Transport)
153    }
154
155    /// Write a string point (STRING_ASCII / STRING_UTF8), §15.
156    pub async fn write_point_str(&mut self, id: &str, s: &str) -> Result<(), Error<T::Error>> {
157        let (p, b) = self.point(id)?;
158        let bufs = desc_bufs(p);
159        let d = point_desc(p, b.space(), &bufs);
160        if !d.writable() {
161            return Err(Error::WriteAccess);
162        }
163        if effective_space(p, b) != schema::AddressSpace::HoldingRegister {
164            return Err(Error::UnsupportedMapping("cannot write this address space"));
165        }
166        let off = self.offset_of(p, b).await?;
167        let mut regs = vec![0u16; d.words()];
168        encode_str(&d, s, &mut regs)?;
169        self.transport
170            .write_holding(off, &regs)
171            .await
172            .map_err(Error::Transport)
173    }
174
175    // --- internals -------------------------------------------------------- //
176
177    /// Read the points referenced by p's scale_ref / selector_ref
178    /// (spec §10.4/§10.5), decoded to integers for the codec context.
179    async fn ref_context(
180        &mut self,
181        p: &'d schema::Point,
182    ) -> Result<Vec<(&'d str, i64)>, Error<T::Error>> {
183        let mut ids: Vec<&'d str> = Vec::new();
184        if let Some(sr) = p.transform.as_ref().and_then(|t| t.scale_ref.as_ref()) {
185            ids.push(&sr.point_id);
186        }
187        if let Some(sel) = p.selector_ref.as_ref() {
188            ids.push(&sel.point_id);
189        }
190        let mut refs = Vec::with_capacity(ids.len());
191        for id in ids {
192            let (rp, rb) = self.point(id)?;
193            let regs = self.read_registers(rp, rb).await?;
194            let bufs = desc_bufs(rp);
195            let d = point_desc(rp, rb.space(), &bufs);
196            let v = decode(&d, &regs, &Ctx::EMPTY)?;
197            if let Some(iv) = v.as_i64() {
198                refs.push((id, iv));
199            }
200        }
201        Ok(refs)
202    }
203
204    async fn read_registers(
205        &mut self,
206        p: &'d schema::Point,
207        b: &'d schema::RegisterBlock,
208    ) -> Result<Vec<u16>, Error<T::Error>> {
209        if p.storage_type() == schema::StorageType::Composed {
210            return Err(Error::UnsupportedMapping(
211                "composed points are not read via the facade",
212            ));
213        }
214        let space = effective_space(p, b);
215        let n = point_words(p);
216        let off = self.offset_of(p, b).await?;
217        self.read_space(space, off, n).await
218    }
219
220    async fn read_space(
221        &mut self,
222        space: schema::AddressSpace,
223        off: u16,
224        n: usize,
225    ) -> Result<Vec<u16>, Error<T::Error>> {
226        match space {
227            schema::AddressSpace::HoldingRegister => {
228                let mut regs = vec![0u16; n];
229                self.transport
230                    .read_holding(off, &mut regs)
231                    .await
232                    .map_err(Error::Transport)?;
233                Ok(regs)
234            }
235            schema::AddressSpace::InputRegister => {
236                let mut regs = vec![0u16; n];
237                self.transport
238                    .read_input(off, &mut regs)
239                    .await
240                    .map_err(Error::Transport)?;
241                Ok(regs)
242            }
243            schema::AddressSpace::Coil => {
244                let mut bits = [false];
245                self.transport
246                    .read_coils(off, &mut bits)
247                    .await
248                    .map_err(Error::Transport)?;
249                Ok(vec![bits[0] as u16])
250            }
251            schema::AddressSpace::DiscreteInput => {
252                let mut bits = [false];
253                self.transport
254                    .read_discrete(off, &mut bits)
255                    .await
256                    .map_err(Error::Transport)?;
257                Ok(vec![bits[0] as u16])
258            }
259            schema::AddressSpace::Unspecified => {
260                Err(Error::UnsupportedMapping("unspecified address space"))
261            }
262        }
263    }
264
265    async fn offset_of(
266        &mut self,
267        p: &schema::Point,
268        b: &'d schema::RegisterBlock,
269    ) -> Result<u16, Error<T::Error>> {
270        let m = p
271            .mapping
272            .as_ref()
273            .ok_or(Error::UnsupportedMapping("point has no mapping"))?;
274        if b.discovery.is_some() {
275            let base = self.resolve_model_base(b).await?;
276            Ok(base + m.model_relative_offset as u16)
277        } else {
278            Ok(m.offset as u16)
279        }
280    }
281
282    /// Probe discovery anchors for the SunS marker, walk the (model_id,
283    /// length) chain, and return the offset of the target model's ID register.
284    async fn resolve_model_base(
285        &mut self,
286        b: &'d schema::RegisterBlock,
287    ) -> Result<u16, Error<T::Error>> {
288        if let Some(base) = self.model_base.get(b.block_id.as_str()) {
289            return Ok(*base);
290        }
291        let disc = b
292            .discovery
293            .as_ref()
294            .ok_or(Error::UnsupportedMapping("block has no discovery"))?;
295        if disc.kind() != schema::DiscoveryKind::Sunspec {
296            return Err(Error::UnsupportedMapping("unsupported discovery kind"));
297        }
298        let space = b.space();
299        let defaults = [40000u32, 50000, 0];
300        let candidates: &[u32] = if disc.anchor_candidates.is_empty() {
301            &defaults
302        } else {
303            &disc.anchor_candidates
304        };
305
306        let mut anchor = None;
307        for &c in candidates {
308            // Devices answer exceptions off-anchor; try the next candidate.
309            if let Ok(hdr) = self.read_space(space, c as u16, 2).await {
310                if hdr[..2] == SUNS_MARKER {
311                    anchor = Some(c as u16);
312                    break;
313                }
314            }
315        }
316        let Some(anchor) = anchor else {
317            return Err(Error::UnsupportedMapping("SunS marker not found"));
318        };
319
320        // Walk model headers starting just after the marker.
321        let mut off = anchor + 2;
322        for _ in 0..256 {
323            let hdr = self.read_space(space, off, 2).await?;
324            let (id, length) = (hdr[0], hdr[1]);
325            if id == 0xffff {
326                break;
327            }
328            if id as u32 == disc.model_id {
329                // Base is the model ID register (model_relative_offset 0, §7.3).
330                self.model_base.insert(&b.block_id, off);
331                return Ok(off);
332            }
333            off = off
334                .checked_add(2 + length)
335                .ok_or(Error::UnsupportedMapping("SunSpec model chain overflows"))?;
336        }
337        Err(Error::UnsupportedMapping("SunSpec model not found"))
338    }
339}
340
341/// The effective address space: the mapping's, else the owning block's.
342fn effective_space(p: &schema::Point, b: &schema::RegisterBlock) -> schema::AddressSpace {
343    match p.mapping.as_ref().map(|m| m.space()) {
344        Some(s) if s != schema::AddressSpace::Unspecified => s,
345        _ => b.space(),
346    }
347}
348
349/// Decode a point's registers into an owned [`DecodedValue`]. Date/times are
350/// normalized to epoch **milliseconds** (parity with the TS `Date` surface).
351fn decode_owned(
352    p: &schema::Point,
353    block_space: schema::AddressSpace,
354    regs: &[u16],
355    ctx: &Ctx<'_>,
356) -> Result<DecodedValue, DecodeError> {
357    let bufs = desc_bufs(p);
358    let d = point_desc(p, block_space, &bufs);
359
360    match d.value {
361        ValueKind::Str { .. } => {
362            let mut buf = vec![0u8; regs.len() * 2];
363            let s = decode_str(&d, regs, &mut buf)?;
364            Ok(DecodedValue::Str(String::from(s)))
365        }
366        ValueKind::Bytes => {
367            let mut buf = vec![0u8; regs.len() * 2];
368            let bytes = decode_bytes(&d, regs, &mut buf)?;
369            Ok(DecodedValue::Bytes(bytes.to_vec()))
370        }
371        _ => Ok(match decode(&d, regs, ctx)? {
372            Value::Bool(v) => DecodedValue::Bool(v),
373            Value::U64(v) => DecodedValue::U64(v),
374            Value::I64(v) => DecodedValue::I64(v),
375            Value::F64(v) => DecodedValue::F64(v),
376            Value::Flags(mask) => {
377                DecodedValue::Flags(flag_names(&d, mask).map(String::from).collect())
378            }
379            Value::Fields(window) => {
380                let fields = match d.value {
381                    ValueKind::Fields(fs) => fs,
382                    _ => &[],
383                };
384                DecodedValue::Fields(
385                    fields
386                        .iter()
387                        .map(|f| (String::from(f.id), field_value(f, window)))
388                        .collect(),
389                )
390            }
391            Value::DateTime(t) => DecodedValue::DateTime(match d.value {
392                ValueKind::DateTime(DateTimeEncoding::EpochMillis) => t,
393                _ => t.saturating_mul(1000),
394            }),
395            Value::Unavailable => {
396                // Recover the sentinel's meaning for the owned value.
397                let (raw, bits) = decode_raw(&d, regs)?;
398                let meaning =
399                    d.na.iter()
400                        .find(|na| (na.raw as u64) & mask_for(bits) == raw)
401                        .map(|na| na.meaning)
402                        .unwrap_or("");
403                DecodedValue::Unavailable(String::from(meaning))
404            }
405        }),
406    }
407}