From a73999bd7e2dec55ab4c55e4a8360b776fec199b Mon Sep 17 00:00:00 2001 From: Eric Lindvall Date: Mon, 12 Jan 2026 13:22:24 -0800 Subject: [PATCH] Add support for fetching energy usage data --- nuheat/__init__.py | 10 +- nuheat/thermostat.py | 276 +++++++++++++++++++++++- tests/fixtures/energy_usage.json | 11 + tests/fixtures/energy_usage_no_kwh.json | 11 + tests/fixtures/weekly_usage.json | 16 ++ tests/fixtures/yearly_usage.json | 21 ++ tests/test_thermostat.py | 191 +++++++++++++++- 7 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/energy_usage.json create mode 100644 tests/fixtures/energy_usage_no_kwh.json create mode 100644 tests/fixtures/weekly_usage.json create mode 100644 tests/fixtures/yearly_usage.json diff --git a/nuheat/__init__.py b/nuheat/__init__.py index e2407a0..d1421b4 100644 --- a/nuheat/__init__.py +++ b/nuheat/__init__.py @@ -1,4 +1,12 @@ from nuheat.api import NuHeat -from nuheat.thermostat import NuHeatThermostat +from nuheat.thermostat import ( + NuHeatThermostat, + EnergyUsage, + HourlyUsage, + DailyUsage, + WeeklyUsage, + MonthlyUsage, + YearlyUsage, +) import nuheat.config as config import nuheat.util as util diff --git a/nuheat/thermostat.py b/nuheat/thermostat.py index e21972d..1afc3a0 100644 --- a/nuheat/thermostat.py +++ b/nuheat/thermostat.py @@ -1,4 +1,6 @@ -from datetime import datetime, timezone, timedelta, time +from dataclasses import dataclass +from datetime import datetime, timezone, timedelta, time, date +from typing import Optional import nuheat.config as config from nuheat.util import ( celsius_to_nuheat, @@ -8,6 +10,64 @@ ) +@dataclass +class HourlyUsage: + """Hourly energy usage data.""" + + hour: int + heating_minutes: int + energy_kwh: Optional[float] + + +@dataclass +class DailyUsage: + """Daily energy usage data.""" + + date: date + heating_minutes: int + energy_kwh: Optional[float] + + +@dataclass +class EnergyUsage: + """Energy usage data from the NuHeat API (daily view with hourly breakdown).""" + + date: date + heating_minutes: int + energy_kwh: Optional[float] + hourly: list + + +@dataclass +class WeeklyUsage: + """Weekly energy usage data from the NuHeat API (weekly view with daily breakdown).""" + + week_start: date + heating_minutes: int + energy_kwh: Optional[float] + daily: list + + +@dataclass +class MonthlyUsage: + """Monthly energy usage data.""" + + year: int + month: int + heating_minutes: int + energy_kwh: Optional[float] + + +@dataclass +class YearlyUsage: + """Yearly energy usage data from the NuHeat API (yearly view with monthly breakdown).""" + + year: int + heating_minutes: int + energy_kwh: Optional[float] + monthly: list + + class NuHeatThermostat(object): _session = None _data = None @@ -351,3 +411,217 @@ def set_data(self, post_data): data=post_data, params=params, ) + + def get_energy_usage(self, for_date: Optional[date] = None) -> EnergyUsage: + """ + Fetch energy usage data for a specific date. + + :param for_date: The date to fetch energy usage for. Defaults to today. + :return: EnergyUsage object containing heating minutes and energy in kWh + """ + if for_date is None: + for_date = date.today() + + date_str = for_date.strftime("%Y-%m-%d") + + params = { + "serialnumber": self.serial_number, + "view": "day", + "date": date_str, + "history": "0", + "calc": "yes", + "weekstart": "sunday", + } + + data = self._session.request( + url=f"{self._session._api_url}/energyusage", + params=params, + ) + + total_minutes = 0 + total_kwh = 0.0 + has_kwh = False + hourly_data = [] + + # Response format: {"EnergyUsage": [{"Usage": [{"Minutes": x, "EnergyKWattHour": y}, ...]}]} + energy_usage = data.get("EnergyUsage", []) + for day_data in energy_usage: + usage_entries = day_data.get("Usage", []) + for hour, entry in enumerate(usage_entries): + minutes = entry.get("Minutes", 0) + kwh = entry.get("EnergyKWattHour", 0) + + total_minutes += minutes + if kwh > 0: + has_kwh = True + total_kwh += kwh + + hourly_data.append(HourlyUsage( + hour=hour, + heating_minutes=minutes, + energy_kwh=kwh if kwh > 0 else None, + )) + + # If we have heating minutes but no kWh data, return None (user hasn't + # configured watt density in NuHeat app). If heating minutes is 0, + # return 0.0 since zero heating = zero energy. + if total_minutes == 0: + final_kwh: Optional[float] = 0.0 + elif has_kwh: + final_kwh = total_kwh + else: + final_kwh = None + + return EnergyUsage( + date=for_date, + heating_minutes=total_minutes, + energy_kwh=final_kwh, + hourly=hourly_data, + ) + + def get_weekly_usage(self, for_date: Optional[date] = None) -> WeeklyUsage: + """ + Fetch weekly energy usage data with daily breakdown. + + :param for_date: A date within the week to fetch. Defaults to today. + :return: WeeklyUsage object containing daily breakdown + """ + if for_date is None: + for_date = date.today() + + date_str = for_date.strftime("%Y-%m-%d") + + params = { + "serialnumber": self.serial_number, + "view": "week", + "date": date_str, + "history": "0", + "calc": "yes", + "weekstart": "sunday", + } + + data = self._session.request( + url=f"{self._session._api_url}/energyusage", + params=params, + ) + + total_minutes = 0 + total_kwh = 0.0 + has_kwh = False + daily_data = [] + + # Determine week start based on API response + monday_first = data.get("MondayIsFirstDay", False) + + # Calculate the start of the week containing for_date + weekday = for_date.weekday() # Monday=0, Sunday=6 + if monday_first: + days_since_start = weekday + else: + # Sunday=0 for week start + days_since_start = (weekday + 1) % 7 + week_start = for_date - timedelta(days=days_since_start) + + # Response format: {"EnergyUsage": [{"Usage": [{"Minutes": x, "EnergyKWattHour": y}, ...]}]} + energy_usage = data.get("EnergyUsage", []) + for day_data in energy_usage: + usage_entries = day_data.get("Usage", []) + for day_offset, entry in enumerate(usage_entries): + minutes = entry.get("Minutes", 0) + kwh = entry.get("EnergyKWattHour", 0) + + total_minutes += minutes + if kwh > 0: + has_kwh = True + total_kwh += kwh + + entry_date = week_start + timedelta(days=day_offset) + daily_data.append(DailyUsage( + date=entry_date, + heating_minutes=minutes, + energy_kwh=kwh if kwh > 0 else None, + )) + + # If we have heating minutes but no kWh data, return None (user hasn't + # configured watt density in NuHeat app). If heating minutes is 0, + # return 0.0 since zero heating = zero energy. + if total_minutes == 0: + final_kwh: Optional[float] = 0.0 + elif has_kwh: + final_kwh = total_kwh + else: + final_kwh = None + + return WeeklyUsage( + week_start=week_start, + heating_minutes=total_minutes, + energy_kwh=final_kwh, + daily=daily_data, + ) + + def get_yearly_usage(self, year: Optional[int] = None) -> YearlyUsage: + """ + Fetch yearly energy usage data with monthly breakdown. + + :param year: The year to fetch. Defaults to current year. + :return: YearlyUsage object containing monthly breakdown + """ + if year is None: + year = date.today().year + + params = { + "serialnumber": self.serial_number, + "view": "year", + "date": str(year), + "history": "0", + "calc": "yes", + "weekstart": "sunday", + } + + data = self._session.request( + url=f"{self._session._api_url}/energyusage", + params=params, + ) + + total_minutes = 0 + total_kwh = 0.0 + has_kwh = False + monthly_data = [] + + # Response format: {"EnergyUsage": [{"Usage": [{"Minutes": x, "EnergyKWattHour": y}, ...]}]} + # 12 entries, one per month (January=0 to December=11) + energy_usage = data.get("EnergyUsage", []) + for year_data in energy_usage: + usage_entries = year_data.get("Usage", []) + for month_index, entry in enumerate(usage_entries): + minutes = entry.get("Minutes", 0) + kwh = entry.get("EnergyKWattHour", 0) + + total_minutes += minutes + if kwh > 0: + has_kwh = True + total_kwh += kwh + + monthly_data.append(MonthlyUsage( + year=year, + month=month_index + 1, # 1-12 + heating_minutes=minutes, + energy_kwh=kwh if kwh > 0 else None, + )) + + # If we have heating minutes but no kWh data, return None (user hasn't + # configured watt density in NuHeat app). If heating minutes is 0, + # return 0.0 since zero heating = zero energy. + if total_minutes == 0: + final_kwh: Optional[float] = 0.0 + elif has_kwh: + final_kwh = total_kwh + else: + final_kwh = None + + return YearlyUsage( + year=year, + heating_minutes=total_minutes, + energy_kwh=final_kwh, + monthly=monthly_data, + ) diff --git a/tests/fixtures/energy_usage.json b/tests/fixtures/energy_usage.json new file mode 100644 index 0000000..2a73047 --- /dev/null +++ b/tests/fixtures/energy_usage.json @@ -0,0 +1,11 @@ +{ + "EnergyUsage": [ + { + "Usage": [ + {"Minutes": 15, "EnergyKWattHour": 0.3}, + {"Minutes": 30, "EnergyKWattHour": 0.6}, + {"Minutes": 10, "EnergyKWattHour": 0.2} + ] + } + ] +} diff --git a/tests/fixtures/energy_usage_no_kwh.json b/tests/fixtures/energy_usage_no_kwh.json new file mode 100644 index 0000000..a32d6d2 --- /dev/null +++ b/tests/fixtures/energy_usage_no_kwh.json @@ -0,0 +1,11 @@ +{ + "EnergyUsage": [ + { + "Usage": [ + {"Minutes": 15, "EnergyKWattHour": 0}, + {"Minutes": 30, "EnergyKWattHour": 0}, + {"Minutes": 10, "EnergyKWattHour": 0} + ] + } + ] +} diff --git a/tests/fixtures/weekly_usage.json b/tests/fixtures/weekly_usage.json new file mode 100644 index 0000000..aa56a0b --- /dev/null +++ b/tests/fixtures/weekly_usage.json @@ -0,0 +1,16 @@ +{ + "MondayIsFirstDay": false, + "EnergyUsage": [ + { + "Usage": [ + {"Minutes": 0, "EnergyKWattHour": 0}, + {"Minutes": 0, "EnergyKWattHour": 0}, + {"Minutes": 0, "EnergyKWattHour": 0}, + {"Minutes": 0, "EnergyKWattHour": 0}, + {"Minutes": 0, "EnergyKWattHour": 0}, + {"Minutes": 261, "EnergyKWattHour": 0}, + {"Minutes": 576, "EnergyKWattHour": 0} + ] + } + ] +} diff --git a/tests/fixtures/yearly_usage.json b/tests/fixtures/yearly_usage.json new file mode 100644 index 0000000..51e7e6e --- /dev/null +++ b/tests/fixtures/yearly_usage.json @@ -0,0 +1,21 @@ +{ + "MondayIsFirstDay": false, + "EnergyUsage": [ + { + "Usage": [ + {"Minutes": 13780, "EnergyKWattHour": 0}, + {"Minutes": 15400, "EnergyKWattHour": 0}, + {"Minutes": 10348, "EnergyKWattHour": 0}, + {"Minutes": 9106, "EnergyKWattHour": 0}, + {"Minutes": 6233, "EnergyKWattHour": 0}, + {"Minutes": 7871, "EnergyKWattHour": 0}, + {"Minutes": 9350, "EnergyKWattHour": 0}, + {"Minutes": 14239, "EnergyKWattHour": 0}, + {"Minutes": 9981, "EnergyKWattHour": 0}, + {"Minutes": 15947, "EnergyKWattHour": 0}, + {"Minutes": 14183, "EnergyKWattHour": 0}, + {"Minutes": 14907, "EnergyKWattHour": 0} + ] + } + ] +} diff --git a/tests/test_thermostat.py b/tests/test_thermostat.py index 3dac0d9..42eb4d9 100644 --- a/tests/test_thermostat.py +++ b/tests/test_thermostat.py @@ -1,12 +1,12 @@ import json import responses -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone, timedelta, date from mock import patch from parameterized import parameterized from urllib.parse import urlencode -from nuheat import NuHeat, NuHeatThermostat, config +from nuheat import NuHeat, NuHeatThermostat, EnergyUsage, HourlyUsage, DailyUsage, WeeklyUsage, MonthlyUsage, YearlyUsage, config from . import NuTestCase, load_fixture @@ -552,3 +552,190 @@ def test_set_data(self, _): self.assertEqual(api_call.request.method, "POST") self.assertUrlsEqual(api_call.request.url, request_url) self.assertEqual(api_call.request.body, urlencode(post_data)) + + @responses.activate + @patch("nuheat.NuHeatThermostat.get_data") + def test_get_energy_usage(self, _): + response_data = load_fixture("energy_usage.json") + api = NuHeat(None, None, session_id="my-session") + + responses.add( + responses.GET, + f"{api._api_url}/energyusage", + status=200, + body=json.dumps(response_data), + content_type="application/json" + ) + + serial_number = "my-thermostat" + thermostat = NuHeatThermostat(api, serial_number) + + test_date = date(2024, 1, 15) + energy = thermostat.get_energy_usage(test_date) + + self.assertIsInstance(energy, EnergyUsage) + self.assertEqual(energy.date, test_date) + self.assertEqual(energy.heating_minutes, 55) # 15 + 30 + 10 + self.assertAlmostEqual(energy.energy_kwh, 1.1, places=2) # 0.3 + 0.6 + 0.2 + + # Verify hourly data + self.assertEqual(len(energy.hourly), 3) + self.assertIsInstance(energy.hourly[0], HourlyUsage) + self.assertEqual(energy.hourly[0].hour, 0) + self.assertEqual(energy.hourly[0].heating_minutes, 15) + self.assertAlmostEqual(energy.hourly[0].energy_kwh, 0.3, places=2) + self.assertEqual(energy.hourly[1].hour, 1) + self.assertEqual(energy.hourly[1].heating_minutes, 30) + self.assertEqual(energy.hourly[2].hour, 2) + self.assertEqual(energy.hourly[2].heating_minutes, 10) + + api_call = responses.calls[0] + self.assertEqual(api_call.request.method, "GET") + self.assertIn("serialnumber=my-thermostat", api_call.request.url) + self.assertIn("date=2024-01-15", api_call.request.url) + self.assertIn("view=day", api_call.request.url) + + @responses.activate + @patch("nuheat.NuHeatThermostat.get_data") + def test_get_energy_usage_no_kwh(self, _): + response_data = load_fixture("energy_usage_no_kwh.json") + api = NuHeat(None, None, session_id="my-session") + + responses.add( + responses.GET, + f"{api._api_url}/energyusage", + status=200, + body=json.dumps(response_data), + content_type="application/json" + ) + + serial_number = "my-thermostat" + thermostat = NuHeatThermostat(api, serial_number) + + test_date = date(2024, 1, 15) + energy = thermostat.get_energy_usage(test_date) + + self.assertIsInstance(energy, EnergyUsage) + self.assertEqual(energy.date, test_date) + self.assertEqual(energy.heating_minutes, 55) # 15 + 30 + 10 + self.assertIsNone(energy.energy_kwh) # No kWh data available + + # Verify hourly data has no kWh + self.assertEqual(len(energy.hourly), 3) + for hourly in energy.hourly: + self.assertIsNone(hourly.energy_kwh) + + @responses.activate + @patch("nuheat.NuHeatThermostat.get_data") + def test_get_energy_usage_default_date(self, _): + response_data = load_fixture("energy_usage.json") + api = NuHeat(None, None, session_id="my-session") + + responses.add( + responses.GET, + f"{api._api_url}/energyusage", + status=200, + body=json.dumps(response_data), + content_type="application/json" + ) + + serial_number = "my-thermostat" + thermostat = NuHeatThermostat(api, serial_number) + + energy = thermostat.get_energy_usage() # No date specified + + self.assertIsInstance(energy, EnergyUsage) + self.assertEqual(energy.date, date.today()) + + @responses.activate + @patch("nuheat.NuHeatThermostat.get_data") + def test_get_weekly_usage(self, _): + response_data = load_fixture("weekly_usage.json") + api = NuHeat(None, None, session_id="my-session") + + responses.add( + responses.GET, + f"{api._api_url}/energyusage", + status=200, + body=json.dumps(response_data), + content_type="application/json" + ) + + serial_number = "my-thermostat" + thermostat = NuHeatThermostat(api, serial_number) + + # Saturday Jan 11, 2025 - week starts Sunday Jan 5 + test_date = date(2025, 1, 11) + weekly = thermostat.get_weekly_usage(test_date) + + self.assertIsInstance(weekly, WeeklyUsage) + self.assertEqual(weekly.week_start, date(2025, 1, 5)) # Sunday + self.assertEqual(weekly.heating_minutes, 837) # 261 + 576 + self.assertIsNone(weekly.energy_kwh) # No kWh data + + # Verify daily data + self.assertEqual(len(weekly.daily), 7) + self.assertIsInstance(weekly.daily[0], DailyUsage) + + # Sunday (index 0) = Jan 5 + self.assertEqual(weekly.daily[0].date, date(2025, 1, 5)) + self.assertEqual(weekly.daily[0].heating_minutes, 0) + + # Friday (index 5) = Jan 10 + self.assertEqual(weekly.daily[5].date, date(2025, 1, 10)) + self.assertEqual(weekly.daily[5].heating_minutes, 261) + + # Saturday (index 6) = Jan 11 + self.assertEqual(weekly.daily[6].date, date(2025, 1, 11)) + self.assertEqual(weekly.daily[6].heating_minutes, 576) + + api_call = responses.calls[0] + self.assertEqual(api_call.request.method, "GET") + self.assertIn("view=week", api_call.request.url) + + @responses.activate + @patch("nuheat.NuHeatThermostat.get_data") + def test_get_yearly_usage(self, _): + response_data = load_fixture("yearly_usage.json") + api = NuHeat(None, None, session_id="my-session") + + responses.add( + responses.GET, + f"{api._api_url}/energyusage", + status=200, + body=json.dumps(response_data), + content_type="application/json" + ) + + serial_number = "my-thermostat" + thermostat = NuHeatThermostat(api, serial_number) + + yearly = thermostat.get_yearly_usage(2024) + + self.assertIsInstance(yearly, YearlyUsage) + self.assertEqual(yearly.year, 2024) + # Sum: 13780+15400+10348+9106+6233+7871+9350+14239+9981+15947+14183+14907 = 141345 + self.assertEqual(yearly.heating_minutes, 141345) + self.assertIsNone(yearly.energy_kwh) # No kWh data + + # Verify monthly data + self.assertEqual(len(yearly.monthly), 12) + self.assertIsInstance(yearly.monthly[0], MonthlyUsage) + + # January (index 0) + self.assertEqual(yearly.monthly[0].year, 2024) + self.assertEqual(yearly.monthly[0].month, 1) + self.assertEqual(yearly.monthly[0].heating_minutes, 13780) + + # February (index 1) + self.assertEqual(yearly.monthly[1].month, 2) + self.assertEqual(yearly.monthly[1].heating_minutes, 15400) + + # December (index 11) + self.assertEqual(yearly.monthly[11].month, 12) + self.assertEqual(yearly.monthly[11].heating_minutes, 14907) + + api_call = responses.calls[0] + self.assertEqual(api_call.request.method, "GET") + self.assertIn("view=year", api_call.request.url) + self.assertIn("date=2024", api_call.request.url)