Skip to main content

tanuki_app/
app.rs

1use std::{
2    collections::hash_map::Entry,
3    sync::{Arc, mpsc::Receiver},
4    time::Instant,
5};
6
7use egui::{
8    Align, Button, CentralPanel, Layout, Margin, ScrollArea, SidePanel, TextWrapMode,
9    ahash::{HashMap, HashMapExt as _},
10    vec2,
11};
12use tanuki::{
13    PublishEvent, TanukiConnection,
14    capabilities::{User, media::Media, on_off::OnOff},
15};
16use tanuki_common::{
17    EntityId, Topic,
18    capabilities::{
19        buttons::ButtonEvent,
20        light::LightState,
21        media::{MediaCapabilities, MediaCommand, MediaState, MediaStatus},
22        on_off::OnOffCommand,
23        sensor::SensorValue,
24    },
25};
26
27pub struct TanukiApp {
28    rx: Receiver<PublishEvent>,
29    tanuki: Arc<TanukiConnection>,
30    tokio_rt: tokio::runtime::Handle,
31    entities: HashMap<EntityId, TanukiEntity>,
32    selected_entity: Option<EntityId>,
33    selected_capability: Option<String>,
34}
35
36pub struct TanukiEntity {
37    pub id: EntityId,
38    pub name: Option<String>,
39    pub capabilities: HashMap<String, TanukiCapability>,
40}
41
42impl TanukiEntity {
43    pub fn capability_mut(&mut self, name: &str) -> Option<&mut TanukiCapability> {
44        match self.capabilities.entry(name.to_string()) {
45            Entry::Occupied(entry) => Some(entry.into_mut()),
46            Entry::Vacant(entry) => {
47                if let Some(cap) = TanukiCapability::new_from_name(name) {
48                    Some(entry.insert(cap))
49                } else {
50                    None
51                }
52            }
53        }
54    }
55}
56
57pub enum TanukiCapability {
58    Buttons(TanukiButtonsState),
59    Light(TanukiLightState),
60    Media(TanukiMediaState),
61    OnOff(TanukiOnOffState),
62    Sensor(TanukiSensorState),
63}
64
65impl TanukiCapability {
66    pub fn new_from_name(name: &str) -> Option<Self> {
67        match name {
68            "tanuki.buttons" => Some(TanukiCapability::Buttons(Default::default())),
69            "tanuki.light" => Some(TanukiCapability::Light(Default::default())),
70            "tanuki.media" => Some(TanukiCapability::Media(Default::default())),
71            "tanuki.on_off" => Some(TanukiCapability::OnOff(Default::default())),
72            "tanuki.sensor" => Some(TanukiCapability::Sensor(Default::default())),
73            _ => None,
74        }
75    }
76}
77
78#[derive(Default)]
79pub struct TanukiSensorState {
80    pub sensors: HashMap<EntityId, SensorHistory>,
81}
82
83#[derive(Default)]
84pub struct SensorHistory {
85    pub unit: String,
86    pub timeline: Timeline<SensorValue>,
87}
88
89#[derive(Default)]
90pub struct TanukiOnOffState {
91    pub on: Timeline<bool>,
92}
93
94#[derive(Default)]
95pub struct TanukiLightState {
96    pub state: Option<LightState>,
97}
98
99#[derive(Default)]
100pub struct TanukiMediaState {
101    pub capabilities: MediaCapabilities,
102    pub state: MediaState,
103}
104
105#[derive(Default)]
106pub struct TanukiButtonsState {
107    pub buttons: HashMap<String, Timeline<ButtonEvent>>,
108}
109
110pub struct Timeline<T> {
111    pub readings: Vec<(Instant, T)>,
112}
113
114impl<T> Default for Timeline<T> {
115    fn default() -> Self {
116        Self { readings: Vec::new() }
117    }
118}
119
120impl<T> Timeline<T> {
121    pub fn last(&self) -> Option<&T> {
122        self.readings.last().map(|(_, v)| v)
123    }
124
125    pub fn update(&mut self, payload: T) {
126        self.readings.push((Instant::now(), payload));
127    }
128
129    pub fn update_with_timestamp(&mut self, timestamp: Instant, payload: T) {
130        self.readings.push((timestamp, payload));
131    }
132}
133
134impl TanukiApp {
135    pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
136        let (tx, rx) = std::sync::mpsc::channel::<PublishEvent>();
137
138        let rt = tokio::runtime::Builder::new_multi_thread()
139            .enable_all()
140            .build()
141            .unwrap();
142
143        let tokio_rt = rt.handle().clone();
144
145        let (tanuki_tx, tanuki_rx) = std::sync::mpsc::sync_channel(1);
146
147        let ctx = cc.egui_ctx.clone();
148        std::thread::spawn(move || {
149            rt.block_on(async {
150                let tanuki = tanuki::TanukiConnection::connect("tanuki-app", "192.168.0.106:1883")
151                    .await
152                    .unwrap();
153
154                tanuki_tx.send(tanuki.clone()).unwrap();
155
156                tanuki.raw_subscribe("tanuki/#").await.unwrap();
157
158                loop {
159                    match tanuki.recv().await {
160                        Ok(packet) => {
161                            log::debug!("Received packet: {packet:#?}");
162                            tx.send(packet).unwrap();
163                            ctx.request_repaint();
164                        }
165                        Err(e) => {
166                            log::error!("Error receiving packet: {e}");
167                        }
168                    }
169                }
170            });
171        });
172
173        let tanuki = tanuki_rx.recv().unwrap();
174
175        cc.egui_ctx.all_styles_mut(|s| {
176            s.interaction.selectable_labels = false;
177
178            s.spacing.window_margin = Margin::symmetric(10, 8);
179            s.spacing.item_spacing = vec2(8., 1.);
180            s.spacing.button_padding = vec2(8., 6.);
181            s.spacing.interact_size = vec2(40., 22.);
182        });
183
184        Self {
185            rx,
186            tanuki,
187            tokio_rt,
188            entities: HashMap::new(),
189            selected_entity: None,
190            selected_capability: None,
191        }
192    }
193
194    pub fn entity_mut(&mut self, id: EntityId) -> &mut TanukiEntity {
195        self.entities
196            .entry(id.clone())
197            .or_insert_with(|| TanukiEntity {
198                id,
199                name: None,
200                capabilities: HashMap::new(),
201            })
202    }
203}
204
205impl eframe::App for TanukiApp {
206    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
207        while let Ok(packet) = self.rx.try_recv() {
208            match packet.topic {
209                Topic::EntityMeta { entity, key } if key == "name" => {
210                    if let Some(name) = packet.payload.as_str() {
211                        self.entity_mut(entity).name = Some(name.to_owned());
212                    }
213                }
214                Topic::CapabilityMeta { entity, capability, key } if key == "version" => {
215                    log::info!("New capability: {entity} / {capability}");
216                    if let Some(cap) = TanukiCapability::new_from_name(&capability) {
217                        log::info!("Created capability instance for {capability}");
218
219                        self.entity_mut(entity)
220                            .capabilities
221                            .insert(capability.to_string(), cap);
222                    } else {
223                        log::warn!("Unknown capability name: {capability}");
224                    }
225                }
226                Topic::CapabilityData { entity, capability, rest }
227                    if capability == "tanuki.media" && rest == "state" =>
228                {
229                    if let Some(TanukiCapability::Media(state)) = self
230                        .entity_mut(entity)
231                        .capabilities
232                        .get_mut(capability.as_str())
233                        && let Ok(media_state) =
234                            serde_json::from_value::<MediaState>(packet.payload)
235                    {
236                        state.state = media_state;
237                    }
238                }
239                Topic::CapabilityData { entity, capability, rest }
240                    if capability == "tanuki.media" && rest == "capabilities" =>
241                {
242                    if let Some(TanukiCapability::Media(state)) = self
243                        .entity_mut(entity)
244                        .capabilities
245                        .get_mut(capability.as_str())
246                        && let Ok(media_caps) =
247                            serde_json::from_value::<MediaCapabilities>(packet.payload)
248                    {
249                        state.capabilities = media_caps;
250                    }
251                }
252                Topic::CapabilityData { entity, capability, rest }
253                    if capability == "tanuki.on_off" && rest == "state" =>
254                {
255                    if let Some(TanukiCapability::OnOff(state)) = self
256                        .entity_mut(entity)
257                        .capabilities
258                        .get_mut(capability.as_str())
259                        && let Ok(on) = serde_json::from_value::<bool>(packet.payload)
260                    {
261                        state.on.update(on);
262                    }
263                }
264                _ => {}
265            }
266        }
267
268        SidePanel::left("entities")
269            .resizable(false)
270            .show(ctx, |ui| {
271                ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
272                ScrollArea::vertical().show(ui, |ui| {
273                    ui.with_layout(Layout::top_down_justified(Align::Min), |ui| {
274                        for (entity_id, entity) in &self.entities {
275                            ui.selectable_value(
276                                &mut self.selected_entity,
277                                Some(entity_id.clone()),
278                                entity.name.as_deref().unwrap_or(entity_id.as_str()),
279                            );
280                        }
281                    });
282                });
283            });
284
285        if let Some(selected_entity_id) = &self.selected_entity {
286            let entity = self.entities.get(selected_entity_id).unwrap();
287
288            SidePanel::left("capabilities")
289                .resizable(false)
290                .show(ctx, |ui| {
291                    ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
292
293                    ScrollArea::vertical().show(ui, |ui| {
294                        ui.with_layout(Layout::top_down_justified(Align::Min), |ui| {
295                            for cap_name in entity.capabilities.keys() {
296                                ui.selectable_value(
297                                    &mut self.selected_capability,
298                                    Some(cap_name.clone()),
299                                    cap_name,
300                                );
301                            }
302                        });
303                    });
304                });
305
306            if let Some(selected_capability_name) = &self.selected_capability
307                && let Some(capability) = entity.capabilities.get(selected_capability_name)
308            {
309                CentralPanel::default().show(ctx, |ui| match capability {
310                    TanukiCapability::Buttons(_state) => {
311                        ui.heading("todo");
312                    }
313                    TanukiCapability::Light(_state) => {
314                        ui.heading("todo");
315                    }
316                    TanukiCapability::Media(state) => {
317                        if let Some(title) = &state.state.info.title {
318                            ui.heading(title);
319                        }
320
321                        if let Some(artist) = state.state.info.artists.first() {
322                            ui.label(artist);
323                        }
324
325                        ui.add_space(4.);
326
327                        match state.state.status {
328                            MediaStatus::Playing => ui.label("Playing"),
329                            MediaStatus::Paused => ui.label("Paused"),
330                            MediaStatus::Stopped => ui.label("Stopped"),
331                            MediaStatus::Buffering => ui.label("Buffering"),
332                            MediaStatus::Idle => ui.label("Idle"),
333                            MediaStatus::Unknown => ui.label("Unknown status"),
334                        };
335
336                        ui.add_space(8.);
337
338                        ui.horizontal(|ui| {
339                            for (cap, label, cmd) in [
340                                (state.capabilities.play, "Play", MediaCommand::Play),
341                                (state.capabilities.pause, "Pause", MediaCommand::Pause),
342                                (state.capabilities.stop, "Stop", MediaCommand::Stop),
343                                (state.capabilities.previous, "Previous", MediaCommand::Previous),
344                                (state.capabilities.next, "Next", MediaCommand::Next),
345                            ] {
346                                if ui.add_enabled(cap, Button::new(label)).clicked() {
347                                    let tanuki = self.tanuki.clone();
348                                    let entity = selected_entity_id.clone();
349                                    let cmd = cmd.clone();
350                                    self.tokio_rt.spawn(async move {
351                                        let entity = tanuki.entity(entity).await.unwrap();
352                                        let cap = entity.capability::<Media<User>>().await.unwrap();
353                                        cap.command(cmd).await.unwrap();
354                                    });
355                                }
356                            }
357                        });
358                    }
359                    TanukiCapability::OnOff(state) => {
360                        if let Some(on) = state.on.last() {
361                            ui.label(format!("State: {}", if *on { "On" } else { "Off" }));
362                        }
363
364                        if ui.button("Toggle").clicked() {
365                            let tanuki = self.tanuki.clone();
366                            let entity = selected_entity_id.clone();
367                            self.tokio_rt.spawn(async move {
368                                let entity = tanuki.entity(entity).await.unwrap();
369                                let cap = entity.capability::<OnOff<User>>().await.unwrap();
370                                cap.command(OnOffCommand::Toggle).await.unwrap();
371                            });
372                        }
373                    }
374                    TanukiCapability::Sensor(_state) => {
375                        ui.heading("todo");
376                    }
377                });
378            }
379        }
380    }
381}