This commit is contained in:
Mateusz Gruszczyński
2026-09-16 14:24:40 +02:00
parent d0ae3d0f6a
commit 3f9acb1675
28 changed files with 726 additions and 127 deletions
+4
View File
@@ -238,6 +238,10 @@ pub fn router(state: AppState) -> Router {
"/api/integrations/home-assistant/entity",
post(inspect_home_assistant_entity),
)
.route(
"/api/integrations/home-assistant/entities",
get(list_home_assistant_entities),
)
.route(
"/api/integrations/home-assistant/energy-sensors",
get(list_home_assistant_energy_sensors),
+1 -1
View File
@@ -27,7 +27,7 @@ async fn export_configuration(
fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 3 {
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.12".into()));
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.13".into()));
}
if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest(
+54 -3
View File
@@ -2,6 +2,7 @@ fn map_home_assistant_integration_error(error: anyhow::Error) -> AppError {
let message = error.to_string();
if message.contains("Home Assistant URL is not configured")
|| message.contains("Home Assistant token is not configured")
|| message.contains("Home Assistant Supervisor token is not available")
|| message.contains("invalid Home Assistant URL")
|| message.contains("Home Assistant URL must use http or https")
|| message.contains("cannot build Home Assistant API URL")
@@ -45,10 +46,58 @@ async fn test_home_assistant(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone();
home_assistant::test_connection(&state.http, &settings.home_assistant)
let sample = home_assistant::test_connection(&state.http, &settings.home_assistant)
.await
.map_err(map_home_assistant_integration_error)?;
Ok(Json(json!({"ok": true})))
Ok(Json(json!({
"ok": true,
"auth_mode": home_assistant::auth_mode(&settings.home_assistant),
"sample": sample,
})))
}
async fn list_home_assistant_entities(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.home_assistant.clone();
if !home_assistant::token_configured(&settings)
|| home_assistant::effective_url(&settings).trim().is_empty()
{
return Ok(Json(json!({
"configured": false,
"entities": [],
})));
}
let mut entities = home_assistant::list_entities(&state.http, &settings)
.await
.map_err(map_home_assistant_integration_error)?
.into_iter()
.filter_map(|entity| {
let entity_id = entity.get("entity_id")?.as_str()?.trim();
if entity_id.is_empty() {
return None;
}
let attributes = entity.get("attributes").and_then(Value::as_object);
Some(json!({
"entity_id": entity_id,
"name": attributes.and_then(|value| value.get("friendly_name")).and_then(Value::as_str).unwrap_or_default(),
"state": entity.get("state").and_then(Value::as_str).unwrap_or_default(),
"unit": attributes.and_then(|value| value.get("unit_of_measurement")).and_then(Value::as_str).unwrap_or_default(),
"device_class": attributes.and_then(|value| value.get("device_class")).and_then(Value::as_str).unwrap_or_default(),
}))
})
.collect::<Vec<_>>();
entities.sort_by(|a, b| {
a.get("entity_id")
.and_then(Value::as_str)
.unwrap_or_default()
.cmp(b.get("entity_id").and_then(Value::as_str).unwrap_or_default())
});
Ok(Json(json!({
"configured": true,
"entities": entities,
})))
}
#[derive(Debug, Deserialize)]
@@ -116,7 +165,9 @@ async fn list_home_assistant_energy_sensors(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.home_assistant.clone();
if settings.url.trim().is_empty() || settings.token.trim().is_empty() {
if !home_assistant::token_configured(&settings)
|| home_assistant::effective_url(&settings).trim().is_empty()
{
return Ok(Json(json!({
"configured": false,
"sensors": [],
+6 -1
View File
@@ -50,9 +50,14 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
},
"home_assistant": {
"url": home_assistant::effective_url(&settings.home_assistant),
"auth_mode": home_assistant::auth_mode(),
"manual_url": settings.home_assistant.url,
"auth_mode": home_assistant::auth_mode(&settings.home_assistant),
"token": "",
"token_configured": home_assistant::token_configured(&settings.home_assistant),
"manual_token_configured": !settings.home_assistant.token.trim().is_empty(),
"supervisor_detected": home_assistant::supervisor_detected(),
"supervisor_token_detected": home_assistant::supervisor_token_detected(),
"manual_auth_override": settings.home_assistant.manual_auth_override,
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
"sensor_stale_after_seconds": settings.home_assistant.sensor_stale_after_seconds,
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
+12 -5
View File
@@ -79,8 +79,13 @@ fn notification_settings(settings: &RuntimeSettings) -> NotificationSettingsView
fn home_assistant_settings(settings: &RuntimeSettings) -> HomeAssistantSettingsView {
HomeAssistantSettingsView {
url: home_assistant::effective_url(&settings.home_assistant),
auth_mode: home_assistant::auth_mode().to_string(),
manual_url: settings.home_assistant.url.clone(),
auth_mode: home_assistant::auth_mode(&settings.home_assistant).to_string(),
token_configured: home_assistant::token_configured(&settings.home_assistant),
manual_token_configured: !settings.home_assistant.token.trim().is_empty(),
supervisor_detected: home_assistant::supervisor_detected(),
supervisor_token_detected: home_assistant::supervisor_token_detected(),
manual_auth_override: settings.home_assistant.manual_auth_override,
outdoor_entity_id: settings.home_assistant.outdoor_entity_id.clone(),
sensor_stale_after_seconds: settings.home_assistant.sensor_stale_after_seconds,
allow_invalid_tls: settings.home_assistant.allow_invalid_tls,
@@ -694,9 +699,10 @@ fn apply_home_assistant_update(
current: &HomeAssistantSettings,
input: HomeAssistantSettingsUpdate,
) -> HomeAssistantSettings {
let supervisor_managed = home_assistant::uses_supervisor_auth();
let supervisor_detected = home_assistant::supervisor_detected();
let manual_auth_override = supervisor_detected && input.manual_auth_override;
let mut next = HomeAssistantSettings {
url: if supervisor_managed {
url: if supervisor_detected && !manual_auth_override {
current.url.clone()
} else {
input.url
@@ -705,10 +711,11 @@ fn apply_home_assistant_update(
outdoor_entity_id: input.outdoor_entity_id,
sensor_stale_after_seconds: input.sensor_stale_after_seconds.clamp(30, 86_400),
allow_invalid_tls: input.allow_invalid_tls,
manual_auth_override,
sensor_aliases: input.sensor_aliases,
flow_inputs: input.flow_inputs,
};
if !supervisor_managed {
if !supervisor_detected || manual_auth_override {
if let Some(token) = input.token {
next.token = token;
}
@@ -728,7 +735,7 @@ async fn update_home_assistant_settings(
normalize_sensor_aliases(&mut next);
validate_flow_shared_inputs(&mut next, &state)?;
canonicalize_home_assistant_entities(&mut next);
if !home_assistant::uses_supervisor_auth() {
if !home_assistant::uses_supervisor_auth(&next) {
validate_home_assistant_url(&next)?;
}
settings.home_assistant = next.clone();
+1
View File
@@ -131,6 +131,7 @@ impl Config {
allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS")
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false),
manual_auth_override: false,
sensor_aliases: Default::default(),
flow_inputs: Vec::new(),
},
+56 -12
View File
@@ -9,14 +9,25 @@ const SUPERVISOR_AUTH_MODE: &str = "supervisor";
const SUPERVISOR_TOKEN_ENV: &str = "SUPERVISOR_TOKEN";
const SUPERVISOR_BASE_URL: &str = "http://supervisor/core/";
pub fn uses_supervisor_auth() -> bool {
pub fn supervisor_detected() -> bool {
env::var(SUPERVISOR_AUTH_ENV)
.map(|value| value.trim().eq_ignore_ascii_case(SUPERVISOR_AUTH_MODE))
.unwrap_or(false)
}
pub fn auth_mode() -> &'static str {
if uses_supervisor_auth() {
pub fn supervisor_token_detected() -> bool {
supervisor_detected()
&& env::var(SUPERVISOR_TOKEN_ENV)
.map(|token| !token.trim().is_empty())
.unwrap_or(false)
}
pub fn uses_supervisor_auth(settings: &HomeAssistantSettings) -> bool {
supervisor_detected() && !settings.manual_auth_override
}
pub fn auth_mode(settings: &HomeAssistantSettings) -> &'static str {
if uses_supervisor_auth(settings) {
SUPERVISOR_AUTH_MODE
} else {
"manual"
@@ -24,7 +35,7 @@ pub fn auth_mode() -> &'static str {
}
pub fn effective_url(settings: &HomeAssistantSettings) -> String {
if uses_supervisor_auth() {
if uses_supervisor_auth(settings) {
SUPERVISOR_BASE_URL.to_string()
} else {
settings.url.clone()
@@ -32,17 +43,15 @@ pub fn effective_url(settings: &HomeAssistantSettings) -> String {
}
pub fn token_configured(settings: &HomeAssistantSettings) -> bool {
if uses_supervisor_auth() {
env::var(SUPERVISOR_TOKEN_ENV)
.map(|token| !token.trim().is_empty())
.unwrap_or(false)
if uses_supervisor_auth(settings) {
supervisor_token_detected()
} else {
!settings.token.trim().is_empty()
}
}
fn connection(settings: &HomeAssistantSettings) -> Result<(Url, String)> {
let (raw_url, token) = if uses_supervisor_auth() {
let (raw_url, token) = if uses_supervisor_auth(settings) {
let token = env::var(SUPERVISOR_TOKEN_ENV)
.context("Home Assistant Supervisor token is not available")?;
(SUPERVISOR_BASE_URL.to_string(), token)
@@ -54,7 +63,7 @@ fn connection(settings: &HomeAssistantSettings) -> Result<(Url, String)> {
bail!("Home Assistant URL is not configured")
}
if token.trim().is_empty() {
if uses_supervisor_auth() {
if uses_supervisor_auth(settings) {
bail!("Home Assistant Supervisor token is not available")
}
bail!("Home Assistant token is not configured")
@@ -110,7 +119,7 @@ pub fn resolve_entity_id(
pub async fn test_connection(
default_client: &reqwest::Client,
settings: &HomeAssistantSettings,
) -> Result<()> {
) -> Result<Option<Value>> {
let (mut base, token) = connection(settings)?;
base = base.join("api/").context("cannot build Home Assistant API URL")?;
let response = request_client(default_client, settings)?
@@ -128,7 +137,41 @@ pub async fn test_connection(
body.chars().take(200).collect::<String>()
)
}
Ok(())
let entities = list_entities(default_client, settings).await?;
let sample = entities
.iter()
.find(|entity| {
entity
.get("entity_id")
.and_then(Value::as_str)
.map(|id| id.starts_with("sensor."))
.unwrap_or(false)
&& entity
.get("state")
.and_then(Value::as_str)
.map(|state| !matches!(state, "" | "unknown" | "unavailable"))
.unwrap_or(false)
})
.or_else(|| {
entities.iter().find(|entity| {
entity
.get("state")
.and_then(Value::as_str)
.map(|state| !matches!(state, "" | "unknown" | "unavailable"))
.unwrap_or(false)
})
})
.map(|entity| {
let attributes = entity.get("attributes").and_then(Value::as_object);
serde_json::json!({
"entity_id": entity.get("entity_id").and_then(Value::as_str).unwrap_or_default(),
"name": attributes.and_then(|value| value.get("friendly_name")).and_then(Value::as_str).unwrap_or_default(),
"state": entity.get("state").and_then(Value::as_str).unwrap_or_default(),
"unit": attributes.and_then(|value| value.get("unit_of_measurement")).and_then(Value::as_str).unwrap_or_default(),
})
});
Ok(sample)
}
pub async fn read_temperature(
@@ -213,6 +256,7 @@ mod tests {
outdoor_entity_id: "sensor.zewnatrz_temperature".into(),
sensor_stale_after_seconds: 300,
allow_invalid_tls: false,
manual_auth_override: false,
sensor_aliases,
flow_inputs: Vec::new(),
}
+4
View File
@@ -22,6 +22,10 @@ pub struct HomeAssistantSettings {
/// Accept self-signed/expired certificates for local Home Assistant HTTPS.
#[serde(default)]
pub allow_invalid_tls: bool,
/// In the Home Assistant add-on, use the persisted URL/token instead of Supervisor auth.
/// Ignored by standalone installations.
#[serde(default)]
pub manual_auth_override: bool,
/// Friendly labels used only by the controller UI/charts; entity_id remains the storage key.
#[serde(default)]
pub sensor_aliases: BTreeMap<String, String>,
+7
View File
@@ -131,6 +131,8 @@ pub struct HomeAssistantSettingsUpdate {
pub outdoor_entity_id: String,
pub sensor_stale_after_seconds: u64,
pub allow_invalid_tls: bool,
#[serde(default)]
pub manual_auth_override: bool,
pub sensor_aliases: BTreeMap<String, String>,
pub flow_inputs: Vec<FlowSharedInput>,
pub outdoor_assist_enabled: bool,
@@ -139,8 +141,13 @@ pub struct HomeAssistantSettingsUpdate {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HomeAssistantSettingsView {
pub url: String,
pub manual_url: String,
pub auth_mode: String,
pub token_configured: bool,
pub manual_token_configured: bool,
pub supervisor_detected: bool,
pub supervisor_token_detected: bool,
pub manual_auth_override: bool,
pub outdoor_entity_id: String,
pub sensor_stale_after_seconds: u64,
pub allow_invalid_tls: bool,