1use 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
19pub const PROTOCOL: u64 = 1;
21pub const FEATURES: [&str; 4] = ["info", "momentary", "readback", "errors"];
23const RELEASES: [&str; 3] = ["release", "timeout", "disconnect"];
24const 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#[cfg(not(test))]
36const SAVE_DELAY: Duration = Duration::from_secs(2);
37#[cfg(not(test))]
39const RETRY_DELAY: Duration = Duration::from_millis(500);
40#[cfg(test)]
42const SAVE_DELAY: Duration = Duration::from_millis(10);
43#[cfg(test)]
44const RETRY_DELAY: Duration = Duration::from_millis(100);
45
46pub type SessionId = u64;
48
49#[derive(Default)]
52pub struct Outbox {
53 queue: Mutex<(VecDeque<Json>, IndexMap<String, Json>)>,
54 notify: Notify,
55}
56
57impl Outbox {
58 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 pub fn push(&self, name: &str, entry: Json) {
70 self.push_all(vec![(name.to_owned(), entry)]);
71 }
72
73 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 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
105pub enum Msg {
107 Open(SessionId, TokenConfig, Arc<Outbox>),
109 Text(SessionId, String),
111 Close(SessionId),
113 Worker(Event),
115 WriteDone(Box<WriteDone>),
117 Timeout(SessionId, String, u64),
119 Resend(String),
121 SavePages,
123 Stop(oneshot::Sender<()>),
125}
126
127pub 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
138pub struct Shared {
140 pub config: Arc<Config>,
142 pub rules: Arc<Rules>,
144 pub workers: IndexMap<String, Arc<PlcWorker>>,
146 pub sessions: AtomicUsize,
148 pub writes: AtomicU64,
150 pub write_rejects: AtomicU64,
152 pub momentary_timeouts: AtomicU64,
154}
155
156impl Shared {
157 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 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 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 pressed: IndexMap<String, (u64, Json)>,
198}
199
200fn 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
211fn 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
224fn 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
249fn 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
266fn 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
283pub struct Hub {
285 shared: Arc<Shared>,
286 tx: mpsc::UnboundedSender<Msg>,
287 sessions: HashMap<SessionId, Session>,
288 refs: HashMap<String, BTreeMap<u8, usize>>,
290 subscribers: HashMap<String, HashMap<SessionId, HashSet<String>>>,
292 pages: BTreeMap<String, BTreeSet<String>>,
294 save_scheduled: bool,
295 owed: HashMap<String, (TokenConfig, SessionId, Json, &'static str)>,
297 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 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 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 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 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); }
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; 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 fn worker_event(&mut self, event: Event) {
434 match event {
435 Event::Values(plc, changes) => {
436 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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); 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; }
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
1284fn py_value_repr(value: &Json) -> String {
1286 match value {
1287 Json::String(s) => py_repr(s),
1288 other => py_str(other),
1289 }
1290}
1291
1292fn 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}