7.4 KiB · text History d13c936
//! The session bus side.
//!
//! `io.neiam.ApollosCrib` on the session bus:
//!
//! ```text
//! /io/neiam/ApollosCrib                   io.neiam.ApollosCrib1
//!                                         org.freedesktop.DBus.ObjectManager
//! /io/neiam/ApollosCrib/query/gtfs_12     io.neiam.ApollosCrib1.Query
//! ```
//!
//! Everything is a property, and every change is a `PropertiesChanged`, since
//! that is what Plasma's QML `DBusProperties` binds to: a widget names a path
//! and its labels follow. Structured values are JSON strings, because a D-Bus
//! struct reaches QML as an opaque `QDBusArgument` and a string reaches it as
//! something `JSON.parse` can read.

use std::collections::HashMap;

use serde_json::Value;
use tokio::sync::mpsc;
use zbus::object_server::SignalEmitter;
use zbus::{fdo, interface};

use crate::source::Command;

pub const NAME: &str = "io.neiam.ApollosCrib";
pub const ROOT: &str = "/io/neiam/ApollosCrib";

/// A query's object path.
///
/// A path element allows only `[A-Za-z0-9_]`, and a key is `<type>-<id>`
/// with an id that may be anything a feed calls a stop. The type's dash
/// becomes `_`, so the common `gtfs-12` reads as `gtfs_12`; every other
/// character outside the alphabet becomes `_` and its UTF-8 bytes in hex,
/// so `gtfs-4:place-sstat` is `gtfs_4_3aplace_2dsstat` and two stops told
/// apart only by punctuation stay two objects.
pub fn query_path(key: &str) -> String {
    let (kind, id) = key.split_once('-').unwrap_or((key, ""));
    let escape = |part: &str| -> String {
        let mut out = String::new();
        for c in part.chars() {
            if c.is_ascii_alphanumeric() {
                out.push(c);
            } else {
                let mut buf = [0u8; 4];
                for b in c.encode_utf8(&mut buf).bytes() {
                    out.push_str(&format!("_{b:02x}"));
                }
            }
        }
        out
    };
    format!("{ROOT}/query/{}_{}", escape(kind), escape(id))
}

/// Where a key came from, so a refresh of it goes to the right place.
#[derive(Debug, Clone)]
pub struct Origin {
    pub source: String,
    pub query_id: u32,
}

pub struct Root {
    pub board: String,
    pub points: String,
    pub queries: Vec<String>,
    pub sources: String,
    pub connected: bool,
    pub origins: HashMap<String, Origin>,
    pub commands: HashMap<String, (bool, mpsc::Sender<Command>)>,
}

#[interface(name = "io.neiam.ApollosCrib1")]
impl Root {
    /// Every query, keyed `<type>-<id>`: `{name, type, id, query_id, source,
    /// updated, stale, data, meta, plus, points}`. Everything at once, for a
    /// widget that would rather bind one property than one path per query.
    #[zbus(property)]
    fn board(&self) -> String {
        self.board.clone()
    }

    /// Every query said in a few words, in key order: `[{key, name, type,
    /// text, color?, stale}]`. For a wallpaper, a tooltip, a ticker.
    #[zbus(property)]
    fn points(&self) -> String {
        self.points.clone()
    }

    /// The keys there are objects for.
    #[zbus(property)]
    fn queries(&self) -> Vec<String> {
        self.queries.clone()
    }

    /// Each source and whether it is connected: `[{name, connected, error?}]`.
    #[zbus(property)]
    fn sources(&self) -> String {
        self.sources.clone()
    }

    /// Whether any source is connected.
    #[zbus(property)]
    fn connected(&self) -> bool {
        self.connected
    }

    /// Ask every source for fresh data.
    async fn refresh(&self) {
        for (_, tx) in self.commands.values() {
            let _ = tx.send(Command::Refresh(None)).await;
        }
    }

