use anyhow::Context as _;
use chrono::{DateTime, Days, Duration, Months, NaiveDateTime, NaiveTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use crate::tapo_vendor::error::Error;
use crate::tapo_vendor::responses::TapoResponseExt;
use crate::tapo_vendor::utils::{
der_tapo_datetime_format, local_midnight, local_to_utc, skipped_local_time,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(any(), pyo3::prelude::pyclass(from_py_object, get_all))]
pub struct EnergyDataResult {
pub local_time: NaiveDateTime,
pub start_date_time: DateTime<Utc>,
pub entries: Vec<EnergyDataIntervalResult>,
pub interval_length: u64,
}
impl TapoResponseExt for EnergyDataResult {}
#[cfg(any())]
crate::tapo_vendor::impl_to_dict!(EnergyDataResult);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(any(), pyo3::prelude::pyclass(from_py_object, get_all))]
pub struct EnergyDataIntervalResult {
pub start_date_time: DateTime<Utc>,
pub energy: u64,
}
#[cfg(any())]
crate::tapo_vendor::impl_to_dict!(EnergyDataIntervalResult);
#[derive(Debug, Deserialize)]
pub(crate) struct EnergyDataResultRaw {
#[serde(deserialize_with = "der_tapo_datetime_format")]
pub local_time: NaiveDateTime,
pub data: Vec<u64>,
pub start_timestamp: i64,
pub interval: u64,
}
impl TapoResponseExt for EnergyDataResultRaw {}
enum Step {
Hour,
Day,
Month,
}
impl EnergyDataResultRaw {
pub fn in_timezone<Tz: TimeZone>(self, timezone: &Tz) -> Result<EnergyDataResult, Error> {
let start = timezone
.timestamp_opt(self.start_timestamp, 0)
.single()
.context("Failed to map start_timestamp to local time")?;
let start_date_time = start.to_utc();
let step = match self.interval {
60 => Step::Hour,
1440 => Step::Day,
43200 => Step::Month,
_ => {
return Err(anyhow::anyhow!(
"Unsupported interval duration: {} minutes",
self.interval
)
.into());
}
};
let start_date = start.date_naive();
let now = timezone
.from_local_datetime(&self.local_time)
.latest()
.map_or_else(
|| skipped_local_time(self.local_time, timezone),
|now| now.to_utc(),
);
let mut entries = Vec::with_capacity(self.data.len());
for (index, energy) in self.data.into_iter().enumerate() {
let entry_start = match step {
Step::Hour => {
let date = start_date
.checked_add_days(Days::new(index as u64 / 24))
.context("Energy data date is out of range")?;
let hour = Duration::hours(index as i64 % 24);
local_to_utc(date.and_time(NaiveTime::MIN) + hour, timezone)
}
Step::Day => {
let date = start_date
.checked_add_days(Days::new(index as u64))
.context("Energy data date is out of range")?;
local_midnight(date, timezone)
}
Step::Month => {
let date = start_date
.checked_add_months(Months::new(index as u32))
.context("Energy data date is out of range")?;
local_midnight(date, timezone)
}
};
if entry_start > now {
break;
}
entries.push(EnergyDataIntervalResult {
start_date_time: entry_start,
energy,
});
}
Ok(EnergyDataResult {
local_time: self.local_time,
start_date_time,
entries,
interval_length: self.interval,
})
}
}
#[cfg(test)]
mod tests {
use chrono::{NaiveDate, NaiveTime, Timelike as _};
use chrono_tz::America::Santiago;
use chrono_tz::Europe::London;
use super::*;
fn raw(start_date_time: DateTime<Utc>, count: usize, interval: u64) -> EnergyDataResultRaw {
EnergyDataResultRaw {
local_time: NaiveDate::from_ymd_opt(2100, 1, 1)
.unwrap()
.and_time(NaiveTime::MIN),
data: vec![0; count],
start_timestamp: start_date_time.timestamp(),
interval,
}
}
fn local_dates_and_times(result: &EnergyDataResult) -> Vec<NaiveDateTime> {
result
.entries
.iter()
.map(|entry| entry.start_date_time.with_timezone(&Santiago).naive_local())
.collect()
}
#[test]
fn test_daily_entries_start_at_local_midnight_across_dst_changes() {
let at = |month, day, hour| {
NaiveDate::from_ymd_opt(2026, month, day)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap()
};
let expected = vec![at(9, 4, 0), at(9, 5, 0), at(9, 6, 1), at(9, 7, 0)];
let start = Santiago.from_local_datetime(&at(9, 4, 0)).unwrap().to_utc();
let result = raw(start, 4, 1440).in_timezone(&Santiago).unwrap();
assert_eq!(local_dates_and_times(&result), expected);
let expected = vec![at(4, 3, 0), at(4, 4, 0), at(4, 5, 0), at(4, 6, 0)];
let start = Santiago.from_local_datetime(&at(4, 3, 0)).unwrap().to_utc();
let result = raw(start, 4, 1440).in_timezone(&Santiago).unwrap();
assert_eq!(local_dates_and_times(&result), expected);
}
#[test]
fn test_monthly_entries_start_on_the_first_of_each_month() {
let first_day = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let start = local_midnight(first_day, &Santiago);
let result = raw(start, 12, 43200).in_timezone(&Santiago).unwrap();
for (index, date_time) in local_dates_and_times(&result).into_iter().enumerate() {
assert_eq!(date_time.date(), first_day + Months::new(index as u32));
assert_eq!(date_time.hour(), 0);
}
}
#[test]
fn test_entries_after_the_local_time_are_left_out() {
let date = |day| NaiveDate::from_ymd_opt(2026, 10, day).unwrap();
let start = local_midnight(date(1), &Santiago);
let mut quarter = raw(start, 92, 1440);
quarter.local_time = date(18).and_hms_opt(12, 0, 0).unwrap();
let result = quarter.in_timezone(&Santiago).unwrap();
let dates: Vec<_> = local_dates_and_times(&result)
.into_iter()
.map(|date_time| date_time.date())
.collect();
assert_eq!(dates, (1..=18).map(date).collect::<Vec<_>>());
}
#[test]
fn test_skipped_local_time_is_read_with_the_offset_from_before_the_jump() {
let at = |day, hour| {
NaiveDate::from_ymd_opt(2026, 9, day)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap()
};
let start = local_midnight(at(5, 0).date(), &Santiago);
let mut hours = raw(start, 48, 60);
hours.local_time = NaiveDate::from_ymd_opt(2026, 9, 6)
.unwrap()
.and_hms_opt(0, 30, 0)
.unwrap();
let result = hours.in_timezone(&Santiago).unwrap();
let mut expected: Vec<_> = (0..24).map(|hour| at(5, hour)).collect();
expected.extend([at(6, 1), at(6, 1)]);
assert_eq!(local_dates_and_times(&result), expected);
}
#[test]
fn test_hourly_entries_are_24_to_a_local_day_across_a_repeated_hour() {
let at = |day, hour| {
NaiveDate::from_ymd_opt(2026, 4, day)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap()
};
let start = local_midnight(at(4, 0).date(), &Santiago);
let result = raw(start, 48, 60).in_timezone(&Santiago).unwrap();
let expected: Vec<_> = [4, 5]
.into_iter()
.flat_map(|day| (0..24).map(move |hour| at(day, hour)))
.collect();
assert_eq!(local_dates_and_times(&result), expected);
assert_eq!(
result.entries[24].start_date_time - result.entries[23].start_date_time,
Duration::hours(2)
);
}
#[test]
fn test_hourly_entries_are_24_to_a_local_day_across_a_repeated_hour_mid_day() {
let at = |day, hour| {
NaiveDate::from_ymd_opt(2026, 10, day)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap()
};
let start = local_midnight(at(25, 0).date(), &London);
let result = raw(start, 48, 60).in_timezone(&London).unwrap();
let local: Vec<_> = result
.entries
.iter()
.map(|entry| entry.start_date_time.with_timezone(&London).naive_local())
.collect();
let expected: Vec<_> = [25, 26]
.into_iter()
.flat_map(|day| (0..24).map(move |hour| at(day, hour)))
.collect();
assert_eq!(local, expected);
assert_eq!(
result.entries[2].start_date_time - result.entries[1].start_date_time,
Duration::hours(2)
);
}
#[test]
fn test_hourly_entry_for_a_skipped_hour_starts_with_the_next_hour() {
let at = |hour| {
NaiveDate::from_ymd_opt(2026, 9, 6)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap()
};
let start = local_midnight(at(0).date(), &Santiago);
let result = raw(start, 24, 60).in_timezone(&Santiago).unwrap();
let mut expected = vec![at(1)];
expected.extend((1..24).map(at));
assert_eq!(local_dates_and_times(&result), expected);
}
#[test]
fn test_unsupported_interval_is_an_error() {
assert!(raw(Utc::now(), 1, 15).in_timezone(&Santiago).is_err());
assert!(raw(Utc::now(), 0, 15).in_timezone(&Santiago).is_err());
}
}