This commit is contained in:
Mateusz Gruszczyński
2026-09-16 16:56:13 +02:00
parent 143ff0d272
commit 1862fed87f
27 changed files with 1368 additions and 178 deletions
+8 -2
View File
@@ -23,7 +23,7 @@ use axum::{
body::Body,
extract::{
ws::{Message, WebSocket},
Path, Query, Request, State, WebSocketUpgrade,
ConnectInfo, Path, Query, Request, State, WebSocketUpgrade,
},
http::{header, HeaderMap, HeaderValue, StatusCode},
middleware::{self, Next},
@@ -39,7 +39,7 @@ use serde::Deserialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::{
net::IpAddr,
net::{IpAddr, SocketAddr},
sync::atomic::Ordering,
time::{Duration, Instant},
};
@@ -49,6 +49,8 @@ use uuid::Uuid;
mod openapi;
const INDEX_HTML: &str = include_str!("../web/index.html");
const CUSTOM_CHART_HTML: &str = include_str!("../web/custom-chart.html");
const CUSTOM_CHART_JS: &str = include_str!("../web/custom-chart.js");
const NOT_FOUND_HTML: &str = include_str!("../web/404.html");
const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js"));
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js");
@@ -156,6 +158,7 @@ pub fn router(state: AppState) -> Router {
)
.route("/api/readings", get(readings))
.route("/api/history", get(history))
.route("/api/charts/custom/share", post(create_public_custom_chart))
.route("/api/history/energy", get(energy_history))
.route("/api/history/network", get(network_history))
.route("/api/control-plan", get(control_plan))
@@ -300,6 +303,9 @@ pub fn router(state: AppState) -> Router {
let mut app = Router::new()
.route("/api/health", get(health))
.route("/api/public/charts/custom/:token", get(public_custom_chart))
.route("/charts/custom/:token", get(custom_chart_page))
.route("/custom-chart.js", get(custom_chart_js))
.route("/ws", get(websocket))
.route("/", get(index))
.route("/index.html", get(index))
+35
View File
@@ -29,6 +29,34 @@ async fn not_found(State(state): State<AppState>, headers: HeaderMap) -> Respons
response
}
async fn custom_chart_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
let base = request_base_path(&state, &headers);
let body = CUSTOM_CHART_HTML
.replace("__GREE_BASE_PATH__", &base)
.replace(
"__GREE_THEME_INIT_ASSET__",
&format!("{base}{THEME_INIT_ASSET_PATH}"),
)
.replace(
"__GREE_STYLES_ASSET__",
&format!("{base}{STYLES_CSS_ASSET_PATH}"),
)
.replace(
"__GREE_CUSTOM_CHART_ASSET__",
&format!("{base}/custom-chart.js"),
);
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, no-cache"),
);
response
}
async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
let base = request_base_path(&state, &headers);
let body = INDEX_HTML
@@ -88,6 +116,13 @@ fn forwarded_prefix(headers: &HeaderMap) -> Option<String> {
}
None
}
async fn custom_chart_js() -> Response {
static_response(
CUSTOM_CHART_JS,
"application/javascript; charset=utf-8",
"public, max-age=300",
)
}
async fn app_js() -> Response {
static_response(
APP_JS,
+24
View File
@@ -22,12 +22,36 @@ async fn debug_api_requests(
response
}
fn trusted_home_assistant_ingress_parts(headers: &HeaderMap, peer: IpAddr) -> bool {
let ingress_request = headers
.get("x-ingress-path")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.is_some_and(|path| path.starts_with("/api/hassio_ingress/"));
ingress_request && home_assistant::is_supervisor_ingress_peer(peer)
}
fn trusted_home_assistant_ingress(request: &Request) -> bool {
request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|info| trusted_home_assistant_ingress_parts(request.headers(), info.0.ip()))
.unwrap_or(false)
}
async fn auth(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, AppError> {
if trusted_home_assistant_ingress(&request) {
return Ok(next.run(request).await);
}
let expected = state.config.app_token.trim();
if home_assistant::supervisor_token_detected() && expected.is_empty() {
return Err(AppError::Unauthorized);
}
if expected.is_empty() {
return Ok(next.run(request).await);
}
+268
View File
@@ -495,6 +495,274 @@ async fn history(
}
}
#[derive(Debug, Deserialize)]
struct CreatePublicCustomChartRequest {
title: Option<String>,
series: Vec<String>,
hours: Option<i64>,
lang: Option<String>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct PublicCustomChartShare {
title: String,
series: Vec<String>,
hours: i64,
lang: String,
}
#[derive(Debug, serde::Serialize)]
struct PublicChartPoint {
timestamp: chrono::DateTime<Utc>,
value: f64,
}
#[derive(Debug, serde::Serialize)]
struct PublicChartSeries {
key: String,
label: String,
dashed: bool,
points: Vec<PublicChartPoint>,
}
fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
if series.is_empty()
|| series.len() > 16
|| series
.iter()
.any(|item| item.is_empty() || item.len() > 256)
{
return Err(AppError::BadRequest("invalid custom chart definition".into()));
}
for key in series {
let mut parts = key.splitn(3, '|');
let kind = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default();
let field = parts.next().unwrap_or_default();
if id.trim().is_empty() || public_chart_field_label(kind, field, "en").is_none() {
return Err(AppError::BadRequest(format!(
"unsupported custom chart series: {key}"
)));
}
}
Ok(())
}
fn generate_public_chart_token() -> String {
let mut bytes = [0u8; 32];
let mut rng = OsRng;
rng.fill_bytes(&mut bytes);
format!("chart_{}", URL_SAFE_NO_PAD.encode(bytes))
}
async fn create_public_custom_chart(
State(state): State<AppState>,
Json(input): Json<CreatePublicCustomChartRequest>,
) -> Result<Json<Value>, AppError> {
validate_public_chart_spec(&input.series)?;
let hours = input.hours.unwrap_or(24).clamp(1, 24 * 3650);
let lang = if input.lang.as_deref() == Some("pl") {
"pl"
} else {
"en"
};
let default_title = if lang == "pl" {
"Wykres niestandardowy"
} else {
"Custom chart"
};
let title = input
.title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default_title)
.chars()
.take(120)
.collect::<String>();
let share = PublicCustomChartShare {
title,
series: input.series,
hours,
lang: lang.to_string(),
};
let token = generate_public_chart_token();
let token_hash = hash_token(&token);
state
.db
.save_public_chart_share(&token_hash, &serde_json::to_value(&share)?)?;
Ok(Json(json!({
"path": format!("/charts/custom/{token}")
})))
}
fn public_chart_field_label(kind: &str, field: &str, lang: &str) -> Option<&'static str> {
let pl = lang == "pl";
match (kind, field) {
("device", "indoor") => Some(if pl { "Temperatura wewnętrzna" } else { "Indoor temperature" }),
("device", "outdoor") => Some(if pl { "Temperatura zewnętrzna GREE" } else { "GREE outdoor temperature" }),
("device", "target") => Some(if pl { "Temperatura zadana urządzenia" } else { "Device target" }),
("installation", "outdoor") => Some(if pl { "Wspólna temperatura zewnętrzna" } else { "Shared outdoor temperature" }),
("zone", "control") => Some(if pl { "Temperatura sterująca" } else { "Control temperature" }),
("zone", "gree") => Some(if pl { "Czujnik GREE" } else { "GREE sensor" }),
("zone", "external") => Some(if pl { "Czujnik pomieszczenia" } else { "Room sensor" }),
("zone", "target") => Some(if pl { "Temperatura docelowa" } else { "Comfort target" }),
("zone", "device_target") => Some(if pl { "Nastawa urządzenia" } else { "Device setpoint" }),
("zone", "outdoor") => Some(if pl { "Temperatura zewnętrzna" } else { "Outdoor temperature" }),
("ha", "temperature") => Some(if pl { "Temperatura" } else { "Temperature" }),
_ => None,
}
}
fn reading_points(rows: Vec<Reading>, field: &str) -> Vec<PublicChartPoint> {
rows.into_iter()
.filter_map(|row| {
let value = match field {
"indoor" => row.indoor_temperature,
"outdoor" => row.outdoor_temperature,
"target" => Some(row.target_temperature),
_ => None,
}?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value })
})
.collect()
}
fn zone_reading_points(rows: Vec<ZoneReading>, field: &str) -> Vec<PublicChartPoint> {
rows.into_iter()
.filter_map(|row| {
let value = match field {
"control" => row.control_temperature,
"gree" => row.gree_temperature,
"external" => row.external_temperature,
"target" => row.target_temperature,
"device_target" => row.device_setpoint,
"outdoor" => row.outdoor_temperature,
_ => None,
}?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value })
})
.collect()
}
async fn public_custom_chart(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Result<Json<Value>, AppError> {
if token.len() < 32 || token.len() > 128 || !token.starts_with("chart_") {
return Err(AppError::NotFound("custom chart".into()));
}
let payload = state
.db
.get_public_chart_share(&hash_token(&token))?
.ok_or_else(|| AppError::NotFound("custom chart".into()))?;
let share: PublicCustomChartShare = serde_json::from_value(payload)?;
validate_public_chart_spec(&share.series)?;
let hours = share.hours.clamp(1, 24 * 3650);
let lang = if share.lang == "pl" { "pl" } else { "en" };
let since = Utc::now() - ChronoDuration::hours(hours);
let bucket_seconds = history_bucket_seconds(hours);
let limit = 20_000;
let ha_settings = state.settings.read().await.home_assistant.clone();
let outdoor_entity = ha_settings.outdoor_entity_id.clone();
let mut series = Vec::with_capacity(share.series.len());
for key in share.series {
let mut parts = key.splitn(3, '|');
let kind = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default();
let field = parts.next().unwrap_or_default();
let field_label = public_chart_field_label(kind, field, lang)
.ok_or_else(|| AppError::BadRequest(format!("unsupported custom chart series: {key}")))?;
let item = match kind {
"device" => {
let device = state.db.get_device(id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
let (rows, _, _) = combined_device_history(&state, Some(id), since.clone(), bucket_seconds, limit).await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", device.name, field_label),
dashed: field == "target",
points: reading_points(rows, field),
}
}
"installation" => {
let group = state.db.get_device_group(id)?
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
let representative = group.outdoor_temperature_device_id.as_deref()
.filter(|device_id| group.device_ids.iter().any(|member| member.as_str() == *device_id))
.or_else(|| group.device_ids.first().map(String::as_str))
.ok_or_else(|| AppError::BadRequest(format!("device group {id} has no devices")))?;
let (rows, _, _) = combined_device_history(&state, Some(representative), since.clone(), bucket_seconds, limit).await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", group.name, field_label),
dashed: false,
points: reading_points(rows, "outdoor"),
}
}
"zone" => {
let zone = state.db.get_zone(id)?
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let (rows, _, _) = combined_zone_history(&state, Some(id), since.clone(), bucket_seconds, limit).await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", zone.name, field_label),
dashed: matches!(field, "target" | "device_target" | "outdoor"),
points: zone_reading_points(rows, field),
}
}
"ha" => {
let (rows, _, _) = combined_sensor_history(
&state,
Some(id),
since.clone(),
bucket_seconds,
limit,
&outdoor_entity,
).await?;
let alias = ha_settings.sensor_aliases.get(id)
.map(String::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(id);
PublicChartSeries {
key: key.clone(),
label: format!("HA · {} · {}", alias, field_label),
dashed: true,
points: rows.into_iter()
.filter(|row| row.entity_id == id && row.temperature.is_finite())
.map(|row| PublicChartPoint { timestamp: row.timestamp, value: row.temperature })
.collect(),
}
}
_ => return Err(AppError::BadRequest(format!("unsupported custom chart series: {key}"))),
};
series.push(item);
}
let (hint, no_data_label) = if lang == "pl" {
(format!("Ostatnie {hours} h"), "Brak danych")
} else {
(format!("Last {hours} h"), "No data")
};
Ok(Json(json!({
"title": share.title,
"hint": hint,
"no_data_label": no_data_label,
"lang": lang,
"hours": hours,
"bucket_seconds": bucket_seconds,
"series": series,
})))
}
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let snapshot = engine::get_control_plan_snapshot(&state).await?;
Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?))
+5 -1
View File
@@ -24,7 +24,11 @@ async fn security_headers(request: Request, next: Next) -> Response {
);
headers.insert(
header::HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("same-origin"),
HeaderValue::from_static(if path.starts_with("/charts/custom/") {
"no-referrer"
} else {
"same-origin"
}),
);
if is_html {
+21 -4
View File
@@ -107,15 +107,32 @@ struct BootstrapResponse {
system: SystemInfoResponse,
}
async fn health(State(state): State<AppState>) -> Json<Value> {
Json(json!({
async fn health(
State(state): State<AppState>,
request: Request,
) -> Result<Json<Value>, AppError> {
if home_assistant::supervisor_token_detected() {
let trusted_supervisor = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|info| home_assistant::is_supervisor_ingress_peer(info.0.ip()))
.unwrap_or(false);
if !trusted_supervisor {
let expected = state.config.app_token.trim();
if expected.is_empty() || request_token(&request).as_deref() != Some(expected) {
return Err(AppError::Unauthorized);
}
}
}
Ok(Json(json!({
"status": "ok",
"name": "gree-controller",
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": state.started.elapsed().as_secs(),
"control_ready": state.initial_device_sync_complete.load(Ordering::Acquire),
"time": Utc::now(),
}))
})))
}
async fn bootstrap(State(state): State<AppState>) -> Result<Json<BootstrapResponse>, AppError> {
@@ -162,7 +179,7 @@ fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse
SystemInfoResponse {
version: env!("CARGO_PKG_VERSION"),
uptime_seconds: state.started.elapsed().as_secs(),
auth_required: !state.config.app_token.trim().is_empty(),
auth_required: !state.config.app_token.trim().is_empty() || home_assistant::supervisor_token_detected(),
control_ready: state.initial_device_sync_complete.load(Ordering::Acquire),
database: state.config.database.display().to_string(),
device_count: devices.len(),
+10 -3
View File
@@ -5,11 +5,18 @@ struct WsQuery {
async fn websocket(
State(state): State<AppState>,
Query(query): Query<WsQuery>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> Result<Response, AppError> {
let expected = state.config.app_token.trim();
if !expected.is_empty() && query.token.as_deref() != Some(expected) {
return Err(AppError::Unauthorized);
if !trusted_home_assistant_ingress_parts(&headers, peer.ip()) {
let expected = state.config.app_token.trim();
if home_assistant::supervisor_token_detected() && expected.is_empty() {
return Err(AppError::Unauthorized);
}
if !expected.is_empty() && query.token.as_deref() != Some(expected) {
return Err(AppError::Unauthorized);
}
}
Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket)))
}
+24
View File
@@ -109,4 +109,28 @@ impl Db {
let conn = self.lock()?;
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
}
pub fn save_public_chart_share(&self, token_hash: &str, payload: &Value) -> Result<()> {
let conn = self.lock()?;
conn.execute(
queries::INSERT_PUBLIC_CHART_SHARE,
params![
token_hash,
serde_json::to_string(payload)?,
Utc::now().to_rfc3339()
],
)?;
Ok(())
}
pub fn get_public_chart_share(&self, token_hash: &str) -> Result<Option<Value>> {
let conn = self.lock()?;
let payload: Option<String> = conn
.query_row(queries::GET_PUBLIC_CHART_SHARE, [token_hash], |row| row.get(0))
.optional()?;
payload
.map(|value| serde_json::from_str(&value))
.transpose()
.map_err(Into::into)
}
}
+13
View File
@@ -80,6 +80,19 @@ mod tests {
assert!(db.delete_api_token(&access_token.id).unwrap());
assert!(!db.api_token_exists("test-hash").unwrap());
let chart_share = serde_json::json!({
"title": "Room temperatures",
"series": ["device|dev-1|indoor"],
"hours": 24,
"lang": "en"
});
db.save_public_chart_share("chart-hash", &chart_share).unwrap();
assert_eq!(
db.get_public_chart_share("chart-hash").unwrap(),
Some(chart_share)
);
assert!(db.get_public_chart_share("missing-chart").unwrap().is_none());
let now = Utc::now();
db.add_reading(&Reading {
id: 0,
+9 -1
View File
@@ -1,7 +1,11 @@
use crate::models::HomeAssistantSettings;
use anyhow::{anyhow, bail, Context, Result};
use serde_json::Value;
use std::{env, time::Duration};
use std::{
env,
net::{IpAddr, Ipv4Addr},
time::Duration,
};
use url::Url;
const SUPERVISOR_AUTH_ENV: &str = "GREE_CONTROLLER_HA_AUTH";
@@ -22,6 +26,10 @@ pub fn supervisor_token_detected() -> bool {
.unwrap_or(false)
}
pub fn is_supervisor_ingress_peer(peer: IpAddr) -> bool {
supervisor_token_detected() && peer == IpAddr::V4(Ipv4Addr::new(172, 30, 32, 2))
}
pub fn uses_supervisor_auth(settings: &HomeAssistantSettings) -> bool {
supervisor_detected() && !settings.manual_auth_override
}
+5 -2
View File
@@ -144,7 +144,7 @@ async fn main() -> Result<()> {
address = %config.bind,
database = %config.database.display(),
simulator = config.simulate,
auth = !config.app_token.trim().is_empty(),
auth = !config.app_token.trim().is_empty() || home_assistant::supervisor_token_detected(),
gree_interface = %gree_interface_log,
discovery_broadcast = %runtime_settings.discovery_broadcast,
"GREE Controller started"
@@ -158,7 +158,10 @@ async fn main() -> Result<()> {
let _ = shutdown_tx.send(true);
});
let server = axum::serve(listener, app)
let server = axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()))
.into_future();
tokio::pin!(server);
+5
View File
@@ -64,6 +64,11 @@ pub const INSERT_API_TOKEN: &str =
pub const API_TOKEN_EXISTS: &str = "SELECT 1 FROM api_tokens WHERE token_hash=?1 LIMIT 1";
pub const DELETE_API_TOKEN: &str = "DELETE FROM api_tokens WHERE id=?1";
pub const INSERT_PUBLIC_CHART_SHARE: &str =
"INSERT INTO public_chart_shares(token_hash,payload,created_at) VALUES(?1,?2,?3)";
pub const GET_PUBLIC_CHART_SHARE: &str =
"SELECT payload FROM public_chart_shares WHERE token_hash=?1 LIMIT 1";
pub const LOAD_RUNTIME_SETTINGS: &str = "SELECT value FROM settings WHERE key='runtime'";
pub const UPSERT_RUNTIME_SETTINGS: &str = r#"
INSERT INTO settings(key,value,updated_at) VALUES('runtime',?1,?2)
+8
View File
@@ -165,6 +165,12 @@ CREATE TABLE IF NOT EXISTS api_tokens (
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS public_chart_shares (
token_hash TEXT PRIMARY KEY,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
@@ -183,5 +189,7 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (8, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (9, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
"#;