Skip to content

Commit 22f4af3

Browse files
authored
Merge pull request #945 from eetu/pv-forecast
Add 66h PV forecast overlay
2 parents acf43a9 + fdddf6b commit 22f4af3

18 files changed

Lines changed: 536 additions & 53 deletions

File tree

‎README.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ HCC_HISTORY_RETENTION_DAYS=90 # days of sensor history to keep (default: 0/disab
2222
# Maps room types to lists of room names as configured in the Hue app.
2323
# Rooms not listed default to "inside".
2424
HUE_ROOM_TYPES={"inside": ["Keittiö", "Olohuone"], "inside_cold": ["Kuisti"], "outside": ["Ulkona"]}
25+
2526
```
2627

28+
PV forecast vars (`PV_LAT`, `PV_LON`, `PV_TILT`, `PV_AZIMUTH`, `PV_KW`) are
29+
consumed by `scripts/refresh-pv-forecast.sh` — see the "PV forecast"
30+
section below for details.
31+
2732
### Pairing with the Hue bridge
2833

2934
1. Press the link button on the bridge
@@ -69,6 +74,56 @@ GET /api/history/sensors?sensor_id=<id>&hours=<n>
6974
| `sensor_id` | no | all | Filter to a specific sensor |
7075
| `hours` | no | 24 | Hours of history to return (max 720) |
7176

77+
## PV forecast
78+
79+
The backend exposes `GET/POST /api/pv/forecast`. The actual forecast is
80+
produced by an external one-shot CLI maintained in a separate repository,
81+
[fmi-pv-forecast-runner](https://github.com/eetu/fmi-pv-forecast-runner),
82+
which wraps the
83+
[FMI open PV forecast](https://github.com/fmidev/fmi-open-pv-forecast-packaged)
84+
package and emits the next ~66 hours of hourly output as JSON on stdout.
85+
86+
Refreshing the forecast is a two-step shell pipeline: run the CLI, pipe
87+
its stdout to `POST /api/pv/forecast`. `scripts/refresh-pv-forecast.sh`
88+
wraps both:
89+
90+
```bash
91+
# Auto-detect: uses local uv repo if ../fmi-pv-forecast-runner/ is checked out,
92+
# otherwise pulls the published Docker image.
93+
HCC_PV_ENV_FILE=.env.pv ./scripts/refresh-pv-forecast.sh
94+
95+
# Force a specific mode:
96+
./scripts/refresh-pv-forecast.sh uv
97+
./scripts/refresh-pv-forecast.sh docker
98+
```
99+
100+
Required env vars (place in `$HCC_PV_ENV_FILE` or export beforehand):
101+
`PV_LAT`, `PV_LON`, `PV_TILT`, `PV_AZIMUTH`, `PV_KW`. Optional overrides:
102+
`HCC_BACKEND_URL` (default `http://localhost:3000`), `PV_RUNNER_PATH`,
103+
`PV_RUNNER_IMAGE`.
104+
105+
### Cold-start and periodic refresh
106+
107+
Run the script once manually to populate the database after the first
108+
deploy. For a 3-hour refresh cadence (matching the upstream forecast
109+
update interval), add a host cron entry:
110+
111+
```cron
112+
17 */3 * * * cd /opt/hcc && HCC_PV_ENV_FILE=/opt/hcc/.env.pv ./scripts/refresh-pv-forecast.sh >>/var/log/hcc-pv.log 2>&1
113+
```
114+
115+
### Storage and rendering
116+
117+
Hourly forecast points are upserted into the `pv_forecast_points` table
118+
(steady-state ≤66 rows) and rendered as bars on the daily weather chart,
119+
aggregated to kWh per day.
120+
121+
### Geographic limits
122+
123+
The FMI forecast covers Finland, Scandinavia, and the Baltic countries. See
124+
[ilmatieteenlaitos.fi/numerical-weather-prediction](https://en.ilmatieteenlaitos.fi/numerical-weather-prediction)
125+
for the full available area.
126+
72127
## Media
73128

74129
![screenshot](/documentation/screenshot.png)

‎backend/src/hue/client.rs‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ pub enum HueError {
1313
Http(#[from] reqwest::Error),
1414
#[error("Bridge error {status}: {body}")]
1515
Bridge { status: u16, body: String },
16+
#[error("Failed to decode response from {path}: {source} (body: {body})")]
17+
Decode {
18+
path: String,
19+
body: String,
20+
#[source]
21+
source: serde_json::Error,
22+
},
1623
}
1724

1825
pub async fn hue_fetch<T: serde::de::DeserializeOwned>(
@@ -40,10 +47,32 @@ pub async fn hue_fetch<T: serde::de::DeserializeOwned>(
4047
if !res.status().is_success() {
4148
let status = res.status().as_u16();
4249
let body = res.text().await.unwrap_or_default();
50+
tracing::error!(path, status, body = %truncate(&body, 500), "Hue bridge returned non-success");
4351
return Err(HueError::Bridge { status, body });
4452
}
4553

46-
Ok(res.json().await?)
54+
let body = res.text().await?;
55+
serde_json::from_str::<HueList<T>>(&body).map_err(|e| {
56+
tracing::error!(
57+
path,
58+
error = %e,
59+
body = %truncate(&body, 500),
60+
"Failed to decode Hue response"
61+
);
62+
HueError::Decode {
63+
path: path.to_string(),
64+
body,
65+
source: e,
66+
}
67+
})
68+
}
69+
70+
fn truncate(s: &str, n: usize) -> String {
71+
if s.len() <= n {
72+
s.to_string()
73+
} else {
74+
format!("{}…[+{} bytes]", &s[..n], s.len() - n)
75+
}
4776
}
4877

4978
pub async fn hue_put(

‎backend/src/hue/data.rs‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ async fn fetch_from_bridge(state: &Arc<AppState>) -> Result<HueResponse, HueErro
3636
let battery_by_device: HashMap<&str, u8> = device_powers
3737
.data
3838
.iter()
39-
.filter_map(|dp| dp.power_state.battery_level.map(|b| (dp.owner.rid.as_str(), b)))
39+
.filter_map(|dp| {
40+
dp.power_state
41+
.battery_level
42+
.map(|b| (dp.owner.rid.as_str(), b))
43+
})
4044
.collect();
4145

4246
// device ID → room
@@ -65,7 +69,7 @@ async fn fetch_from_bridge(state: &Arc<AppState>) -> Result<HueResponse, HueErro
6569
.map(|m| {
6670
let (motion, updated_at) = match &m.motion.motion_report {
6771
Some(report) => (report.motion, Some(report.changed.as_str())),
68-
None => (m.motion.motion, None),
72+
None => (m.motion.motion.unwrap_or(false), None),
6973
};
7074
(m.owner.rid.as_str(), (motion, updated_at))
7175
})
@@ -108,19 +112,17 @@ async fn fetch_from_bridge(state: &Arc<AppState>) -> Result<HueResponse, HueErro
108112
.temperature_report
109113
.as_ref()
110114
.map(|r| r.temperature)
111-
.unwrap_or(temp.temperature.temperature);
115+
.or(temp.temperature.temperature);
112116

113-
let battery = battery_by_device
114-
.get(temp.owner.rid.as_str())
115-
.copied();
117+
let battery = battery_by_device.get(temp.owner.rid.as_str()).copied();
116118

117119
let motion_data = motion_by_device.get(temp.owner.rid.as_str());
118120

119121
Sensor {
120122
id: temp.id.clone(),
121123
device_id: temp.owner.rid.clone(),
122124
name,
123-
temperature: Some(temperature),
125+
temperature,
124126
room_type,
125127
enabled: temp.enabled,
126128
battery,

‎backend/src/hue/handlers.rs‎

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,17 @@ pub async fn get_data(state: web::Data<Arc<AppState>>) -> HttpResponse {
4343
(status = 200, description = "Server-sent events stream of Hue device changes")
4444
)
4545
)]
46-
pub async fn events_sse(state: web::Data<Arc<AppState>>) -> sse::Sse<impl futures_util::Stream<Item = Result<sse::Event, std::convert::Infallible>>> {
46+
pub async fn events_sse(
47+
state: web::Data<Arc<AppState>>,
48+
) -> sse::Sse<impl futures_util::Stream<Item = Result<sse::Event, std::convert::Infallible>>> {
4749
let rx = subscribe(&state.hue_events_tx);
4850
let stream = BroadcastStream::new(rx).filter_map(|result| async move {
4951
match result {
5052
Ok(event) => {
5153
let json = serde_json::to_string(&event).ok()?;
52-
Some(Ok::<_, std::convert::Infallible>(sse::Event::Data(sse::Data::new(json))))
54+
Some(Ok::<_, std::convert::Infallible>(sse::Event::Data(
55+
sse::Data::new(json),
56+
)))
5357
}
5458
Err(_) => None,
5559
}
@@ -86,10 +90,7 @@ pub struct PairResponse {
8690
(status = 400, description = "Pairing failed")
8791
)
8892
)]
89-
pub async fn pair(
90-
state: web::Data<Arc<AppState>>,
91-
body: web::Json<PairRequest>,
92-
) -> HttpResponse {
93+
pub async fn pair(state: web::Data<Arc<AppState>>, body: web::Json<PairRequest>) -> HttpResponse {
9394
let bridge_ip = body
9495
.bridge_ip
9596
.clone()
@@ -114,16 +115,14 @@ pub async fn pair(
114115
let res = match state.hue_client.post(&url).json(&payload).send().await {
115116
Ok(r) => r,
116117
Err(e) => {
117-
return HttpResponse::BadRequest()
118-
.json(serde_json::json!({"error": e.to_string()}));
118+
return HttpResponse::BadRequest().json(serde_json::json!({"error": e.to_string()}));
119119
}
120120
};
121121

122122
let entries: Vec<serde_json::Value> = match res.json().await {
123123
Ok(v) => v,
124124
Err(e) => {
125-
return HttpResponse::BadRequest()
126-
.json(serde_json::json!({"error": e.to_string()}));
125+
return HttpResponse::BadRequest().json(serde_json::json!({"error": e.to_string()}));
127126
}
128127
};
129128

@@ -133,8 +132,7 @@ pub async fn pair(
133132
.get("description")
134133
.and_then(|d| d.as_str())
135134
.unwrap_or("Pairing failed");
136-
return HttpResponse::BadRequest()
137-
.json(serde_json::json!({"error": desc}));
135+
return HttpResponse::BadRequest().json(serde_json::json!({"error": desc}));
138136
}
139137

140138
if let Some(success) = entry.get("success") {
@@ -184,8 +182,7 @@ pub async fn toggle_group(
184182
Ok(res) => res,
185183
Err(e) => {
186184
tracing::error!("Failed to get group {group_id}: {e}");
187-
return HttpResponse::BadGateway()
188-
.json(serde_json::json!({"error": e.to_string()}));
185+
return HttpResponse::BadGateway().json(serde_json::json!({"error": e.to_string()}));
189186
}
190187
};
191188

@@ -202,8 +199,7 @@ pub async fn toggle_group(
202199
Ok(()) => HttpResponse::Ok().finish(),
203200
Err(e) => {
204201
tracing::error!("Failed to toggle group {group_id}: {e}");
205-
HttpResponse::BadGateway()
206-
.json(serde_json::json!({"error": e.to_string()}))
202+
HttpResponse::BadGateway().json(serde_json::json!({"error": e.to_string()}))
207203
}
208204
}
209205
}

‎backend/src/hue/models.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ pub struct TemperatureResource {
8484

8585
#[derive(Debug, Deserialize)]
8686
pub struct TemperatureData {
87-
pub temperature: f64,
87+
/// Absent on disabled temperature sensors — only `temperature_valid` is emitted then.
88+
pub temperature: Option<f64>,
8889
pub temperature_report: Option<TemperatureReport>,
8990
}
9091

@@ -141,7 +142,8 @@ pub struct MotionResource {
141142

142143
#[derive(Debug, Deserialize)]
143144
pub struct MotionData {
144-
pub motion: bool,
145+
/// Absent on disabled motion sensors — only `motion_valid` is emitted then.
146+
pub motion: Option<bool>,
145147
pub motion_report: Option<MotionReport>,
146148
}
147149

‎backend/src/lib.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod cache;
22
pub mod hue;
3+
pub mod pv;
34
pub mod settings;
45
pub mod solis;
56
pub mod storage;
@@ -25,6 +26,7 @@ pub struct AppState {
2526
pub tomorrow_cache: Cache<serde_json::Value>,
2627
pub weather_cache: Cache<weather::models::WeatherResponse>,
2728
pub solis_cache: Cache<solis::models::SolisWidgetData>,
29+
pub pv_cache: Cache<pv::models::PvForecast>,
2830
pub hue_events_tx: broadcast::Sender<hue::events::HueLiveEvent>,
2931
pub storage: storage::Storage,
3032
}
@@ -39,6 +41,8 @@ pub struct AppState {
3941
hue::handlers::pair,
4042
hue::handlers::toggle_group,
4143
solis::handlers::get_data,
44+
pv::handlers::get_forecast,
45+
pv::handlers::post_forecast,
4246
status,
4347
sensor_history,
4448
),
@@ -52,6 +56,8 @@ pub struct AppState {
5256
hue::handlers::PairRequest,
5357
hue::handlers::PairResponse,
5458
solis::models::SolisWidgetData,
59+
pv::models::PvForecast,
60+
pv::models::PvPoint,
5561
StatusResponse,
5662
storage::SensorReading,
5763
))
@@ -187,6 +193,13 @@ pub fn create_app(
187193
.service(
188194
web::scope("/solis")
189195
.route("", web::get().to(solis::handlers::get_data)),
196+
)
197+
.service(
198+
web::scope("/pv").service(
199+
web::resource("/forecast")
200+
.route(web::get().to(pv::handlers::get_forecast))
201+
.route(web::post().to(pv::handlers::post_forecast)),
202+
),
190203
),
191204
)
192205
.service(SwaggerUi::new("/docs/{_:.*}").url("/api-doc/openapi.json", openapi))
@@ -230,6 +243,7 @@ pub fn create_test_app_state_with(settings: Settings) -> Arc<AppState> {
230243
tomorrow_cache: Cache::new(std::time::Duration::from_secs(3600)),
231244
weather_cache: Cache::new(std::time::Duration::from_secs(3600)),
232245
solis_cache: Cache::new(std::time::Duration::from_secs(300)),
246+
pv_cache: Cache::new(std::time::Duration::from_secs(60)),
233247
hue_events_tx,
234248
storage,
235249
})
@@ -268,6 +282,7 @@ pub async fn run_server() -> std::io::Result<()> {
268282
tomorrow_cache: Cache::new(std::time::Duration::from_secs(3600)),
269283
weather_cache: Cache::new(std::time::Duration::from_secs(3600)),
270284
solis_cache: Cache::new(std::time::Duration::from_secs(300)),
285+
pv_cache: Cache::new(std::time::Duration::from_secs(60)),
271286
hue_events_tx: hue_events_tx.clone(),
272287
storage,
273288
});

‎backend/src/pv/handlers.rs‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
use std::sync::Arc;
2+
3+
use actix_web::{web, HttpResponse};
4+
5+
use super::models::PvForecast;
6+
use crate::AppState;
7+
8+
const CACHE_KEY: &str = "pv";
9+
10+
#[utoipa::path(
11+
get,
12+
path = "/api/pv/forecast",
13+
responses(
14+
(status = 200, description = "Latest PV forecast", body = super::models::PvForecast),
15+
(status = 503, description = "No forecast available yet"),
16+
)
17+
)]
18+
pub async fn get_forecast(state: web::Data<Arc<AppState>>) -> HttpResponse {
19+
if let Some(cached) = state.pv_cache.get(CACHE_KEY).await {
20+
return HttpResponse::Ok().json(cached);
21+
}
22+
23+
match state.storage.read_pv_forecast().await {
24+
Ok(Some(forecast)) => {
25+
state.pv_cache.set(CACHE_KEY.into(), forecast.clone()).await;
26+
HttpResponse::Ok().json(forecast)
27+
}
28+
Ok(None) => HttpResponse::ServiceUnavailable()
29+
.json(serde_json::json!({"error": "no forecast available yet"})),
30+
Err(e) => {
31+
tracing::error!("Failed to read PV forecast: {e}");
32+
HttpResponse::InternalServerError().finish()
33+
}
34+
}
35+
}
36+
37+
#[utoipa::path(
38+
post,
39+
path = "/api/pv/forecast",
40+
request_body = super::models::PvForecast,
41+
responses(
42+
(status = 200, description = "Forecast upserted"),
43+
)
44+
)]
45+
pub async fn post_forecast(
46+
state: web::Data<Arc<AppState>>,
47+
body: web::Json<PvForecast>,
48+
) -> HttpResponse {
49+
let forecast = body.into_inner();
50+
match state.storage.upsert_pv_forecast(&forecast).await {
51+
Ok(n) => {
52+
state.pv_cache.set(CACHE_KEY.into(), forecast).await;
53+
tracing::info!("Upserted {n} PV forecast points");
54+
HttpResponse::Ok().json(serde_json::json!({"upserted": n}))
55+
}
56+
Err(e) => {
57+
tracing::error!("Failed to upsert PV forecast: {e}");
58+
HttpResponse::InternalServerError().finish()
59+
}
60+
}
61+
}

‎backend/src/pv/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod handlers;
2+
pub mod models;
3+
pub mod storage;

0 commit comments

Comments
 (0)