The second of the web front-end's three pages. Lights get a toggle, five brightness steps and six colours; shutters get four presets, up/stop/down and a window that reflects the position Home Assistant reports. Its own violet hue rather than the player's teal, so the two pages read as siblings rather than one bleeding into the other - the same reason room.css has its own tokens. The backend keeps Home Assistant's URL and token and relays the calls, so this client learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha means Home Assistant is not configured, which is a normal answer: the tab simply does not appear. Polled, not subscribed, and only while the page is on screen. HA's own auth-and- subscribe websocket is more machinery than a room panel needs, and a lamp's state is not worth a request every 2.5 seconds when nobody is looking at it - this device has a music player to stay out of the way of. A tap patches the entity locally and records when; a poll that was already in flight when the patch landed is dropped for that entity rather than putting the lamp back to "off" until the next one catches up. That reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried over for the same reason. Nothing on a shutter card animates locally. A real cover reports its own movement and position, and a client-side guess at where it will end up is precisely what would fight with that - the design mockup animated it because it had no backend to ask. Two additions beyond a straight port, both because a kiosk has no pointer: CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a front-end whose README claims every screen is reachable from the keyboard. Plain TAB was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere else. --page opens directly on a page, which a kiosk may well want and which is the only way in with neither keyboard nor pointer. Verified against the real Home Assistant: the shutter reported open and the lamp off, and both drew that way. Nothing was switched - those entities are hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
335 lines
12 KiB
Rust
335 lines
12 KiB
Rust
//! The shapes the backend serves, and the calls that fetch them.
|
|
//!
|
|
//! Mirrors `python-backend/musicmouse/services/web/schemas.py`, by way of
|
|
//! `web/src/api/types.ts` - the three are hand-kept in step. Only the music player's
|
|
//! share of the API is here; settings, the IR remote, Home Assistant and the typing
|
|
//! game are all reachable and all deliberately absent.
|
|
//!
|
|
//! Every field the backend can omit is modelled with `#[serde(default)]` rather than a
|
|
//! required `Option`, so a backend that grows a field does not break this client and
|
|
//! one that loses a field fails at the one call site that cared.
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// Default for a development run; `--server` overrides it.
|
|
pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080";
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum AlbumKind {
|
|
#[default]
|
|
Music,
|
|
Book,
|
|
}
|
|
|
|
impl AlbumKind {
|
|
/// Shape is how you tell the two apart without reading anything: albums square,
|
|
/// audiobooks taller than wide, everywhere they appear. Nothing else in this app
|
|
/// may set a cover's aspect ratio. Ported from `web/src/lib/covers.ts`.
|
|
pub fn aspect(self) -> f32 {
|
|
match self {
|
|
Self::Music => 1.0,
|
|
Self::Book => 0.82,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Track {
|
|
pub title: String,
|
|
/// Seconds, read from the file's tags at scan time. `0.0` when it carries none.
|
|
#[serde(default)]
|
|
pub duration: f64,
|
|
/// A typing-reward track not yet earned. This front-end does not host the typing
|
|
/// game, but it still has to not show what the game is withholding.
|
|
#[serde(default)]
|
|
pub locked: bool,
|
|
}
|
|
|
|
// `series`, and the parts of `PlayerState` the UI does not read, are kept because these
|
|
// structs are the record of what the backend serves - dropping a field would make this
|
|
// file a list of what this front-end happens to use today, which is a much less useful
|
|
// thing to read next to `schemas.py`.
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Album {
|
|
pub id: String,
|
|
/// One of the four fixed shelf folder names: `Figuren`, `Musik`, `Hörbücher`,
|
|
/// `Kinderpodcasts`. What `Group::of` reads to spot a podcast.
|
|
pub section: String,
|
|
#[serde(default)]
|
|
pub kind: AlbumKind,
|
|
pub title: String,
|
|
#[serde(default)]
|
|
pub artist: String,
|
|
#[serde(default)]
|
|
pub series: Option<String>,
|
|
/// The figurine whose folder this is, when it has one.
|
|
#[serde(default)]
|
|
pub figure: Option<String>,
|
|
/// Series for audiobooks and podcasts, artist for music. What browsing groups by.
|
|
#[serde(default)]
|
|
pub category: String,
|
|
/// Exactly three `#rrggbb`: primary, secondary, accent, pulled out of the cover art
|
|
/// or synthesised from the id. The generated stand-in cover is painted from these.
|
|
#[serde(default)]
|
|
pub colors: Vec<String>,
|
|
#[serde(default)]
|
|
pub has_cover: bool,
|
|
#[serde(default)]
|
|
pub duration: f64,
|
|
#[serde(default)]
|
|
pub tracks: Vec<Track>,
|
|
#[serde(default)]
|
|
pub locked: bool,
|
|
}
|
|
|
|
impl Album {
|
|
pub fn is_book(&self) -> bool {
|
|
self.kind == AlbumKind::Book
|
|
}
|
|
|
|
/// `"Album · Artist"`, unless a podcast makes those the same string.
|
|
pub fn line(&self) -> String {
|
|
let prefix = if self.is_book() { "📖 " } else { "" };
|
|
if !self.artist.is_empty() && self.artist != self.title {
|
|
format!("{prefix}{} · {}", self.title, self.artist)
|
|
} else {
|
|
format!("{prefix}{}", self.title)
|
|
}
|
|
}
|
|
|
|
/// `colors` padded out to the three the painter wants, with the same fallbacks the
|
|
/// web client uses, so a library entry missing them looks the same in both.
|
|
pub fn palette(&self) -> [&str; 3] {
|
|
[
|
|
self.colors.first().map_or("#4a6fa5", String::as_str),
|
|
self.colors.get(1).map_or("#6a8fc5", String::as_str),
|
|
self.colors.get(2).map_or("#a5804a", String::as_str),
|
|
]
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct LibraryOut {
|
|
albums: Vec<Album>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
pub struct Connection {
|
|
#[serde(default)]
|
|
pub firmware: bool,
|
|
#[serde(default)]
|
|
pub mqtt: bool,
|
|
#[serde(default)]
|
|
pub lirc: bool,
|
|
}
|
|
|
|
/// What is playing. Owned entirely by the backend: the buttons on the mouse, a figurine
|
|
/// on the reader and Home Assistant all move it too, so this client follows it rather
|
|
/// than assuming it is in charge.
|
|
#[allow(dead_code)] // see the note above `Album`
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
pub struct PlayerState {
|
|
#[serde(default)]
|
|
pub playing: bool,
|
|
#[serde(default)]
|
|
pub album_id: Option<String>,
|
|
#[serde(default)]
|
|
pub album_title: Option<String>,
|
|
#[serde(default)]
|
|
pub artist: Option<String>,
|
|
#[serde(default)]
|
|
pub kind: Option<AlbumKind>,
|
|
#[serde(default)]
|
|
pub track_index: i32,
|
|
#[serde(default)]
|
|
pub track_title: Option<String>,
|
|
#[serde(default)]
|
|
pub track_count: i32,
|
|
#[serde(default)]
|
|
pub position: f64,
|
|
#[serde(default)]
|
|
pub duration: f64,
|
|
/// Percent, 0..100. The device's configured range never leaves the backend.
|
|
#[serde(default)]
|
|
pub volume: i32,
|
|
#[serde(default)]
|
|
pub active_figure: Option<String>,
|
|
#[serde(default)]
|
|
pub connected: Connection,
|
|
}
|
|
|
|
/// The push-only websocket's frames. `library` carries no payload - it is a bare "your
|
|
/// index is stale, fetch it again" after a rescan.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "lowercase")]
|
|
pub enum ServerMessage {
|
|
State {
|
|
state: PlayerState,
|
|
},
|
|
Position {
|
|
position: f64,
|
|
#[serde(default)]
|
|
duration: f64,
|
|
},
|
|
Library,
|
|
/// A frame this build does not know. Ignored rather than fatal, so the backend can
|
|
/// grow a message type without this client falling over on it.
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
// ------------------------------------------------------------------------- client --
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Client {
|
|
base: String,
|
|
agent: ureq::Agent,
|
|
}
|
|
|
|
/// Enough of an error type to put a cause on screen. Nothing here is recoverable in a
|
|
/// way the caller could branch on - the UI either has a library or says it cannot reach
|
|
/// the mouse - so the variants are not worth splitting.
|
|
pub type Error = String;
|
|
|
|
impl Client {
|
|
pub fn new(base_url: &str) -> Self {
|
|
Self {
|
|
base: base_url.trim_end_matches('/').to_string(),
|
|
agent: ureq::AgentBuilder::new()
|
|
.timeout_connect(std::time::Duration::from_secs(4))
|
|
.timeout_read(std::time::Duration::from_secs(30))
|
|
.build(),
|
|
}
|
|
}
|
|
|
|
/// `http://host:port` -> `ws://host:port/api/ws`. The device serves plain HTTP, but
|
|
/// deriving the scheme rather than hardcoding it keeps a TLS proxy in front working.
|
|
pub fn websocket_url(&self) -> String {
|
|
let ws = if let Some(rest) = self.base.strip_prefix("https://") {
|
|
format!("wss://{rest}")
|
|
} else if let Some(rest) = self.base.strip_prefix("http://") {
|
|
format!("ws://{rest}")
|
|
} else {
|
|
format!("ws://{}", self.base)
|
|
};
|
|
format!("{ws}/api/ws")
|
|
}
|
|
|
|
pub fn cover_url(&self, album_id: &str) -> String {
|
|
format!("{}/api/albums/{album_id}/cover", self.base)
|
|
}
|
|
|
|
/// A GET that decodes JSON, with the status code kept in the error text so a
|
|
/// caller that cares about 404 - several do, because "not configured" is a normal
|
|
/// answer here - can tell it from a connection failure.
|
|
pub(crate) fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
|
|
self.agent
|
|
.get(&format!("{}/api{path}", self.base))
|
|
.call()
|
|
.map_err(|e| format!("GET {path}: {e}"))?
|
|
.into_json()
|
|
.map_err(|e| format!("GET {path}: bad JSON: {e}"))
|
|
}
|
|
|
|
/// A POST whose response body, if any, is not worth reading. Home Assistant's
|
|
/// relayed replies are the only ones with a body, and nothing here reads it.
|
|
pub(crate) fn post_json(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
|
|
self.agent
|
|
.post(&format!("{}/api{path}", self.base))
|
|
.send_json(body)
|
|
.map(|_| ())
|
|
.map_err(|e| format!("POST {path}: {e}"))
|
|
}
|
|
|
|
pub fn library(&self) -> Result<Vec<Album>, Error> {
|
|
let out: LibraryOut = self
|
|
.agent
|
|
.get(&format!("{}/api/library", self.base))
|
|
.call()
|
|
.map_err(|e| format!("GET /api/library: {e}"))?
|
|
.into_json()
|
|
.map_err(|e| format!("GET /api/library: bad JSON: {e}"))?;
|
|
Ok(out.albums)
|
|
}
|
|
|
|
pub fn state(&self) -> Result<PlayerState, Error> {
|
|
self.agent
|
|
.get(&format!("{}/api/state", self.base))
|
|
.call()
|
|
.map_err(|e| format!("GET /api/state: {e}"))?
|
|
.into_json()
|
|
.map_err(|e| format!("GET /api/state: bad JSON: {e}"))
|
|
}
|
|
|
|
/// Raw cover bytes, or `Ok(None)` for a 404 - which is a normal answer, not a
|
|
/// failure: `has_cover` says so in advance and the client paints the album's own
|
|
/// colours instead.
|
|
pub fn cover(&self, album_id: &str) -> Result<Option<Vec<u8>>, Error> {
|
|
let response = match self.agent.get(&self.cover_url(album_id)).call() {
|
|
Ok(response) => response,
|
|
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
|
Err(e) => return Err(format!("GET cover {album_id}: {e}")),
|
|
};
|
|
let mut bytes = Vec::new();
|
|
std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)
|
|
.map_err(|e| format!("GET cover {album_id}: {e}"))?;
|
|
Ok(Some(bytes))
|
|
}
|
|
|
|
// -------------------------------------------------------------------- commands --
|
|
//
|
|
// Every one of these emits the same *intent* the physical buttons emit, so this UI
|
|
// has no privileged path and no way to get out of step with a figure someone puts
|
|
// on the reader. They all answer 204 with no body - nothing to parse.
|
|
|
|
fn command(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
|
|
let request = self.agent.post(&format!("{}/api{path}", self.base));
|
|
let result = if body.is_null() {
|
|
request.call()
|
|
} else {
|
|
request.send_json(body)
|
|
};
|
|
result.map(|_| ()).map_err(|e| format!("POST {path}: {e}"))
|
|
}
|
|
|
|
pub fn play(&self, album_id: &str, track_index: i32) -> Result<(), Error> {
|
|
self.command(
|
|
"/play",
|
|
serde_json::json!({ "album_id": album_id, "track_index": track_index.max(0) }),
|
|
)
|
|
}
|
|
|
|
pub fn resume(&self) -> Result<(), Error> {
|
|
self.command("/resume", serde_json::Value::Null)
|
|
}
|
|
|
|
pub fn pause(&self) -> Result<(), Error> {
|
|
self.command("/pause", serde_json::Value::Null)
|
|
}
|
|
|
|
pub fn next(&self) -> Result<(), Error> {
|
|
self.command("/next", serde_json::Value::Null)
|
|
}
|
|
|
|
pub fn previous(&self) -> Result<(), Error> {
|
|
self.command("/previous", serde_json::Value::Null)
|
|
}
|
|
|
|
pub fn seek(&self, position: f64) -> Result<(), Error> {
|
|
self.command(
|
|
"/seek",
|
|
serde_json::json!({ "position": position.max(0.0) }),
|
|
)
|
|
}
|
|
|
|
pub fn volume(&self, percent: i32) -> Result<(), Error> {
|
|
self.command(
|
|
"/volume",
|
|
serde_json::json!({ "percent": percent.clamp(0, 100) }),
|
|
)
|
|
}
|
|
}
|