Skip to content

Commit

Permalink
Merge pull request #42 from zestysoft/add-hourly-data-pulls
Browse files Browse the repository at this point in the history
Add Hourly Data
  • Loading branch information
zestysoft authored Dec 3, 2024
2 parents a5d62bf + 02f8b2e commit fcf0a9e
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 80 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ Below are the sensor entities created by this integration:
- `sensor.sensus_analytics_billing_usage`: Total usage amount that has been billed.
- `sensor.sensus_analytics_billing_cost`: Total cost of the billed usage.
- `sensor.sensus_analytics_daily_fee`: Daily fee based on usage.
- `sensor.sensus_analytics_hourly_usage`: Hourly water usage.
- `sensor.sensus_analytics_rain_per_inch_per_hour`: Rain per inch per hour.
- `sensor.sensus_analytics_temp_per_hour`: Temperature per hour.

# Be kind

Expand Down
175 changes: 139 additions & 36 deletions custom_components/sensus_analytics/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
"""DataUpdateCoordinator for Sensus Analytics Integration."""

import logging
from datetime import timedelta
from datetime import datetime, timedelta

import requests
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from requests.exceptions import RequestException

from .const import CONF_ACCOUNT_NUMBER, CONF_BASE_URL, CONF_METER_NUMBER, CONF_PASSWORD, CONF_USERNAME, DOMAIN

Expand All @@ -18,6 +17,7 @@ class SensusAnalyticsDataUpdateCoordinator(DataUpdateCoordinator):

def __init__(self, hass: HomeAssistant, config_entry):
"""Initialize."""
self.hass = hass
self.base_url = config_entry.data[CONF_BASE_URL]
self.username = config_entry.data[CONF_USERNAME]
self.password = config_entry.data[CONF_PASSWORD]
Expand All @@ -41,42 +41,145 @@ def _fetch_data(self):
"""Fetch data from the Sensus Analytics API."""
_LOGGER.debug("Starting data fetch from Sensus Analytics API")
try:
session = requests.Session()
# Get session cookie
r_sec = session.post(
f"{self.base_url}/j_spring_security_check",
data={"j_username": self.username, "j_password": self.password},
allow_redirects=False,
timeout=10,
)
# Check if login was successful
if r_sec.status_code != 302:
_LOGGER.error("Authentication failed with status code %s", r_sec.status_code)
raise UpdateFailed("Authentication failed")

_LOGGER.debug("Authentication successful")

# Request meter data
response = session.post(
f"{self.base_url}/water/widget/byPage",
json={
"group": "meters",
"accountNumber": self.account_number,
"deviceId": self.meter_number,
},
timeout=10,
)
response.raise_for_status()
data = response.json()
_LOGGER.debug("Raw response data: %s", data)
# Navigate to the specific data
data = data.get("widgetList")[0].get("data").get("devices")[0]
_LOGGER.debug("Parsed data: %s", data)
session = self._create_authenticated_session()

# Fetch daily data
data = self._fetch_daily_data(session)

# Fetch hourly data
_LOGGER.debug("Fetching hourly data")
target_date = datetime.now() - timedelta(days=1)
hourly_data = self._retrieve_hourly_data(session, target_date)
if hourly_data:
data["hourly_usage_data"] = hourly_data
else:
_LOGGER.warning("Failed to fetch hourly data")

return data

except RequestException as error:
_LOGGER.error("Error fetching data: %s", error)
raise UpdateFailed(f"Error fetching data: {error}") from error
except UpdateFailed as error:
raise error
except Exception as error:
_LOGGER.error("Unexpected error: %s", error)
raise UpdateFailed(f"Unexpected error: {error}") from error

def _create_authenticated_session(self):
"""Create and return an authenticated session."""
session = requests.Session()
# Authenticate and get session cookie
r_sec = session.post(
f"{self.base_url}/j_spring_security_check",
data={"j_username": self.username, "j_password": self.password},
allow_redirects=False,
timeout=10,
)
# Check if login was successful
if r_sec.status_code != 302:
_LOGGER.error("Authentication failed with status code %s", r_sec.status_code)
raise UpdateFailed("Authentication failed")

_LOGGER.debug("Authentication successful")
return session

def _fetch_daily_data(self, session):
"""Fetch daily meter data."""
response = session.post(
f"{self.base_url}/water/widget/byPage",
json={
"group": "meters",
"accountNumber": self.account_number,
"deviceId": self.meter_number,
},
timeout=10,
)
response.raise_for_status()
data = response.json()
_LOGGER.debug("Raw response data: %s", data)
# Navigate to the specific data
data = data.get("widgetList")[0].get("data").get("devices")[0]
_LOGGER.debug("Parsed data: %s", data)
return data

