tanuki-app 0.0.1

Generic GUI for Tanuki systems
Documentation
use std::{
    collections::hash_map::Entry,
    sync::{Arc, mpsc::Receiver},
    time::Instant,
};

use egui::{
    Align, Button, CentralPanel, Layout, Margin, ScrollArea, SidePanel, TextWrapMode,
    ahash::{HashMap, HashMapExt as _},
    vec2,
};
use tanuki::{
    PublishEvent, TanukiConnection,
    capabilities::{User, media::Media, on_off::OnOff},
};
use tanuki_common::{
    EntityId, Topic,
    capabilities::{
        buttons::ButtonEvent,
        light::LightState,
        media::{MediaCapabilities, MediaCommand, MediaState, MediaStatus},
        on_off::OnOffCommand,
        sensor::SensorValue,
    },
};

pub struct TanukiApp {
    rx: Receiver<PublishEvent>,
    tanuki: Arc<TanukiConnection>,
    tokio_rt: tokio::runtime::Handle,
    entities: HashMap<EntityId, TanukiEntity>,
    selected_entity: Option<EntityId>,
    selected_capability: Option<String>,
}

pub struct TanukiEntity {
    pub id: EntityId,
    pub name: Option<String>,
    pub capabilities: HashMap<String, TanukiCapability>,
}

impl TanukiEntity {
    pub fn capability_mut(&mut self, name: &str) -> Option<&mut TanukiCapability> {
        match self.capabilities.entry(name.to_string()) {
            Entry::Occupied(entry) => Some(entry.into_mut()),
            Entry::Vacant(entry) => {
                if let Some(cap) = TanukiCapability::new_from_name(name) {
                    Some(entry.insert(cap))
                } else {
                    None
                }
            }
        }
    }
}

pub enum TanukiCapability {
    Buttons(TanukiButtonsState),
    Light(TanukiLightState),
    Media(TanukiMediaState),
    OnOff(TanukiOnOffState),
    Sensor(TanukiSensorState),
}

impl TanukiCapability {
    pub fn new_from_name(name: &str) -> Option<Self> {
        match name {
            "tanuki.buttons" => Some(TanukiCapability::Buttons(Default::default())),
            "tanuki.light" => Some(TanukiCapability::Light(Default::default())),
            "tanuki.media" => Some(TanukiCapability::Media(Default::default())),
            "tanuki.on_off" => Some(TanukiCapability::OnOff(Default::default())),
            "tanuki.sensor" => Some(TanukiCapability::Sensor(Default::default())),
            _ => None,
        }
    }
}

#[derive(Default)]
pub struct TanukiSensorState {
    pub sensors: HashMap<EntityId, SensorHistory>,
}

#[derive(Default)]
pub struct SensorHistory {
    pub unit: String,
    pub timeline: Timeline<SensorValue>,
}

#[derive(Default)]
pub struct TanukiOnOffState {
    pub on: Timeline<bool>,
}

#[derive(Default)]
pub struct TanukiLightState {
    pub state: Option<LightState>,
}

#[derive(Default)]
pub struct TanukiMediaState {
    pub capabilities: MediaCapabilities,
    pub state: MediaState,
}

#[derive(Default)]
pub struct TanukiButtonsState {
    pub buttons: HashMap<String, Timeline<ButtonEvent>>,
}

pub struct Timeline<T> {
    pub readings: Vec<(Instant, T)>,
}

impl<T> Default for Timeline<T> {
    fn default() -> Self {
        Self { readings: Vec::new() }
    }
}

impl<T> Timeline<T> {
    pub fn last(&self) -> Option<&T> {
        self.readings.last().map(|(_, v)| v)
    }

    pub fn update(&mut self, payload: T) {
        self.readings.push((Instant::now(), payload));
    }

    pub fn update_with_timestamp(&mut self, timestamp: Instant, payload: T) {
        self.readings.push((timestamp, payload));
    }
}

