This commit is contained in:
Mateusz Gruszczyński
2026-08-24 14:05:43 +02:00
parent eb02f66056
commit 04fc91b9f4
30 changed files with 2257 additions and 175 deletions
+403
View File
@@ -0,0 +1,403 @@
use anyhow::{anyhow, bail, Context, Result};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;
use crate::models::{HaReading, InfluxDbSettings, Reading, ZoneReading};
const DEVICE_MEASUREMENT: &str = "gree_device";
const ZONE_MEASUREMENT: &str = "gree_zone";
const HA_MEASUREMENT: &str = "gree_ha";
pub fn validate(settings: &InfluxDbSettings) -> Result<()> {
if !settings.enabled { return Ok(()); }
if !matches!(settings.version.as_str(), "1" | "2") { bail!("InfluxDB version must be 1 or 2"); }
let parsed = url::Url::parse(settings.url.trim()).context("invalid InfluxDB URL")?;
if !matches!(parsed.scheme(), "http" | "https") { bail!("InfluxDB URL must use http or https"); }
if settings.version == "1" && settings.database.trim().is_empty() { bail!("InfluxDB 1.x database is required"); }
if settings.version == "2" {
if settings.org.trim().is_empty() { bail!("InfluxDB 2.x organization is required"); }
if settings.bucket.trim().is_empty() { bail!("InfluxDB 2.x bucket is required"); }
if settings.token.trim().is_empty() { bail!("InfluxDB 2.x token is required"); }
}
Ok(())
}
pub async fn write_device(client: &Client, settings: &InfluxDbSettings, reading: &Reading) -> Result<()> {
if !settings.enabled { return Ok(()); }
let mut fields = Vec::new();
push_float(&mut fields, "indoor_temperature", reading.indoor_temperature);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
push_float(&mut fields, "target_temperature", Some(reading.target_temperature));
push_int(&mut fields, "power", reading.power as i64);
let line = line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?;
write_line(client, settings, line).await
}
pub async fn write_zone(client: &Client, settings: &InfluxDbSettings, reading: &ZoneReading) -> Result<()> {
if !settings.enabled { return Ok(()); }
let mut fields = Vec::new();
push_float(&mut fields, "gree_temperature", reading.gree_temperature);
push_float(&mut fields, "external_temperature", reading.external_temperature);
push_float(&mut fields, "control_temperature", reading.control_temperature);
push_float(&mut fields, "target_temperature", reading.target_temperature);
push_float(&mut fields, "device_setpoint", reading.device_setpoint);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
push_int(&mut fields, "power", reading.power as i64);
push_int(&mut fields, "fan_speed", reading.fan_speed as i64);
push_int(&mut fields, "demand", reading.demand as i64);
let line = line_protocol(
ZONE_MEASUREMENT,
&[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)],
fields,
reading.timestamp,
)?;
write_line(client, settings, line).await
}
pub async fn write_ha(client: &Client, settings: &InfluxDbSettings, reading: &HaReading) -> Result<()> {
if !settings.enabled { return Ok(()); }
let zone = reading.zone_id.as_deref().unwrap_or("");
let mut fields = Vec::new();
push_float(&mut fields, "temperature", Some(reading.temperature));
let line = line_protocol(
HA_MEASUREMENT,
&[("entity_id", &reading.entity_id), ("zone_id", zone), ("kind", &reading.kind)],
fields,
reading.timestamp,
)?;
write_line(client, settings, line).await
}
pub async fn write_batch(
client: &Client,
settings: &InfluxDbSettings,
devices: &[Reading],
zones: &[ZoneReading],
ha: &[HaReading],
) -> Result<()> {
if !settings.enabled { return Ok(()); }
let mut lines = Vec::with_capacity(devices.len() + zones.len() + ha.len());
for reading in devices {
let mut fields = Vec::new();
push_float(&mut fields, "indoor_temperature", reading.indoor_temperature);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
push_float(&mut fields, "target_temperature", Some(reading.target_temperature));
push_int(&mut fields, "power", reading.power as i64);
lines.push(line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?);
}
for reading in zones {
let mut fields = Vec::new();
push_float(&mut fields, "gree_temperature", reading.gree_temperature);
push_float(&mut fields, "external_temperature", reading.external_temperature);
push_float(&mut fields, "control_temperature", reading.control_temperature);
push_float(&mut fields, "target_temperature", reading.target_temperature);
push_float(&mut fields, "device_setpoint", reading.device_setpoint);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
push_int(&mut fields, "power", reading.power as i64);
push_int(&mut fields, "fan_speed", reading.fan_speed as i64);
push_int(&mut fields, "demand", reading.demand as i64);
lines.push(line_protocol(ZONE_MEASUREMENT, &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], fields, reading.timestamp)?);
}
for reading in ha {
let mut fields = Vec::new();
push_float(&mut fields, "temperature", Some(reading.temperature));
lines.push(line_protocol(HA_MEASUREMENT, &[("entity_id", &reading.entity_id), ("zone_id", reading.zone_id.as_deref().unwrap_or("")), ("kind", &reading.kind)], fields, reading.timestamp)?);
}
if lines.is_empty() { return Ok(()); }
write_lines(client, settings, lines.join("\n")).await
}
async fn write_line(client: &Client, settings: &InfluxDbSettings, line: String) -> Result<()> {
write_lines(client, settings, line).await
}
async fn write_lines(client: &Client, settings: &InfluxDbSettings, body: String) -> Result<()> {
validate(settings)?;
let base = settings.url.trim_end_matches('/');
let request = if settings.version == "1" {
let request = client.post(format!("{base}/write"))
.query(&[("db", settings.database.as_str()), ("precision", "ns")])
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(body.clone());
if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) }
} else {
client.post(format!("{base}/api/v2/write"))
.query(&[("org", settings.org.as_str()), ("bucket", settings.bucket.as_str()), ("precision", "ns")])
.bearer_auth(settings.token.trim())
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(body)
};
let response = request.send().await.context("InfluxDB write request failed")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("InfluxDB write failed ({status}): {}", truncate(&body, 300));
}
Ok(())
}
pub async fn query_devices(
client: &Client,
settings: &InfluxDbSettings,
device_id: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<Reading>> {
if settings.version == "1" {
query_devices_v1(client, settings, device_id, start, stop, bucket_seconds, limit).await
} else {
let tags = if let Some(value) = device_id { format!(" |> filter(fn: (r) => r.device_id == {})", flux_string(value)) } else { String::new() };
let query = flux_query(settings, DEVICE_MEASUREMENT, &tags, &["device_id"], start, stop, bucket_seconds);
let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; };
let Some(id) = row.get("device_id").filter(|v| !v.is_empty()) else { continue; };
out.push(Reading {
id: 0,
device_id: id.clone(),
timestamp,
indoor_temperature: row_f64(&row, "indoor_temperature"),
outdoor_temperature: row_f64(&row, "outdoor_temperature"),
target_temperature: row_f64(&row, "target_temperature").unwrap_or(0.0),
power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5,
source: "influx".into(),
});
}
out.sort_by_key(|row| row.timestamp);
Ok(out)
}
}
pub async fn query_zones(
client: &Client,
settings: &InfluxDbSettings,
zone_id: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<ZoneReading>> {
if settings.version == "1" {
query_zones_v1(client, settings, zone_id, start, stop, bucket_seconds, limit).await
} else {
let tags = if let Some(value) = zone_id { format!(" |> filter(fn: (r) => r.zone_id == {})", flux_string(value)) } else { String::new() };
let query = flux_query(settings, ZONE_MEASUREMENT, &tags, &["zone_id", "device_id"], start, stop, bucket_seconds);
let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; };
let Some(zone) = row.get("zone_id").filter(|v| !v.is_empty()) else { continue; };
out.push(ZoneReading {
id: 0,
zone_id: zone.clone(),
device_id: row.get("device_id").cloned().unwrap_or_default(),
timestamp,
gree_temperature: row_f64(&row, "gree_temperature"),
external_temperature: row_f64(&row, "external_temperature"),
control_temperature: row_f64(&row, "control_temperature"),
target_temperature: row_f64(&row, "target_temperature"),
device_setpoint: row_f64(&row, "device_setpoint"),
outdoor_temperature: row_f64(&row, "outdoor_temperature"),
power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5,
mode: "history".into(),
fan_speed: row_f64(&row, "fan_speed").unwrap_or(0.0).round().clamp(0.0, 5.0) as u8,
demand: row_f64(&row, "demand").unwrap_or(0.0) >= 0.5,
control_source: "influx".into(),
active_preset: "history".into(),
});
}
out.sort_by_key(|row| row.timestamp);
Ok(out)
}
}
pub async fn query_ha(
client: &Client,
settings: &InfluxDbSettings,
entity_id: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<HaReading>> {
if settings.version == "1" {
query_ha_v1(client, settings, entity_id, start, stop, bucket_seconds, limit).await
} else {
let tags = if let Some(value) = entity_id { format!(" |> filter(fn: (r) => r.entity_id == {})", flux_string(value)) } else { String::new() };
let query = flux_query(settings, HA_MEASUREMENT, &tags, &["entity_id", "zone_id", "kind"], start, stop, bucket_seconds);
let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; };
let Some(entity) = row.get("entity_id").filter(|v| !v.is_empty()) else { continue; };
let Some(temperature) = row_f64(&row, "temperature") else { continue; };
out.push(HaReading {
id: 0,
entity_id: entity.clone(),
zone_id: row.get("zone_id").filter(|v| !v.is_empty()).cloned(),
kind: row.get("kind").cloned().unwrap_or_else(|| "room".into()),
timestamp,
temperature,
});
}
out.sort_by_key(|row| row.timestamp);
Ok(out)
}
}
async fn query_devices_v1(client: &Client, settings: &InfluxDbSettings, device_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<Reading>> {
let filter = device_id.map(|id| format!(" AND \"device_id\"='{}'", influxql_string(id))).unwrap_or_default();
let q = format!("SELECT mean(\"indoor_temperature\") AS \"indoor_temperature\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",mean(\"target_temperature\") AS \"target_temperature\",max(\"power\") AS \"power\" FROM \"{DEVICE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new();
for item in series {
let device = item.tags.get("device_id").cloned().unwrap_or_default();
for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; };
out.push(Reading { id:0, device_id:device.clone(), timestamp, indoor_temperature:row_num(&row,"indoor_temperature"), outdoor_temperature:row_num(&row,"outdoor_temperature"), target_temperature:row_num(&row,"target_temperature").unwrap_or(0.0), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, source:"influx".into() });
}
}
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out)
}
async fn query_zones_v1(client: &Client, settings: &InfluxDbSettings, zone_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<ZoneReading>> {
let filter = zone_id.map(|id| format!(" AND \"zone_id\"='{}'", influxql_string(id))).unwrap_or_default();
let q = format!("SELECT mean(\"gree_temperature\") AS \"gree_temperature\",mean(\"external_temperature\") AS \"external_temperature\",mean(\"control_temperature\") AS \"control_temperature\",mean(\"target_temperature\") AS \"target_temperature\",mean(\"device_setpoint\") AS \"device_setpoint\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",max(\"power\") AS \"power\",mean(\"fan_speed\") AS \"fan_speed\",max(\"demand\") AS \"demand\" FROM \"{ZONE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"zone_id\",\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new();
for item in series {
let zone = item.tags.get("zone_id").cloned().unwrap_or_default();
let device = item.tags.get("device_id").cloned().unwrap_or_default();
for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; };
out.push(ZoneReading { id:0, zone_id:zone.clone(), device_id:device.clone(), timestamp, gree_temperature:row_num(&row,"gree_temperature"), external_temperature:row_num(&row,"external_temperature"), control_temperature:row_num(&row,"control_temperature"), target_temperature:row_num(&row,"target_temperature"), device_setpoint:row_num(&row,"device_setpoint"), outdoor_temperature:row_num(&row,"outdoor_temperature"), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, mode:"history".into(), fan_speed:row_num(&row,"fan_speed").unwrap_or(0.0).round().clamp(0.0,5.0) as u8, demand:row_num(&row,"demand").unwrap_or(0.0)>=0.5, control_source:"influx".into(), active_preset:"history".into() });
}
}
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out)
}
async fn query_ha_v1(client: &Client, settings: &InfluxDbSettings, entity_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<HaReading>> {
let filter = entity_id.map(|id| format!(" AND \"entity_id\"='{}'", influxql_string(id))).unwrap_or_default();
let q = format!("SELECT mean(\"temperature\") AS \"temperature\" FROM \"{HA_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"entity_id\",\"zone_id\",\"kind\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new();
for item in series {
let entity = item.tags.get("entity_id").cloned().unwrap_or_default();
let zone = item.tags.get("zone_id").filter(|v| !v.is_empty()).cloned();
let kind = item.tags.get("kind").cloned().unwrap_or_else(|| "room".into());
for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; };
let Some(temperature) = row_num(&row,"temperature") else { continue; };
out.push(HaReading { id:0, entity_id:entity.clone(), zone_id:zone.clone(), kind:kind.clone(), timestamp, temperature });
}
}
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out)
}
struct V1Series { tags: HashMap<String,String>, rows: Vec<HashMap<String,Value>> }
async fn query_v1(client: &Client, settings: &InfluxDbSettings, q: &str) -> Result<Vec<V1Series>> {
validate(settings)?;
let base = settings.url.trim_end_matches('/');
let request = client.get(format!("{base}/query")).query(&[("db", settings.database.as_str()), ("q", q)]);
let request = if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) };
let response = request.send().await.context("InfluxDB 1.x query failed")?;
let status = response.status();
let body: Value = response.json().await.context("invalid InfluxDB 1.x JSON response")?;
if !status.is_success() { bail!("InfluxDB 1.x query failed ({status}): {body}"); }
if let Some(error) = body.pointer("/results/0/error").and_then(Value::as_str) { bail!("InfluxDB 1.x query error: {error}"); }
let mut out = Vec::new();
for series in body.pointer("/results/0/series").and_then(Value::as_array).into_iter().flatten() {
let columns: Vec<String> = series.get("columns").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect();
let tags = series.get("tags").and_then(Value::as_object).map(|map| map.iter().map(|(k,v)|(k.clone(),v.as_str().unwrap_or_default().to_string())).collect()).unwrap_or_default();
let mut rows = Vec::new();
for values in series.get("values").and_then(Value::as_array).into_iter().flatten() {
let Some(values) = values.as_array() else { continue; };
rows.push(columns.iter().cloned().zip(values.iter().cloned()).collect());
}
out.push(V1Series { tags, rows });
}
Ok(out)
}
async fn query_v2(client: &Client, settings: &InfluxDbSettings, query: &str) -> Result<Vec<HashMap<String,String>>> {
validate(settings)?;
let base = settings.url.trim_end_matches('/');
let response = client.post(format!("{base}/api/v2/query"))
.query(&[("org", settings.org.as_str())])
.bearer_auth(settings.token.trim())
.header(reqwest::header::ACCEPT, "application/csv")
.header(reqwest::header::CONTENT_TYPE, "application/vnd.flux")
.body(query.to_string())
.send().await.context("InfluxDB 2.x query failed")?;
let status = response.status();
let body = response.text().await.context("cannot read InfluxDB 2.x response")?;
if !status.is_success() { bail!("InfluxDB 2.x query failed ({status}): {}", truncate(&body, 500)); }
let mut headers: Option<Vec<String>> = None;
let mut rows = Vec::new();
for line in body.lines().filter(|line| !line.starts_with('#') && !line.trim().is_empty()) {
let record = parse_csv_line(line);
if headers.is_none() {
headers = Some(record);
continue;
}
let row: HashMap<String,String> = headers.as_ref().unwrap().iter().cloned().zip(record.into_iter()).collect();
if row.get("_time").map(|value| !value.is_empty()).unwrap_or(false) { rows.push(row); }
}
Ok(rows)
}
fn parse_csv_line(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut field = String::new();
let mut chars = line.chars().peekable();
let mut quoted = false;
while let Some(ch) = chars.next() {
match ch {
'"' if quoted && chars.peek() == Some(&'"') => {
field.push('"');
chars.next();
}
'"' => quoted = !quoted,
',' if !quoted => {
out.push(std::mem::take(&mut field));
}
_ => field.push(ch),
}
}
out.push(field);
out
}
fn flux_query(settings: &InfluxDbSettings, measurement: &str, extra_filters: &str, group_tags: &[&str], start: DateTime<Utc>, stop: DateTime<Utc>, bucket_seconds: i64) -> String {
let tags = group_tags.iter().map(|tag| format!("\"{tag}\"")).collect::<Vec<_>>().join(",");
format!(
"from(bucket: {}) |> range(start: time(v: {}), stop: time(v: {})) |> filter(fn: (r) => r._measurement == {}){} |> aggregateWindow(every: {}s, fn: mean, createEmpty: false) |> group(columns: [{}]) |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\") |> sort(columns:[\"_time\"])",
flux_string(&settings.bucket), flux_string(&start.to_rfc3339()), flux_string(&stop.to_rfc3339()), flux_string(measurement), extra_filters, bucket_seconds.max(1), tags
)
}
fn line_protocol(measurement: &str, tags: &[(&str, &str)], fields: Vec<String>, timestamp: DateTime<Utc>) -> Result<String> {
if fields.is_empty() { bail!("InfluxDB measurement has no fields"); }
let tags = tags.iter().filter(|(_, value)| !value.is_empty()).map(|(key,value)| format!(",{}={}", escape_tag(key), escape_tag(value))).collect::<String>();
let nanos = timestamp.timestamp_nanos_opt().ok_or_else(|| anyhow!("timestamp outside nanosecond range"))?;
Ok(format!("{}{} {} {}", escape_measurement(measurement), tags, fields.join(","), nanos))
}
fn push_float(fields: &mut Vec<String>, key: &str, value: Option<f64>) { if let Some(value) = value.filter(|v| v.is_finite()) { fields.push(format!("{}={value}", escape_field_key(key))); } }
fn push_int(fields: &mut Vec<String>, key: &str, value: i64) { fields.push(format!("{}={value}i", escape_field_key(key))); }
fn escape_measurement(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace(' ', "\\ ") }
fn escape_tag(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace('=', "\\=").replace(' ', "\\ ") }
fn escape_field_key(value: &str) -> String { escape_tag(value) }
fn influxql_string(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") }
fn flux_string(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) }
fn truncate(value: &str, max: usize) -> String { value.chars().take(max).collect() }
fn row_f64(row: &HashMap<String,String>, key: &str) -> Option<f64> { row.get(key)?.parse().ok() }
fn parse_flux_time(row: &HashMap<String,String>) -> Option<DateTime<Utc>> { DateTime::parse_from_rfc3339(row.get("_time")?).ok().map(|v| v.with_timezone(&Utc)) }
fn row_num(row: &HashMap<String,Value>, key: &str) -> Option<f64> { row.get(key)?.as_f64().or_else(|| row.get(key)?.as_i64().map(|v|v as f64)) }
fn row_time(row: &HashMap<String,Value>) -> Option<DateTime<Utc>> { DateTime::parse_from_rfc3339(row.get("time")?.as_str()?).ok().map(|v|v.with_timezone(&Utc)) }