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
+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)))
}