impl TanukiApp {
    pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
        let (tx, rx) = std::sync::mpsc::channel::<PublishEvent>();

        let rt = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .unwrap();

        let tokio_rt = rt.handle().clone();

        let (tanuki_tx, tanuki_rx) = std::sync::mpsc::sync_channel(1);

        let ctx = cc.egui_ctx.clone();
        std::thread::spawn(move || {
            rt.block_on(async {
                let tanuki = tanuki::TanukiConnection::connect("tanuki-app", "192.168.0.106:1883")
                    .await
                    .unwrap();

                tanuki_tx.send(tanuki.clone()).unwrap();

                tanuki.raw_subscribe("tanuki/#").await.unwrap();

                loop {
                    match tanuki.recv().await {
                        Ok(packet) => {
                            log::debug!("Received packet: {packet:#?}");
                            tx.send(packet).unwrap();
                            ctx.request_repaint();
                        }
                        Err(e) => {
                            log::error!("Error receiving packet: {e}");
                        }
                    }
                }
            });
        });

        let tanuki = tanuki_rx.recv().unwrap();

        cc.egui_ctx.all_styles_mut(|s| {
            s.interaction.selectable_labels = false;

            s.spacing.window_margin = Margin::symmetric(10, 8);
            s.spacing.item_spacing = vec2(8., 1.);
            s.spacing.button_padding = vec2(8., 6.);
            s.spacing.interact_size = vec2(40., 22.);
        });

        Self {
            rx,
            tanuki,
            tokio_rt,
            entities: HashMap::new(),
            selected_entity: None,
            selected_capability: None,
        }
    }

    pub fn entity_mut(&mut self, id: EntityId) -> &mut TanukiEntity {
        self.entities
            .entry(id.clone())
            .or_insert_with(|| TanukiEntity {
                id,
                name: None,
                capabilities: HashMap::new(),
            })
    }
}