    /// Ask for one query afresh. A Keryx refreshes just that query; an
    /// Ankyra has only whole boards to give.
    async fn refresh_query(&self, key: &str) -> fdo::Result<()> {
        let origin = self
            .origins
            .get(key)
            .ok_or_else(|| fdo::Error::InvalidArgs(format!("no query {key}")))?;
        let (keryx, tx) = self
            .commands
            .get(&origin.source)
            .ok_or_else(|| fdo::Error::Failed(format!("{} is not configured", origin.source)))?;
        let q = keryx.then_some(origin.query_id).filter(|q| *q != 0);
        tx.send(Command::Refresh(q))
            .await
            .map_err(|e| fdo::Error::Failed(e.to_string()))
    }

    /// A query has new data. The properties say so too; this is for a
    /// consumer that wants one signal to hang an animation off.
    #[zbus(signal)]
    pub async fn query_updated(emitter: &SignalEmitter<'_>, key: &str) -> zbus::Result<()>;
}

pub struct Query {
    pub key: String,
    pub kind: String,
    pub id: String,
    pub query_id: u32,
    pub name: String,
    pub source: String,
    pub updated: i64,
    pub stale: bool,
    pub data: Value,
    pub meta: Value,
    pub plus: Value,
    pub points: Vec<String>,
}

#[interface(name = "io.neiam.ApollosCrib1.Query")]
impl Query {
    #[zbus(property)]
    fn key(&self) -> String {
        self.key.clone()
    }

    /// The source type: `gtfs`, `weather`, ...
    #[zbus(property, name = "Type")]
    fn kind(&self) -> String {
        self.kind.clone()
    }

    /// Everything in the key after the type: `12`, or for a Plani broken out
    /// by stop, `4:place-sstat`.
    #[zbus(property)]
    fn id(&self) -> String {
        self.id.clone()
    }

    /// The id as a query id; 0 where it is not one.
    #[zbus(property)]
    fn query_id(&self) -> u32 {
        self.query_id
    }

    /// The query's name in Apollo's Crib, or its key if it was sent bare.
    #[zbus(property)]
    fn name(&self) -> String {
        self.name.clone()
    }

    /// `ankyra:<name>` or `keryx:<name>`.
    #[zbus(property)]
    fn source(&self) -> String {
        self.source.clone()
    }

    /// When it last arrived, in seconds since the epoch.
    #[zbus(property)]
    fn updated(&self) -> i64 {
        self.updated
    }

    /// Unheard from for longer than the configured `stale_after_secs`.
    #[zbus(property)]
    fn stale(&self) -> bool {
        self.stale
    }

    /// The condensed reading as JSON, in the apollos-types shape for its type.
    #[zbus(property)]
    fn data(&self) -> String {
        self.data.to_string()
    }

    /// The query's `meta`, as JSON.
    #[zbus(property)]
    fn meta(&self) -> String {
        self.meta.to_string()
    }

    /// The Plus reading as JSON, `null` where there is none.
    #[zbus(property)]
    fn plus(&self) -> String {
        self.plus.to_string()
    }

    /// The reading in a few words, a line each.
    #[zbus(property)]
    fn points(&self) -> Vec<String> {
        self.points.clone()
    }
}

/// Every property of a query, announced. One `PropertiesChanged` each:
/// zbus has no batch, and a reading rarely changes less than all of them.
pub async fn announce_query(q: &Query, e: &SignalEmitter<'_>) -> zbus::Result<()> {
    q.name_changed(e).await?;
    q.source_changed(e).await?;
    q.updated_changed(e).await?;
    q.stale_changed(e).await?;
    q.data_changed(e).await?;
    q.meta_changed(e).await?;
    q.plus_changed(e).await?;
    q.points_changed(e).await
}

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

    #[test]
    fn a_key_becomes_a_legal_path() {
        assert_eq!(query_path("gtfs-12"), "/io/neiam/ApollosCrib/query/gtfs_12");
        assert_eq!(
            query_path("gtfs-4:place-sstat"),
            "/io/neiam/ApollosCrib/query/gtfs_4_3aplace_2dsstat"
        );
        assert_ne!(query_path("gtfs-4:a-b"), query_path("gtfs-4:a_b"));
    }
}