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}