def _retrieve_hourly_data(self, session: requests.Session, target_date: datetime):
"""Retrieve hourly usage data for a specific date based on local time."""
# Prepare request parameters
start_ts, end_ts = self._get_start_end_timestamps(target_date)
usage_url, params = self._construct_hourly_data_request(start_ts, end_ts)

try:
response = session.get(usage_url, params=params, timeout=10)
response.raise_for_status() # Ensure the request was successful
hourly_data = response.json()
_LOGGER.debug("Hourly data response: %s", hourly_data)

# Validate and process the response
hourly_entries = self._process_hourly_data_response(hourly_data)
return hourly_entries

except requests.exceptions.RequestException as e:
_LOGGER.error("Hourly data retrieval failed: %s", e)
return None
except (KeyError, TypeError, ValueError) as e:
_LOGGER.error("Error processing the hourly data response: %s", e)
return None

def _get_start_end_timestamps(self, target_date):
"""Get start and end timestamps in milliseconds for the target date."""
start_dt = datetime.combine(target_date, datetime.min.time())
end_dt = datetime.combine(target_date, datetime.max.time())

start_ts = int(start_dt.timestamp() * 1000)
end_ts = int(end_dt.timestamp() * 1000)
return start_ts, end_ts

def _construct_hourly_data_request(self, start_ts, end_ts):
"""Construct the hourly data request URL and parameters."""
usage_url = f"{self.base_url}/water/usage/{self.account_number}/{self.meter_number}"
params = {
"start": start_ts,
"end": end_ts,
"zoom": "day",
"page": "null",
"weather": "1", # As per URL parameter expectations
}
return usage_url, params

def _process_hourly_data_response(self, hourly_data):
"""Process and structure the hourly data response."""
if not isinstance(hourly_data, dict):
_LOGGER.error("Unexpected response format for hourly data.")
return None

if not hourly_data.get("operationSuccess", False):
errors = hourly_data.get("errors", [])
_LOGGER.error("API returned errors: %s", errors)
return None

usage_list = hourly_data.get("data", {}).get("usage", [])
if not usage_list or len(usage_list) < 2:
_LOGGER.error("Hourly usage data is missing or incomplete.")
return None

# The first element contains units
units = usage_list[0] # ["CF", "INCHES", "FAHRENHEIT", "CF"]
usage_unit = units[0]
rain_unit = units[1]
temp_unit = units[2]

# The rest of the list contains hourly data
hourly_entries = []
for entry in usage_list[1:]:
timestamp, usage, rain, temp = entry[:4]
hourly_entries.append(
{
"timestamp": timestamp,
"usage": usage,
"rain": rain,
"temp": temp,
"usage_unit": usage_unit,
"rain_unit": rain_unit,
"temp_unit": temp_unit,
}
)

return hourly_entries
2 changes: 1 addition & 1 deletion custom_components/sensus_analytics/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"domain": "sensus_analytics",
"name": "Sensus Analytics Integration",
"version": "1.5.3",
"version": "1.6.0",
"documentation": "https://github.com/zestysoft/sensus_analytics_integration",
"dependencies": [],
"codeowners": ["@zestysoft"],
Expand Down
131 changes: 110 additions & 21 deletions custom_components/sensus_analytics/sensor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Sensor platform for the Sensus Analytics Integration."""

from datetime import datetime

from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
Expand All @@ -15,33 +17,35 @@
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities):
"""Set up the Sensus Analytics sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
SensusAnalyticsDailyUsageSensor(coordinator, entry),
SensusAnalyticsUsageUnitSensor(coordinator, entry),
SensusAnalyticsMeterAddressSensor(coordinator, entry),
SensusAnalyticsLastReadSensor(coordinator, entry),
SensusAnalyticsMeterLongitudeSensor(coordinator, entry),
SensusAnalyticsMeterIdSensor(coordinator, entry),
SensusAnalyticsMeterLatitudeSensor(coordinator, entry),
SensusAnalyticsLatestReadUsageSensor(coordinator, entry),
SensusAnalyticsBillingUsageSensor(coordinator, entry),
SensusAnalyticsBillingCostSensor(coordinator, entry),
SensusAnalyticsDailyFeeSensor(coordinator, entry),
],
True,
)


