This commit is contained in:
2026-09-12 21:50:09 +01:00
parent b20e03def7
commit b81eb967ee
17 changed files with 468 additions and 14 deletions
+3 -1
View File
@@ -12,4 +12,6 @@ common = { path = "../common" }
iced = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
reqwest = "0.13.5"
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { version = "0.13.5", features = ["json"] }
+113
View File
@@ -0,0 +1,113 @@
# services
Background services. A service owns a route key, runs as an iced
`Subscription`, receives `common::Wire`s on its `Inbox`, and publishes
payloads back to every module subscribed to its key. Core maps a `Service`
route key to a subscription through `IntoSubscription`.
Message types are shared, so they live in the `common` crate, under
`common/src/messages/services/`. That is what lets a service and the modules
that consume it name the same payload without either depending on the other.
## Getting started: add a service
1. Declare the service's route key and payload in
`crates/common/src/messages/services/greeter.rs`:
```rust
//! Greeter service messages.
use crate::Service;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum GreeterKind {
Hello,
}
impl GreeterKind {
/// Route key a module subscribes under to receive this kind's ticks.
pub fn key(self) -> Service {
Service(match self {
Self::Hello => "greeter.hello",
})
}
}
#[derive(Clone)]
pub struct GreeterPayload {
pub value: String,
}
```
2. Re-export them from `crates/common/src/messages/services/mod.rs`:
```rust
mod datetime;
mod greeter;
mod weather;
pub use datetime::{ClockKind, ClockPayload};
pub use greeter::{GreeterKind, GreeterPayload};
pub use weather::*;
```
3. Create `crates/services/src/greeter.rs`:
```rust
use std::time::Duration;
use common::{Endpoint, Inbox, Wire, GreeterPayload};
use iced::{futures::channel::mpsc, Subscription};
/// Route-key namespace owned by this service; keys look like `"greeter.hello"`.
pub const NAMESPACE: &str = "greeter.";
pub struct GreeterService;
impl GreeterService {
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let key = inbox.key();
let mut _rx = inbox.take().expect("inbox receiver is taken once");
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
let mut interval = tokio::time::interval(Duration::from_secs(5));
loop {
interval.tick().await;
// Fan-out: publish to every module subscribed to `key`.
let wire = Wire::topic(Endpoint::service(key), key, GreeterPayload {
value: "hello".into(),
});
if sender.send(wire).await.is_err() {
return;
}
}
})
})
}
}
```
4. Add one arm to the `IntoSubscription` match in `crates/services/src/lib.rs`:
```rust
pub mod greeter;
impl IntoSubscription for Service {
fn into_subscription(self, inbox: Inbox) -> Subscription<Wire> {
match self.0 {
key if key.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(inbox),
key if key.starts_with(weather::NAMESPACE) => weather::WeatherService::run(inbox),
key if key.starts_with(greeter::NAMESPACE) => greeter::GreeterService::run(inbox),
_ => Subscription::none(),
}
}
}
```
Then a module subscribes by returning `GreeterKind::Hello.key()` from its
`services()` and downcasts `GreeterPayload` in `update()`.
`run_with(inbox, ..)` keys the subscription by the inbox route key, so the
same subscription returned on every rebuild is not restarted. The receiver is
taken once inside the closure — don't call `take()` anywhere else.
+1 -1
View File
@@ -22,7 +22,7 @@ impl IntoSubscription for Service {
fn into_subscription(self, inbox: Inbox) -> Subscription<Wire> {
match self.0 {
key if key.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(inbox),
key if key.starts_with(weather::NAMESPACE) => datetime::ClockTicker::run(inbox),
key if key.starts_with(weather::NAMESPACE) => weather::WeatherService::run(inbox),
_ => Subscription::none(),
}
}
+52 -1
View File
@@ -1,2 +1,53 @@
pub struct WeatherService {}
use std::time::Duration;
use common::{Inbox, WeatherResponse, Wire};
use iced::{futures::channel::mpsc, Subscription};
use tokio::time::interval;
pub const NAMESPACE: &str = "weather.";
const FORECAST_URL: &str = "https://api.open-meteo.com/v1/forecast\
?latitude=51.3714&longitude=-0.2302\
&hourly=temperature_2m,rain,showers,precipitation,relative_humidity_2m";
pub struct WeatherService {
client: reqwest::Client,
latest: Option<WeatherResponse>,
}
impl WeatherService {
fn new() -> Self {
Self {
client: reqwest::Client::default(),
latest: None,
}
}
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let _key = inbox.key();
let mut _rx = inbox.take().expect("cant take");
iced::stream::channel(0, move |mut _sender: mpsc::Sender<Wire>| async move {
let mut weather = WeatherService::new();
let mut interval = interval(Duration::from_mins(30));
loop {
tokio::select! {
_ = interval.tick() => {
weather.latest = weather.get_weather().await;
}
}
}
})
})
}
async fn get_weather(&self) -> Option<WeatherResponse> {
self.client
.get(FORECAST_URL)
.send()
.await
.ok()?
.json::<WeatherResponse>()
.await
.ok()
}
}