impl eframe::App for TanukiApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        while let Ok(packet) = self.rx.try_recv() {
            match packet.topic {
                Topic::EntityMeta { entity, key } if key == "name" => {
                    if let Some(name) = packet.payload.as_str() {
                        self.entity_mut(entity).name = Some(name.to_owned());
                    }
                }
                Topic::CapabilityMeta { entity, capability, key } if key == "version" => {
                    log::info!("New capability: {entity} / {capability}");
                    if let Some(cap) = TanukiCapability::new_from_name(&capability) {
                        log::info!("Created capability instance for {capability}");

                        self.entity_mut(entity)
                            .capabilities
                            .insert(capability.to_string(), cap);
                    } else {
                        log::warn!("Unknown capability name: {capability}");
                    }
                }
                Topic::CapabilityData { entity, capability, rest }
                    if capability == "tanuki.media" && rest == "state" =>
                {
                    if let Some(TanukiCapability::Media(state)) = self
                        .entity_mut(entity)
                        .capabilities
                        .get_mut(capability.as_str())
                        && let Ok(media_state) =
                            serde_json::from_value::<MediaState>(packet.payload)
                    {
                        state.state = media_state;
                    }
                }
                Topic::CapabilityData { entity, capability, rest }
                    if capability == "tanuki.media" && rest == "capabilities" =>
                {
                    if let Some(TanukiCapability::Media(state)) = self
                        .entity_mut(entity)
                        .capabilities
                        .get_mut(capability.as_str())
                        && let Ok(media_caps) =
                            serde_json::from_value::<MediaCapabilities>(packet.payload)
                    {
                        state.capabilities = media_caps;
                    }
                }
                Topic::CapabilityData { entity, capability, rest }
                    if capability == "tanuki.on_off" && rest == "state" =>
                {
                    if let Some(TanukiCapability::OnOff(state)) = self
                        .entity_mut(entity)
                        .capabilities
                        .get_mut(capability.as_str())
                        && let Ok(on) = serde_json::from_value::<bool>(packet.payload)
                    {
                        state.on.update(on);
                    }
                }
                _ => {}
            }
        }

        SidePanel::left("entities")
            .resizable(false)
            .show(ctx, |ui| {
                ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
                ScrollArea::vertical().show(ui, |ui| {
                    ui.with_layout(Layout::top_down_justified(Align::Min), |ui| {
                        for (entity_id, entity) in &self.entities {
                            ui.selectable_value(
                                &mut self.selected_entity,
                                Some(entity_id.clone()),
                                entity.name.as_deref().unwrap_or(entity_id.as_str()),
                            );
                        }
                    });
                });
            });

        if let Some(selected_entity_id) = &self.selected_entity {
            let entity = self.entities.get(selected_entity_id).unwrap();

            SidePanel::left("capabilities")
                .resizable(false)
                .show(ctx, |ui| {
                    ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);

                    ScrollArea::vertical().show(ui, |ui| {
                        ui.with_layout(Layout::top_down_justified(Align::Min), |ui| {
                            for cap_name in entity.capabilities.keys() {
                                ui.selectable_value(
                                    &mut self.selected_capability,
                                    Some(cap_name.clone()),
                                    cap_name,
                                );
                            }
                        });
                    });
                });

            if let Some(selected_capability_name) = &self.selected_capability
                && let Some(capability) = entity.capabilities.get(selected_capability_name)
            {
                CentralPanel::default().show(ctx, |ui| match capability {
                    TanukiCapability::Buttons(_state) => {
                        ui.heading("todo");
                    }
                    TanukiCapability::Light(_state) => {
                        ui.heading("todo");
                    }
                    TanukiCapability::Media(state) => {
                        if let Some(title) = &state.state.info.title {
                            ui.heading(title);
                        }

                        if let Some(artist) = state.state.info.artists.first() {
                            ui.label(artist);
                        }

                        ui.add_space(4.);

                        match state.state.status {
                            MediaStatus::Playing => ui.label("Playing"),
                            MediaStatus::Paused => ui.label("Paused"),
                            MediaStatus::Stopped => ui.label("Stopped"),
                            MediaStatus::Buffering => ui.label("Buffering"),
                            MediaStatus::Idle => ui.label("Idle"),
                            MediaStatus::Unknown => ui.label("Unknown status"),
                        };

                        ui.add_space(8.);

                        ui.horizontal(|ui| {
                            for (cap, label, cmd) in [
                                (state.capabilities.play, "Play", MediaCommand::Play),
                                (state.capabilities.pause, "Pause", MediaCommand::Pause),
                                (state.capabilities.stop, "Stop", MediaCommand::Stop),
                                (state.capabilities.previous, "Previous", MediaCommand::Previous),
                                (state.capabilities.next, "Next", MediaCommand::Next),
                            ] {
                                if ui.add_enabled(cap, Button::new(label)).clicked() {
                                    let tanuki = self.tanuki.clone();
                                    let entity = selected_entity_id.clone();
                                    let cmd = cmd.clone();
                                    self.tokio_rt.spawn(async move {
                                        let entity = tanuki.entity(entity).await.unwrap();
                                        let cap = entity.capability::<Media<User>>().await.unwrap();
                                        cap.command(cmd).await.unwrap();
                                    });
                                }
                            }
                        });
                    }
                    TanukiCapability::OnOff(state) => {
                        if let Some(on) = state.on.last() {
                            ui.label(format!("State: {}", if *on { "On" } else { "Off" }));
                        }

                        if ui.button("Toggle").clicked() {
                            let tanuki = self.tanuki.clone();
                            let entity = selected_entity_id.clone();
                            self.tokio_rt.spawn(async move {
                                let entity = tanuki.entity(entity).await.unwrap();
                                let cap = entity.capability::<OnOff<User>>().await.unwrap();
                                cap.command(OnOffCommand::Toggle).await.unwrap();
                            });
                        }
                    }
                    TanukiCapability::Sensor(_state) => {
                        ui.heading("todo");
                    }
                });
            }
        }
    }
}