sensors = [
SensusAnalyticsDailyUsageSensor(coordinator, entry),
SensusAnalyticsUsageUnitSensor(coordinator, entry),
SensusAnalyticsMeterAddressSensor(coordinator, entry),
SensusAnalyticsLastReadSensor(coordinator, entry),
SensusAnalyticsMeterLongitudeSensor(coordinator, entry),
SensusAnalyticsMeterIdSensor(coordinator, entry),
SensusAnalyticsMeterLatitudeSensor(coordinator, entry),
SensusAnalyticsLatestReadUsageSensor(coordinator, entry),
SensusAnalyticsBillingUsageSensor(coordinator, entry),
SensusAnalyticsBillingCostSensor(coordinator, entry),
SensusAnalyticsDailyFeeSensor(coordinator, entry),
HourlyUsageSensor(coordinator, entry),
RainPerInchPerHourSensor(coordinator, entry),
TempPerHourSensor(coordinator, entry), # Renamed as per your request
]
async_add_entities(sensors, True)


# pylint: disable=too-few-public-methods
class UsageConversionMixin:
"""Mixin to provide usage conversion."""

# pylint: disable=too-few-public-methods
def _convert_usage(self, usage):
def _convert_usage(self, usage, usage_unit=None):
"""Convert usage based on configuration and native unit."""
if usage is None:
return None
usage_unit = self.coordinator.data.get("usageUnit")
if usage_unit is None:
usage_unit = self.coordinator.data.get("usageUnit")
if usage_unit == "CF" and self.coordinator.config_entry.data.get("unit_type") == "G":
try:
return round(float(usage) * CF_TO_GALLON)
Expand Down Expand Up @@ -360,3 +364,88 @@ def _calculate_daily_fee(self, usage_gallons):
cost += (usage_gallons - tier1_gallons - tier2_gallons) * tier3_price

return round(cost, 2)


class HourlyUsageSensor(DynamicUnitSensorBase):
"""Representation of the hourly usage sensor."""

def __init__(self, coordinator, entry):
"""Initialize the hourly usage sensor."""
super().__init__(coordinator, entry)
self._attr_name = f"{DEFAULT_NAME} Hourly Usage"
self._attr_unique_id = f"{self._unique_id}_hourly_usage"
self._attr_icon = "mdi:water"

@property
def native_value(self):
"""Return the current hour's usage from the previous day."""
now = datetime.now()
target_hour = now.hour
hourly_data = self.coordinator.data.get("hourly_usage_data", [])
if not hourly_data:
return None

# Find the data corresponding to target_hour from the previous day
for entry in hourly_data:
entry_time = datetime.fromtimestamp(entry["timestamp"] / 1000)
if entry_time.hour == target_hour:
usage = entry["usage"]
usage_unit = entry.get("usage_unit")
return self._convert_usage(usage, usage_unit)
return None


class RainPerInchPerHourSensor(StaticUnitSensorBase):
"""Representation of the rain per inch per hour sensor."""

def __init__(self, coordinator, entry):
"""Initialize the rain per inch per hour sensor."""
super().__init__(coordinator, entry, unit="in")
self._attr_name = f"{DEFAULT_NAME} Rain Per Inch Per Hour"
self._attr_unique_id = f"{self._unique_id}_rain_per_inch_per_hour"
self._attr_icon = "mdi:weather-rainy"

@property
def native_value(self):
"""Return the current hour's rain data from the previous day."""
now = datetime.now()
target_hour = now.hour
hourly_data = self.coordinator.data.get("hourly_usage_data", [])
if not hourly_data:
return None

# Find the data corresponding to target_hour from the previous day
for entry in hourly_data:
entry_time = datetime.fromtimestamp(entry["timestamp"] / 1000)
if entry_time.hour == target_hour:
rain = entry["rain"]
return rain
return None


class TempPerHourSensor(StaticUnitSensorBase):
"""Representation of the temperature per hour sensor."""

def __init__(self, coordinator, entry):
"""Initialize the temperature per hour sensor."""
super().__init__(coordinator, entry, unit="°F")
self._attr_name = f"{DEFAULT_NAME} Temperature Per Hour"
self._attr_unique_id = f"{self._unique_id}_temp_per_hour"
self._attr_icon = "mdi:thermometer"

@property
def native_value(self):
"""Return the current hour's temperature from the previous day."""
now = datetime.now()
target_hour = now.hour
hourly_data = self.coordinator.data.get("hourly_usage_data", [])
if not hourly_data:
return None

# Find the data corresponding to target_hour from the previous day
for entry in hourly_data:
entry_time = datetime.fromtimestamp(entry["timestamp"] / 1000)
if entry_time.hour == target_hour:
temp = entry["temp"]
return temp
return None
Loading

0 comments on commit fcf0a9e

Please sign in to comment.