Skip to main content

ciplink_server/
hub.rs

1//! The hub: subscriptions, fan-out, writes and momentary buttons. One task owns all of it and handles one message
2//! at a time (client messages, worker events, timers), so nothing here needs a lock.
3
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
5use std::path::Path;
6use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
7use std::sync::{Arc, Mutex, PoisonError};
8use std::time::Duration;
9
10use indexmap::IndexMap;
11use serde_json::{Map, Value as Json, json};
12use tokio::sync::{Notify, mpsc, oneshot};
13
14use crate::config::{Config, TokenConfig, py_repr};
15use crate::rules::{Rules, canonical, glob_match, split_bit, truthy};
16use crate::values::from_json;
17use crate::worker::{Event, PlcWorker, WriteResult};
18
19/// The protocol version.
20pub const PROTOCOL: u64 = 1;
21/// What `welcome` says the server supports.
22pub const FEATURES: [&str; 4] = ["info", "momentary", "readback", "errors"];
23const RELEASES: [&str; 3] = ["release", "timeout", "disconnect"];
24/// Kinds a retry never fixes; a release failing any other way (refused, comm, table_changed, ...) is owed.
25const FINAL: [&str; 7] = [
26    "bad_request",
27    "not_allowed",
28    "not_writable",
29    "out_of_range",
30    "not_found",
31    "access",
32    "bad_value",
33];
34/// Page learning is saved at most this often.
35#[cfg(not(test))]
36const SAVE_DELAY: Duration = Duration::from_secs(2);
37/// Before a release refused while connected is written again.
38#[cfg(not(test))]
39const RETRY_DELAY: Duration = Duration::from_millis(500);
40// shorter in tests, as the Python tests set them
41#[cfg(test)]
42const SAVE_DELAY: Duration = Duration::from_millis(10);
43#[cfg(test)]
44const RETRY_DELAY: Duration = Duration::from_millis(100);
45
46/// A session's id.
47pub type SessionId = u64;
48
49/// What the hub queues for one connection: control messages (ack, info, status, meta, error) sent in order, then
50/// one values message with everything pending.
51#[derive(Default)]
52pub struct Outbox {
53    queue: Mutex<(VecDeque<Json>, IndexMap<String, Json>)>,
54    notify: Notify,
55}
56
57impl Outbox {
58    /// Queues a control message.
59    pub fn send(&self, message: Json) {
60        self.queue
61            .lock()
62            .unwrap_or_else(PoisonError::into_inner)
63            .0
64            .push_back(message);
65        self.notify.notify_one();
66    }
67
68    /// Queues the latest entry of a name; one values message carries all of them.
69    pub fn push(&self, name: &str, entry: Json) {
70        self.push_all(vec![(name.to_owned(), entry)]);
71    }
72
73    /// Queues the latest entries of several names at once, so the sender never splits them across values messages.
74    pub fn push_all(&self, entries: Vec<(String, Json)>) {
75        if entries.is_empty() {
76            return;
77        }
78        self.queue
79            .lock()
80            .unwrap_or_else(PoisonError::into_inner)
81            .1
82            .extend(entries);
83        self.notify.notify_one();
84    }
85
86    /// Waits for messages, then takes them: the control messages, then a values message if any are pending.
87    pub async fn next(&self) -> Vec<Json> {
88        loop {
89            {
90                let mut queue = self.queue.lock().unwrap_or_else(PoisonError::into_inner);
91                let mut out: Vec<Json> = queue.0.drain(..).collect();
92                if !queue.1.is_empty() {
93                    let d: Map<String, Json> = std::mem::take(&mut queue.1).into_iter().collect();
94                    out.push(json!({"op": "values", "d": d}));
95                }
96                if !out.is_empty() {
97                    return out;
98                }
99            }
100            self.notify.notified().await;
101        }
102    }
103}
104
105/// Messages to the hub task.
106pub enum Msg {
107    /// A client said hello with a valid token.
108    Open(SessionId, TokenConfig, Arc<Outbox>),
109    /// A text message from a client.
110    Text(SessionId, String),
111    /// A client went away.
112    Close(SessionId),
113    /// A worker's event.
114    Worker(Event),
115    /// A write finished.
116    WriteDone(Box<WriteDone>),
117    /// A pressed button's watchdog fired.
118    Timeout(SessionId, String, u64),
119    /// Write an owed release again.
120    Resend(String),
121    /// Save the learned pages.
122    SavePages,
123    /// Release everything, stop the workers, save, flush the audit log.
124    Stop(oneshot::Sender<()>),
125}
126
127/// A finished write and where its ack goes.
128pub struct WriteDone {
129    session: SessionId,
130    token: TokenConfig,
131    id: Option<Json>,
132    qname: String,
133    name: Json,
134    momentary: Option<&'static str>,
135    result: WriteResult,
136}
137
138/// What HTTP handlers read without asking the hub task.
139pub struct Shared {
140    /// The config.
141    pub config: Arc<Config>,
142    /// The tag settings.
143    pub rules: Arc<Rules>,
144    /// The PLC workers in config order.
145    pub workers: IndexMap<String, Arc<PlcWorker>>,
146    /// Open sessions.
147    pub sessions: AtomicUsize,
148    /// Writes queued to a worker, momentary included.
149    pub writes: AtomicU64,
150    /// Writes rejected by the hub's checks.
151    pub write_rejects: AtomicU64,
152    /// Buttons released by their watchdog.
153    pub momentary_timeouts: AtomicU64,
154}
155
156impl Shared {
157    /// PLC name -> connection state.
158    pub fn states(&self) -> Map<String, Json> {
159        self.workers
160            .iter()
161            .map(|(name, w)| (name.clone(), json!(w.state())))
162            .collect()
163    }
164
165    /// The token with this secret (compared in constant time).
166    pub fn authenticate(&self, secret: Option<&Json>) -> Option<TokenConfig> {
167        let secret = secret?.as_str()?;
168        self.config
169            .tokens
170            .values()
171            .find(|t| constant_time_eq(t.secret.as_bytes(), secret.as_bytes()))
172            .cloned()
173    }
174}
175
176fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
177    let mut diff = a.len() ^ b.len();
178    for (i, x) in a.iter().enumerate() {
179        diff |= usize::from(x ^ b.get(i).copied().unwrap_or(!x));
180    }
181    diff == 0
182}
183
184struct Sub {
185    tier: u8,
186    /// name as the client sent it -> qualified resolved name
187    tags: IndexMap<String, String>,
188    follow: Option<String>,
189}
190
191struct Session {
192    token: TokenConfig,
193    outbox: Arc<Outbox>,
194    subs: IndexMap<String, Sub>,
195    info_sent: HashSet<String>,
196    /// qname -> (watchdog generation, name as sent), in press order (released in that order on disconnect)
197    pressed: IndexMap<String, (u64, Json)>,
198}
199
200/// Python's `str()` of a JSON value, for names that are not strings.
201fn py_str(value: &Json) -> String {
202    match value {
203        Json::Null => "None".into(),
204        Json::Bool(true) => "True".into(),
205        Json::Bool(false) => "False".into(),
206        Json::String(s) => s.clone(),
207        other => other.to_string(),
208    }
209}
210
211/// min/max check of a number, or of every number in a list or object; bools and non-numbers pass.
212fn in_range(value: &Json, lo: Option<f64>, hi: Option<f64>) -> bool {
213    match value {
214        Json::Object(map) => map.values().all(|v| in_range(v, lo, hi)),
215        Json::Array(items) => items.iter().all(|v| in_range(v, lo, hi)),
216        Json::Number(n) => {
217            let v = n.as_f64().unwrap_or(0.0);
218            lo.is_none_or(|lo| v >= lo) && hi.is_none_or(|hi| v <= hi)
219        }
220        _ => true,
221    }
222}
223
224/// `2026-10-23T14:17:33.768123+00:00`, Python's `datetime.now(timezone.utc).isoformat()`.
225fn iso_now() -> String {
226    let now = std::time::SystemTime::now()
227        .duration_since(std::time::UNIX_EPOCH)
228        .unwrap_or_default();
229    let secs = now.as_secs();
230    let (days, rem) = (secs / 86_400, secs % 86_400);
231    let z = days as i64 + 719_468;
232    let era = z.div_euclid(146_097);
233    let doe = z - era * 146_097;
234    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
235    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
236    let mp = (5 * doy + 2) / 153;
237    let day = doy - (153 * mp + 2) / 5 + 1;
238    let month = if mp < 10 { mp + 3 } else { mp - 9 };
239    let year = yoe + era * 400 + i64::from(month <= 2);
240    format!(
241        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:06}+00:00",
242        rem / 3600,
243        rem % 3600 / 60,
244        rem % 60,
245        now.subsec_micros()
246    )
247}
248
249/// page path -> qualified tag names, from the pages file; empty if there is none.
250fn load_pages(path: Option<&Path>) -> BTreeMap<String, BTreeSet<String>> {
251    let Some(path) = path.filter(|p| p.exists()) else {
252        return BTreeMap::new();
253    };
254    let parsed = std::fs::read_to_string(path)
255        .map_err(|e| e.to_string())
256        .and_then(|text| {
257            serde_json::from_str::<BTreeMap<String, BTreeSet<String>>>(&text)
258                .map_err(|e| e.to_string())
259        });
260    parsed.unwrap_or_else(|err| {
261        log::warn!("cannot read {}: {err}", path.display());
262        BTreeMap::new()
263    })
264}
265
266/// Writes the pages file atomically: a temporary file, then a rename over the old one.
267fn save_pages(path: &Path, pages: &BTreeMap<String, BTreeSet<String>>) -> std::io::Result<()> {
268    if let Some(dir) = path.parent() {
269        std::fs::create_dir_all(dir)?;
270    }
271    let mut text = Vec::new();
272    let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
273    let mut ser = serde_json::Serializer::with_formatter(&mut text, formatter);
274    serde::Serialize::serialize(pages, &mut ser).map_err(std::io::Error::other)?;
275    let tmp = path.with_extension(format!(
276        "{}.tmp",
277        path.extension().and_then(|e| e.to_str()).unwrap_or("")
278    ));
279    std::fs::write(&tmp, text)?;
280    std::fs::rename(&tmp, path)
281}
282
283/// The hub task's state.
284pub struct Hub {
285    shared: Arc<Shared>,
286    tx: mpsc::UnboundedSender<Msg>,
287    sessions: HashMap<SessionId, Session>,
288    /// qualified name -> tier -> subscriptions across sessions
289    refs: HashMap<String, BTreeMap<u8, usize>>,
290    /// qualified name -> session -> client names
291    subscribers: HashMap<String, HashMap<SessionId, HashSet<String>>>,
292    /// page path -> qualified names its tier-1 subs used
293    pages: BTreeMap<String, BTreeSet<String>>,
294    save_scheduled: bool,
295    /// qname -> (token, session, name, momentary) of a release the PLC never got, sent on connect
296    owed: HashMap<String, (TokenConfig, SessionId, Json, &'static str)>,
297    /// plc -> its always tags, each holding one tier-1 reference
298    always: HashMap<String, HashSet<String>>,
299    audit: Option<std::sync::mpsc::Sender<String>>,
300    audit_thread: Option<std::thread::JoinHandle<()>>,
301    generation: u64,
302}
303
304impl Hub {
305    /// The hub of these workers; `tx` sends to its own task (timers, write results).
306    pub fn new(shared: Arc<Shared>, tx: mpsc::UnboundedSender<Msg>) -> Self {
307        let config = Arc::clone(&shared.config);
308        let pages = load_pages(config.pages_file.as_deref())
309            .into_iter()
310            .map(|(page, qnames)| {
311                // names of a PLC no longer configured are dropped
312                let kept = qnames
313                    .into_iter()
314                    .filter(|q| {
315                        q.split_once('/')
316                            .is_some_and(|(plc, _)| config.plcs.contains_key(plc))
317                    })
318                    .collect();
319                (page, kept)
320            })
321            .collect();
322        let (audit, audit_thread) = match config.audit_log.clone() {
323            Some(path) => {
324                let (tx, rx) = std::sync::mpsc::channel::<String>();
325                let thread = std::thread::Builder::new()
326                    .name("audit".into())
327                    .spawn(move || write_audit(&path, &rx))
328                    .ok();
329                (Some(tx), thread)
330            }
331            None => (None, None),
332        };
333        let mut hub = Hub {
334            shared,
335            tx,
336            sessions: HashMap::new(),
337            refs: HashMap::new(),
338            subscribers: HashMap::new(),
339            pages,
340            save_scheduled: false,
341            owed: HashMap::new(),
342            always: HashMap::new(),
343            audit,
344            audit_thread,
345            generation: 0,
346        };
347        let plcs: Vec<String> = hub.shared.workers.keys().cloned().collect();
348        for plc in plcs {
349            hub.update_always(&plc);
350        }
351        hub
352    }
353
354    /// Runs the hub until a Stop message; starts the workers first.
355    pub async fn run(mut self, mut rx: mpsc::UnboundedReceiver<Msg>) {
356        for worker in self.shared.workers.values() {
357            worker.start();
358        }
359        while let Some(msg) = rx.recv().await {
360            if let Some(done) = self.dispatch(msg) {
361                self.stop();
362                let _ = done.send(());
363                return;
364            }
365        }
366        self.stop();
367    }
368
369    /// Handles one message; a Stop is returned for the caller to stop.
370    fn dispatch(&mut self, msg: Msg) -> Option<oneshot::Sender<()>> {
371        match msg {
372            Msg::Open(id, token, outbox) => {
373                self.sessions.insert(
374                    id,
375                    Session {
376                        token,
377                        outbox,
378                        subs: IndexMap::new(),
379                        info_sent: HashSet::new(),
380                        pressed: IndexMap::new(),
381                    },
382                );
383                self.shared
384                    .sessions
385                    .store(self.sessions.len(), Ordering::Relaxed);
386            }
387            Msg::Text(id, text) => self.handle(id, &text),
388            Msg::Close(id) => self.drop_session(id),
389            Msg::Worker(event) => self.worker_event(event),
390            Msg::WriteDone(done) => self.write_done(*done),
391            Msg::Timeout(id, qname, generation) => self.timeout(id, &qname, generation),
392            Msg::Resend(qname) => self.resend(&qname),
393            Msg::SavePages => self.save_pages(),
394            Msg::Stop(done) => return Some(done),
395        }
396        None
397    }
398
399    fn stop(&mut self) {
400        let ids: Vec<SessionId> = self.sessions.keys().copied().collect();
401        for id in ids {
402            self.drop_session(id); // queues the releases of pressed tags, which the workers still run
403        }
404        let workers: Vec<Arc<PlcWorker>> = self.shared.workers.values().cloned().collect();
405        tokio::task::block_in_place(|| {
406            for worker in &workers {
407                worker.stop();
408            }
409        });
410        if self.save_scheduled {
411            self.save_pages();
412        }
413        self.audit = None; // the audit thread ends once it has written everything
414        if let Some(thread) = self.audit_thread.take() {
415            let _ = tokio::task::block_in_place(|| thread.join());
416        }
417    }
418
419    fn worker(&self, plc: &str) -> Option<&Arc<PlcWorker>> {
420        self.shared.workers.get(plc)
421    }
422
423    fn later(&self, delay: Duration, msg: Msg) {
424        let tx = self.tx.clone();
425        tokio::spawn(async move {
426            tokio::time::sleep(delay).await;
427            let _ = tx.send(msg);
428        });
429    }
430
431    // ------------------------------------------------------------------------------------------ worker events
432
433    fn worker_event(&mut self, event: Event) {
434        match event {
435            Event::Values(plc, changes) => {
436                // one push per session: the changes of a read go out in one values message
437                let mut per_session: HashMap<SessionId, Vec<(String, Json)>> = HashMap::new();
438                for (tag, entry) in changes {
439                    let Some(subs) = self.subscribers.get(&format!("{plc}/{tag}")) else {
440                        continue;
441                    };
442                    let value = entry.to_json();
443                    for (id, names) in subs {
444                        let pending = per_session.entry(*id).or_default();
445                        pending.extend(names.iter().map(|name| (name.clone(), value.clone())));
446                    }
447                }
448                for (id, entries) in per_session {
449                    if let Some(session) = self.sessions.get(&id) {
450                        session.outbox.push_all(entries);
451                    }
452                }
453            }
454            Event::Status(plc, state) => {
455                self.broadcast(&json!({"op": "status", "plcs": self.shared.states()}));
456                if state == "connected" {
457                    // types are known now
458                    self.update_always(&plc);
459                    self.requalify(&plc);
460                    self.send_info(&plc, None);
461                    let owed: Vec<String> = self
462                        .owed
463                        .keys()
464                        .filter(|q| q.split_once('/').is_some_and(|(p, _)| p == plc))
465                        .cloned()
466                        .collect();
467                    for qname in owed {
468                        self.resend(&qname);
469                    }
470                }
471            }
472            Event::Meta(plc) => {
473                self.broadcast(&json!({"op": "meta", "plc": plc}));
474                self.update_always(&plc);
475                self.requalify(&plc);
476                self.send_info(&plc, None);
477            }
478            Event::Info(plc, tags) => {
479                let tags: HashSet<String> = tags.into_iter().collect();
480                self.send_info(&plc, Some(&tags));
481            }
482        }
483    }
484
485    fn broadcast(&self, message: &Json) {
486        for session in self.sessions.values() {
487            session.outbox.send(message.clone());
488        }
489    }
490
491    /// Holds a tier-1 reference on each always tag; called again once the types are known or reloaded.
492    fn update_always(&mut self, plc: &str) {
493        let new: HashSet<String> = self.shared.rules.always(plc).into_iter().collect();
494        let old = self.always.remove(plc).unwrap_or_default();
495        let mut added: Vec<&String> = new.difference(&old).collect();
496        added.sort();
497        for tag in added {
498            self.hold(&format!("{plc}/{tag}"), 1);
499        }
500        let gone: Vec<String> = old.difference(&new).map(|t| format!("{plc}/{t}")).collect();
501        if !gone.is_empty() {
502            self.release(1, &gone);
503        }
504        self.always.insert(plc.to_owned(), new);
505    }
506
507    /// Resolves the subscribed names of the PLC again once its types are known or reloaded: a bit alias from a
508    /// `[udt]` entry only resolves then. A sub whose names now resolve elsewhere moves its references.
509    fn requalify(&mut self, plc: &str) {
510        let ids: Vec<SessionId> = self.sessions.keys().copied().collect();
511        for id in ids {
512            let sub_ids: Vec<String> = self.sessions[&id].subs.keys().cloned().collect();
513            for sub_id in sub_ids {
514                let (tier, old_tags) = {
515                    let sub = &self.sessions[&id].subs[&sub_id];
516                    (sub.tier, sub.tags.clone())
517                };
518                let mut tags = old_tags.clone();
519                for (name, qname) in &old_tags {
520                    if qname.split_once('/').is_some_and(|(p, _)| p == plc) {
521                        if let Ok((p, _, tag)) = self.qualify_str(name) {
522                            tags.insert(name.clone(), format!("{p}/{tag}"));
523                        }
524                    }
525                }
526                if tags == old_tags {
527                    continue;
528                }
529                let old: HashSet<String> = old_tags.values().cloned().collect();
530                let new: HashSet<String> = tags.values().cloned().collect();
531                for qname in new.difference(&old) {
532                    self.hold(qname, tier);
533                }
534                let gone: Vec<String> = old.difference(&new).cloned().collect();
535                self.release(tier, &gone);
536                if let Some(sub) = self
537                    .sessions
538                    .get_mut(&id)
539                    .and_then(|s| s.subs.get_mut(&sub_id))
540                {
541                    sub.tags = tags;
542                }
543                let all: HashSet<String> = old.union(&new).cloned().collect();
544                self.reindex(id, &all);
545            }
546        }
547    }
548
549    /// Sends the info of the PLC's subscribed names again (only of `tags` if given).
550    fn send_info(&self, plc: &str, tags: Option<&HashSet<String>>) {
551        let mut per_session: HashMap<SessionId, Map<String, Json>> = HashMap::new();
552        for (qname, subs) in &self.subscribers {
553            let Some((p, tag)) = qname.split_once('/') else {
554                continue;
555            };
556            if p != plc || tags.is_some_and(|t| !t.contains(tag)) {
557                continue;
558            }
559            for (id, names) in subs {
560                let Some(session) = self.sessions.get(id) else {
561                    continue;
562                };
563                let d = per_session.entry(*id).or_default();
564                let mut names: Vec<&String> = names.iter().collect();
565                names.sort();
566                for name in names {
567                    d.insert(name.clone(), self.info(&session.token, plc, tag, name));
568                }
569            }
570        }
571        for (id, d) in per_session {
572            if let Some(session) = self.sessions.get(&id) {
573                session.outbox.send(json!({"op": "info", "d": d}));
574            }
575        }
576    }
577
578    // ------------------------------------------------------------------------------------------ tag info
579
580    fn allowed(token: &TokenConfig, qnames: &[&str]) -> bool {
581        token
582            .write
583            .iter()
584            .any(|p| qnames.iter().any(|q| glob_match(p, q)))
585    }
586
587    /// The info record of a resolved tag for a session.
588    fn info(&self, token: &TokenConfig, plc: &str, tag: &str, name: &str) -> Json {
589        let config = &self.shared.config;
590        let rule = self.shared.rules.effective(plc, tag);
591        let group = self.shared.rules.group(plc, tag);
592        let sent = self.qualify_str(name).map(|q| q.1).unwrap_or_default();
593        let writable = truthy(rule.get("writable"))
594            && Self::allowed(token, &[&format!("{plc}/{sent}"), &format!("{plc}/{tag}")]);
595        let mut record = Map::new();
596        if split_bit(tag).is_some() {
597            record.insert("type".into(), json!("BOOL"));
598        } else {
599            let type_info = self
600                .worker(plc)
601                .map(|w| w.type_info(tag))
602                .unwrap_or_default();
603            record.insert("type".into(), json!(type_info.type_));
604            if let Some(dims) = type_info.dims {
605                record.insert("dims".into(), json!(dims));
606            }
607        }
608        record.insert("writable".into(), json!(writable));
609        record.insert(
610            "rate_ms".into(),
611            json!(group.as_ref().map_or(config.cycle_ms, |g| g.rate_ms)),
612        );
613        record.insert("group".into(), json!(group.map(|g| g.name)));
614        if split_bit(tag).is_some() {
615            return Json::Object(record);
616        }
617        for key in ["min", "max", "units", "deadband", "bits"] {
618            if let Some(v) = rule.get(key).filter(|v| !v.is_null()) {
619                record.insert(key.into(), v.clone());
620            }
621        }
622        let description = rule
623            .get("description")
624            .filter(|d| truthy(Some(d)))
625            .cloned()
626            .or_else(|| {
627                self.worker(plc)
628                    .and_then(|w| w.description(tag))
629                    .map(Json::String)
630            });
631        if let Some(d) = description {
632            record.insert("description".into(), d);
633        }
634        Json::Object(record)
635    }
636
637    // ------------------------------------------------------------------------------------------ requests
638
639    /// (plc, tag as sent, resolved tag) of a name.
640    fn qualify_str(&self, name: &str) -> Result<(String, String, String), String> {
641        let (plc, tag) = self.shared.config.qualify(name)?;
642        let tag = canonical(tag);
643        let resolved = self.shared.rules.resolve(plc, &tag);
644        Ok((plc.to_owned(), tag, resolved))
645    }
646
647    fn qualify(&self, name: &Json) -> Result<(String, String, String), String> {
648        match name {
649            Json::String(s) => self.qualify_str(s),
650            _ => Err("tag names must be strings".into()),
651        }
652    }
653
654    /// One client message; a bad one gets an error message.
655    fn handle(&mut self, id: SessionId, text: &str) {
656        let Some(outbox) = self.sessions.get(&id).map(|s| Arc::clone(&s.outbox)) else {
657            return;
658        };
659        let msg: Json = match serde_json::from_str(text) {
660            Ok(msg) => msg,
661            Err(err) => {
662                outbox.send(json!({"op": "error", "id": null, "error": err.to_string()}));
663                return;
664            }
665        };
666        let Json::Object(msg) = msg else {
667            outbox.send(json!({"op": "error", "id": null, "error": format!("{}: not an object", py_repr(text))}));
668            return;
669        };
670        let msg_id = msg.get("id").cloned().unwrap_or(Json::Null);
671        if let Err(error) = self.request(id, &msg, &outbox) {
672            outbox.send(json!({"op": "error", "id": msg_id, "error": error}));
673        }
674    }
675
676    fn request(
677        &mut self,
678        id: SessionId,
679        msg: &Map<String, Json>,
680        outbox: &Outbox,
681    ) -> Result<(), String> {
682        let field = |key: &str| {
683            msg.get(key)
684                .ok_or_else(|| format!("missing field {}", py_repr(key)))
685        };
686        let msg_id = msg.get("id").cloned().unwrap_or(Json::Null);
687        match field("op")? {
688            Json::String(op) if op == "sub" => {
689                let (tags, page) = (
690                    msg.get("tags").filter(|t| !t.is_null()),
691                    msg.get("page").filter(|p| !p.is_null()),
692                );
693                if tags.is_none() && page.is_none() {
694                    return Err("missing field 'tags'".into());
695                }
696                let sub_id = field("id")?.clone();
697                let tier = match msg.get("tier") {
698                    None => 1,
699                    Some(t) => match t.as_f64() {
700                        Some(x) if x == 1.0 || x == 2.0 || x == 3.0 => x as u8,
701                        _ => return Err("tier must be 1, 2 or 3".into()),
702                    },
703                };
704                let page = match page {
705                    None => None,
706                    Some(Json::String(p)) => Some(p.clone()),
707                    Some(_) => return Err("page must be a string".into()),
708                };
709                self.subscribe(id, &sub_id, tags, tier, page.as_deref())?;
710                outbox.send(json!({"op": "ack", "id": msg_id, "ok": true, "error": null}));
711            }
712            Json::String(op) if op == "unsub" => {
713                let sub_id = field("id")?.clone();
714                let ok = self.unsubscribe(id, &sub_id);
715                outbox.send(json!({"op": "ack", "id": msg_id, "ok": ok, "error": if ok { Json::Null } else { json!("unknown id") }}));
716            }
717            Json::String(op) if op == "write" => {
718                let write_id = field("id")?.clone();
719                let tag = msg.get("tag").cloned().unwrap_or(Json::Null);
720                let value = msg.get("value").cloned().unwrap_or(Json::Null);
721                self.write(id, Some(write_id), &tag, &value, None);
722            }
723            Json::String(op) if op == "momentary" => {
724                let action = field("action")?;
725                let tag = msg.get("tag").cloned().unwrap_or(Json::Null);
726                match action.as_str() {
727                    Some("hold") => self.hold_button(id, &tag),
728                    Some(a @ ("press" | "release")) => {
729                        let write_id = field("id")?.clone();
730                        let momentary = if a == "press" { "press" } else { "release" };
731                        self.write(
732                            id,
733                            Some(write_id),
734                            &tag,
735                            &json!(a == "press"),
736                            Some(momentary),
737                        );
738                    }
739                    _ => return Err(format!("unknown action {}", py_value_repr(action))),
740                }
741            }
742            op => return Err(format!("unknown op {}", py_value_repr(op))),
743        }
744        Ok(())
745    }
746
747    /// Subscribes names (or, with `names` None, a page's learned tags); queues the info of names new to the
748    /// session and the cached values. A tier-1 sub with a page teaches the server that page's tags.
749    fn subscribe(
750        &mut self,
751        id: SessionId,
752        sub_id: &Json,
753        names: Option<&Json>,
754        tier: u8,
755        page: Option<&str>,
756    ) -> Result<(), String> {
757        let mut follow = None;
758        let mut tags: IndexMap<String, String> = IndexMap::new();
759        match names {
760            None => {
761                let page = page.ok_or("missing field 'tags'")?;
762                follow = Some(page.to_owned());
763                let learned: Vec<String> = self
764                    .pages
765                    .get(page)
766                    .map(|p| p.iter().cloned().collect())
767                    .unwrap_or_default();
768                for qname in learned {
769                    tags.insert(self.client_name(&qname), self.resolved(&qname));
770                }
771            }
772            Some(Json::Array(names)) => {
773                for name in names {
774                    let (plc, _, tag) = self.qualify(name)?;
775                    tags.insert(py_str(name), format!("{plc}/{tag}"));
776                }
777            }
778            Some(_) => return Err("tags must be a list".into()),
779        }
780        let key = sub_id.to_string();
781        let Some(session) = self.sessions.get_mut(&id) else {
782            return Ok(());
783        };
784        // replaced: released after the new one counts, so no tag drops out
785        let old = session.subs.shift_remove(&key);
786        session.subs.insert(
787            key,
788            Sub {
789                tier,
790                tags: tags.clone(),
791                follow: follow.clone(),
792            },
793        );
794        let qnames: HashSet<String> = tags.values().cloned().collect();
795        let mut sorted: Vec<&String> = qnames.iter().collect();
796        sorted.sort();
797        for qname in sorted {
798            self.hold(qname, tier);
799        }
800        let mut all = qnames.clone();
801        if let Some(old) = old {
802            let gone: Vec<String> = old
803                .tags
804                .values()
805                .cloned()
806                .collect::<HashSet<_>>()
807                .into_iter()
808                .collect();
809            self.release(old.tier, &gone);
810            all.extend(gone);
811        }
812        self.reindex(id, &all);
813        let mut info = Map::new();
814        let mut snapshot = Vec::new();
815        for (name, qname) in &tags {
816            let Some((plc, tag)) = qname.split_once('/') else {
817                continue;
818            };
819            let Some(session) = self.sessions.get(&id) else {
820                return Ok(());
821            };
822            if !session.info_sent.contains(name) {
823                info.insert(name.clone(), self.info(&session.token, plc, tag, name));
824            }
825            if let Some(entry) = self.worker(plc).and_then(|w| w.snapshot(tag)) {
826                snapshot.push((name.clone(), entry.to_json()));
827            }
828        }
829        if let Some(session) = self.sessions.get_mut(&id) {
830            session.info_sent.extend(info.keys().cloned());
831            if !info.is_empty() {
832                session.outbox.send(json!({"op": "info", "d": info}));
833            }
834            session.outbox.push_all(snapshot);
835        }
836        if let (Some(page), None, 1) = (page, &follow, tier) {
837            self.learn(page, &qnames);
838        }
839        Ok(())
840    }
841
842    fn unsubscribe(&mut self, id: SessionId, sub_id: &Json) -> bool {
843        let Some(sub) = self
844            .sessions
845            .get_mut(&id)
846            .and_then(|s| s.subs.shift_remove(&sub_id.to_string()))
847        else {
848            return false;
849        };
850        let qnames: HashSet<String> = sub.tags.values().cloned().collect();
851        let list: Vec<String> = qnames.iter().cloned().collect();
852        self.release(sub.tier, &list);
853        self.reindex(id, &qnames);
854        true
855    }
856
857    /// One more reference at `tier`; the worker reads the tag at the lowest tier held.
858    fn hold(&mut self, qname: &str, tier: u8) {
859        let counts = self.refs.entry(qname.to_owned()).or_default();
860        if counts.keys().next().is_none_or(|&min| tier < min) {
861            if let Some((plc, tag)) = qname.split_once('/') {
862                if let Some(worker) = self.shared.workers.get(plc) {
863                    worker.set_tier(tag, Some(tier));
864                }
865            }
866        }
867        *counts.entry(tier).or_default() += 1;
868    }
869
870    fn release(&mut self, tier: u8, qnames: &[String]) {
871        for qname in qnames {
872            let Some(counts) = self.refs.get_mut(qname) else {
873                continue;
874            };
875            let before = counts.keys().next().copied();
876            if let Some(n) = counts.get_mut(&tier) {
877                *n = n.saturating_sub(1);
878                if *n == 0 {
879                    counts.remove(&tier);
880                }
881            }
882            let after = counts.keys().next().copied();
883            if counts.is_empty() {
884                self.refs.remove(qname);
885            }
886            if after != before {
887                if let Some((plc, tag)) = qname.split_once('/') {
888                    if let Some(worker) = self.shared.workers.get(plc) {
889                        worker.set_tier(tag, after);
890                    }
891                }
892            }
893        }
894    }
895
896    /// Rebuilds the session's entries of `subscribers` for these qualified names from its subs.
897    fn reindex(&mut self, id: SessionId, qnames: &HashSet<String>) {
898        let Some(session) = self.sessions.get_mut(&id) else {
899            return;
900        };
901        let mut names: HashMap<&String, HashSet<String>> = HashMap::new();
902        for sub in session.subs.values() {
903            for (name, qname) in &sub.tags {
904                if qnames.contains(qname) {
905                    names.entry(qname).or_default().insert(name.clone());
906                }
907            }
908        }
909        for qname in qnames {
910            match names.remove(qname) {
911                Some(n) => {
912                    self.subscribers
913                        .entry(qname.clone())
914                        .or_default()
915                        .insert(id, n);
916                }
917                None => {
918                    if let Some(subs) = self.subscribers.get_mut(qname) {
919                        subs.remove(&id);
920                        if subs.is_empty() {
921                            self.subscribers.remove(qname);
922                        }
923                    }
924                }
925            }
926        }
927        // a name subscribed again later gets fresh info (its type may be known by then)
928        let current: HashSet<&String> = session.subs.values().flat_map(|s| s.tags.keys()).collect();
929        session.info_sent.retain(|n| current.contains(n));
930    }
931
932    /// A learned name with its bit alias resolved as the types now say.
933    fn resolved(&self, qname: &str) -> String {
934        match qname.split_once('/') {
935            Some((plc, tag)) => format!("{plc}/{}", self.shared.rules.resolve(plc, tag)),
936            None => qname.to_owned(),
937        }
938    }
939
940    /// How a page subscription names a learned tag: unqualified when there is only one PLC.
941    fn client_name(&self, qname: &str) -> String {
942        match qname.split_once('/') {
943            Some((_, tag)) if self.shared.workers.len() == 1 => tag.to_owned(),
944            _ => qname.to_owned(),
945        }
946    }
947
948    fn learn(&mut self, page: &str, qnames: &HashSet<String>) {
949        let known = self.pages.entry(page.to_owned()).or_default();
950        if qnames.iter().all(|q| known.contains(q)) {
951            return;
952        }
953        known.extend(qnames.iter().cloned());
954        if self.shared.config.pages_file.is_some() && !self.save_scheduled {
955            self.save_scheduled = true;
956            self.later(SAVE_DELAY, Msg::SavePages);
957        }
958        // page subscriptions follow what their page learns
959        let mut follows = Vec::new();
960        for (id, session) in &self.sessions {
961            for (sub_id, sub) in &session.subs {
962                if sub.follow.as_deref() == Some(page) {
963                    follows.push((*id, sub_id.clone(), sub.tier));
964                }
965            }
966        }
967        for (id, sub_id, tier) in follows {
968            let sub_id: Json = serde_json::from_str(&sub_id).unwrap_or(Json::String(sub_id));
969            let _ = self.subscribe(id, &sub_id, None, tier, Some(page));
970        }
971    }
972
973    fn save_pages(&mut self) {
974        self.save_scheduled = false;
975        if let Some(path) = &self.shared.config.pages_file {
976            if let Err(err) = save_pages(path, &self.pages) {
977                log::warn!("cannot write {}: {err}", path.display());
978            }
979        }
980    }
981
982    /// A closed session: its subscriptions go and its pressed momentary tags are released.
983    fn drop_session(&mut self, id: SessionId) {
984        let sub_ids: Vec<String> = self
985            .sessions
986            .get(&id)
987            .map(|s| s.subs.keys().cloned().collect())
988            .unwrap_or_default();
989        for sub_id in sub_ids {
990            let key: Json = serde_json::from_str(&sub_id).unwrap_or(Json::String(sub_id));
991            self.unsubscribe(id, &key);
992        }
993        let Some(session) = self.sessions.remove(&id) else {
994            return;
995        };
996        self.shared
997            .sessions
998            .store(self.sessions.len(), Ordering::Relaxed);
999        for (_, (_, name)) in session.pressed {
1000            self.submit(
1001                &session.token,
1002                id,
1003                None,
1004                &name,
1005                &json!(false),
1006                Some("disconnect"),
1007            );
1008        }
1009    }
1010
1011    // ------------------------------------------------------------------------------------------ writes
1012
1013    fn reject(&self, error: &str, kind: &str) -> WriteResult {
1014        self.shared.write_rejects.fetch_add(1, Ordering::Relaxed);
1015        WriteResult::failed(error, kind)
1016    }
1017
1018    /// The hub's checks, then the write is queued (no await in between, so writes keep their order). Returns
1019    /// (qualified name, old value, the rejection if any); `done` gets the result either way.
1020    #[allow(clippy::too_many_arguments)]
1021    fn submit(
1022        &mut self,
1023        token: &TokenConfig,
1024        session: SessionId,
1025        id: Option<Json>,
1026        name: &Json,
1027        value: &Json,
1028        momentary: Option<&'static str>,
1029    ) -> (String, bool) {
1030        let (qname, old, rejection) = self.check_write(token, name, value, momentary.is_some());
1031        let audit = self.audit.clone().map(|tx| {
1032            let line = json!({
1033                "time": "", "token": token.name, "tag": qname, "old": old, "value": value,
1034            });
1035            (tx, line)
1036        });
1037        let tx = self.tx.clone();
1038        let done_msg = WriteDone {
1039            session,
1040            token: token.clone(),
1041            id,
1042            qname: qname.clone(),
1043            name: name.clone(),
1044            momentary,
1045            result: WriteResult::failed("", ""),
1046        };
1047        let done = move |result: WriteResult| {
1048            if let Some((audit_tx, mut line)) = audit {
1049                line["time"] = json!(iso_now());
1050                line["ok"] = json!(result.ok);
1051                line["error"] = json!(result.error);
1052                line["kind"] = json!(result.kind);
1053                if let Some(m) = momentary {
1054                    line["momentary"] = json!(m);
1055                }
1056                let _ = audit_tx.send(line.to_string());
1057            }
1058            let _ = tx.send(Msg::WriteDone(Box::new(WriteDone { result, ..done_msg })));
1059        };
1060        match rejection {
1061            Some(result) => {
1062                done(result);
1063                (qname, false)
1064            }
1065            None => {
1066                self.shared.writes.fetch_add(1, Ordering::Relaxed);
1067                let (plc, tag) = qname.split_once('/').unwrap_or_default();
1068                let value = from_json(value).unwrap_or(ciplink::Value::Bool(false));
1069                match self.worker(plc) {
1070                    Some(worker) => worker.write(tag, value, Box::new(done)),
1071                    None => done(WriteResult::failed("unknown PLC", "bad_request")),
1072                }
1073                (qname, true)
1074            }
1075        }
1076    }
1077
1078    fn check_write(
1079        &self,
1080        token: &TokenConfig,
1081        name: &Json,
1082        value: &Json,
1083        momentary: bool,
1084    ) -> (String, Json, Option<WriteResult>) {
1085        let (plc, sent, tag) = match self.qualify(name) {
1086            Ok(q) => q,
1087            Err(err) => {
1088                return (
1089                    py_str(name),
1090                    Json::Null,
1091                    Some(self.reject(&err, "bad_request")),
1092                );
1093            }
1094        };
1095        let qname = format!("{plc}/{tag}");
1096        if value.is_null() {
1097            return (
1098                qname,
1099                Json::Null,
1100                Some(self.reject("missing value", "bad_request")),
1101            );
1102        }
1103        if sent.contains('{') {
1104            // X{1} would match other rules than X: no min/max or writable bypass by spelling
1105            return (
1106                qname,
1107                Json::Null,
1108                Some(self.reject("element counts are not allowed in writes", "bad_request")),
1109            );
1110        }
1111        let worker = self.worker(&plc);
1112        let old = worker
1113            .and_then(|w| w.snapshot(&tag))
1114            .map_or(Json::Null, |e| e.value);
1115        let type_info = worker.map(|w| w.type_info(&tag)).unwrap_or_default();
1116        if type_info.dims.is_some() {
1117            // written by name, an array takes one value at element 0: its rules would be skipped
1118            return (
1119                qname,
1120                old,
1121                Some(self.reject("write an element of the array", "bad_request")),
1122            );
1123        }
1124        if momentary && type_info.type_.as_deref().is_some_and(|t| t != "BOOL") {
1125            return (
1126                qname,
1127                old,
1128                Some(self.reject("momentary needs a BOOL tag or a bit", "bad_request")),
1129            );
1130        }
1131        if !Self::allowed(token, &[&format!("{plc}/{sent}"), &qname]) {
1132            return (qname, old, Some(self.reject("not allowed", "not_allowed")));
1133        }
1134        let rule = self.shared.rules.effective(&plc, &tag);
1135        if !truthy(rule.get("writable")) {
1136            return (
1137                qname,
1138                old,
1139                Some(self.reject("not writable", "not_writable")),
1140            );
1141        }
1142        let bound = |key: &str| rule.get(key).and_then(Json::as_f64);
1143        if !in_range(value, bound("min"), bound("max")) {
1144            return (
1145                qname,
1146                old,
1147                Some(self.reject("out of range", "out_of_range")),
1148            );
1149        }
1150        (qname, old, None)
1151    }
1152
1153    /// A write, press or release from a client; `momentary` is press or release.
1154    fn write(
1155        &mut self,
1156        id: SessionId,
1157        write_id: Option<Json>,
1158        name: &Json,
1159        value: &Json,
1160        momentary: Option<&'static str>,
1161    ) {
1162        let Some(token) = self.sessions.get(&id).map(|s| s.token.clone()) else {
1163            return;
1164        };
1165        let (qname, queued) = self.submit(&token, id, write_id, name, value, momentary);
1166        match momentary {
1167            Some("press") if queued => {
1168                self.owed.remove(&qname);
1169                self.arm(id, &qname, name);
1170            }
1171            Some("release") => self.disarm(id, &qname),
1172            _ => {}
1173        }
1174    }
1175
1176    fn write_done(&mut self, done: WriteDone) {
1177        let WriteDone {
1178            session,
1179            token,
1180            id,
1181            qname,
1182            name,
1183            momentary,
1184            result,
1185        } = done;
1186        if let Some(m) = momentary.filter(|m| RELEASES.contains(m)) {
1187            // a release the PLC never got is sent again
1188            if result.ok || result.kind.as_deref().is_some_and(|k| FINAL.contains(&k)) {
1189                self.owed.remove(&qname);
1190            } else {
1191                self.owed.insert(qname.clone(), (token, session, name, m));
1192                let plc = qname.split_once('/').map_or("", |(p, _)| p);
1193                if self.worker(plc).is_some_and(|w| w.state() == "connected") {
1194                    // refused while connected (ciplink's reload backoff): no connect comes to resend it
1195                    self.later(RETRY_DELAY, Msg::Resend(qname.clone()));
1196                }
1197            }
1198        }
1199        if let (Some(id), Some(session)) = (id, self.sessions.get(&session)) {
1200            session.outbox.send(json!({
1201                "op": "ack", "id": id, "ok": result.ok, "error": result.error, "kind": result.kind, "value": result.value,
1202            }));
1203        }
1204    }
1205
1206    /// Writes an owed release again; left owed while the PLC is not connected (sent on connect), and dropped if
1207    /// the tag was pressed again meanwhile.
1208    fn resend(&mut self, qname: &str) {
1209        let plc = qname.split_once('/').map_or("", |(p, _)| p);
1210        if self.worker(plc).is_none_or(|w| w.state() != "connected") {
1211            return;
1212        }
1213        let Some((token, session, name, momentary)) = self.owed.remove(qname) else {
1214            return;
1215        };
1216        if self
1217            .sessions
1218            .values()
1219            .any(|s| s.pressed.contains_key(qname))
1220        {
1221            return;
1222        }
1223        self.submit(&token, session, None, &name, &json!(false), Some(momentary));
1224    }
1225
1226    /// Refreshes the watchdog of a pressed tag; ignored if the tag is not pressed.
1227    fn hold_button(&mut self, id: SessionId, name: &Json) {
1228        let Ok((plc, _, tag)) = self.qualify(name) else {
1229            return;
1230        };
1231        let qname = format!("{plc}/{tag}");
1232        let pressed = self
1233            .sessions
1234            .get(&id)
1235            .and_then(|s| s.pressed.get(&qname))
1236            .map(|(_, name)| name.clone());
1237        if let Some(name) = pressed {
1238            self.arm(id, &qname, &name);
1239        }
1240    }
1241
1242    fn arm(&mut self, id: SessionId, qname: &str, name: &Json) {
1243        self.generation += 1;
1244        let generation = self.generation;
1245        let Some(session) = self.sessions.get_mut(&id) else {
1246            return;
1247        };
1248        session.pressed.shift_remove(qname); // held again: last, as the Python dict re-insert
1249        session
1250            .pressed
1251            .insert(qname.to_owned(), (generation, name.clone()));
1252        let delay = Duration::from_millis(self.shared.config.momentary_timeout_ms);
1253        self.later(delay, Msg::Timeout(id, qname.to_owned(), generation));
1254    }
1255
1256    fn disarm(&mut self, id: SessionId, qname: &str) {
1257        if let Some(session) = self.sessions.get_mut(&id) {
1258            session.pressed.shift_remove(qname);
1259        }
1260    }
1261
1262    fn timeout(&mut self, id: SessionId, qname: &str, generation: u64) {
1263        let Some(session) = self.sessions.get_mut(&id) else {
1264            return;
1265        };
1266        if session
1267            .pressed
1268            .get(qname)
1269            .is_none_or(|(g, _)| *g != generation)
1270        {
1271            return; // released, or held again since
1272        }
1273        let Some((_, name)) = session.pressed.shift_remove(qname) else {
1274            return;
1275        };
1276        let token = session.token.clone();
1277        self.shared
1278            .momentary_timeouts
1279            .fetch_add(1, Ordering::Relaxed);
1280        self.submit(&token, id, None, &name, &json!(false), Some("timeout"));
1281    }
1282}
1283
1284/// Python's `repr()` of a JSON value in error messages.
1285fn py_value_repr(value: &Json) -> String {
1286    match value {
1287        Json::String(s) => py_repr(s),
1288        other => py_str(other),
1289    }
1290}
1291
1292/// The audit thread: appends the queued lines, so a slow disk never blocks the hub.
1293fn write_audit(path: &Path, rx: &std::sync::mpsc::Receiver<String>) {
1294    use std::io::Write as _;
1295    while let Ok(line) = rx.recv() {
1296        let result = std::fs::OpenOptions::new()
1297            .create(true)
1298            .append(true)
1299            .open(path)
1300            .and_then(|mut file| writeln!(file, "{line}"));
1301        if let Err(err) = result {
1302            log::warn!("cannot write {}: {err}", path.display());
1303        }
1304    }
1305}
1306
1307#[cfg(test)]
1308#[path = "hub_tests.rs"]
1309mod hub_tests;
1310
1311#[cfg(test)]
1312#[allow(clippy::unwrap_used)]
1313mod tests {
1314    use super::*;
1315
1316    #[test]
1317    fn ranges() {
1318        assert!(in_range(&json!(5), Some(0.0), Some(10.0)));
1319        assert!(!in_range(&json!(11), Some(0.0), Some(10.0)));
1320        assert!(!in_range(&json!({"a": [1, 20]}), None, Some(10.0)));
1321        assert!(in_range(&json!(true), Some(5.0), None));
1322        assert!(in_range(&json!("x"), Some(5.0), None));
1323    }
1324
1325    #[test]
1326    fn iso_time_shape() {
1327        let t = iso_now();
1328        assert_eq!(t.len(), "2026-10-23T14:17:33.768123+00:00".len());
1329        assert!(t.ends_with("+00:00"));
1330    }
1331
1332    #[test]
1333    fn secrets_compare() {
1334        assert!(constant_time_eq(b"abc", b"abc"));
1335        assert!(!constant_time_eq(b"abc", b"abd"));
1336        assert!(!constant_time_eq(b"abc", b"ab"));
1337        assert!(!constant_time_eq(b"", b"a"));
1338    }
1339}