diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2b99502..9dc9761 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,23 @@ Changelog ========= +Version 7.5.0 (2026-02-16) +========================== + +Added +----- +- **OpenEI Client Module**: New ``OpenEIClient`` async client (``nwp500.openei``) for browsing utility rate plans from the OpenEI API by zip code. Supports listing utilities, filtering rate plans, and fetching plan details. API key read from ``OPENEI_API_KEY`` environment variable. +- **Convert TOU API**: ``NavienAPIClient.convert_tou()`` sends raw OpenEI rate data to the Navien backend for server-side conversion into device-ready TOU schedules with season/week bitfields and scaled pricing. +- **Update TOU API**: ``NavienAPIClient.update_tou()`` applies a converted TOU rate plan to a device, matching the mobile app's ``PUT /device/tou`` endpoint. +- **ConvertedTOUPlan Model**: New Pydantic model for parsed ``convert_tou()`` results (utility, name, schedule). +- **CLI ``tou rates``**: Browse utilities and rate plans for a zip code (``nwp500 tou rates 94903``). +- **CLI ``tou plan``**: View converted rate plan details with decoded pricing (``nwp500 tou plan 94903 "EV Rate A"``). +- **CLI ``tou apply``**: Apply a rate plan to the water heater with optional ``--enable`` flag to activate TOU via MQTT. + +Changed +------- +- **``examples/advanced/tou_openei.py``**: Rewritten to use the new ``OpenEIClient`` and ``convert_tou()``/``update_tou()`` library methods instead of inline OpenEI API calls and client-side conversion. + Version 7.4.6 (2026-02-13) ========================== diff --git a/docs/guides/time_of_use.rst b/docs/guides/time_of_use.rst index 5341d11..a35a43d 100644 --- a/docs/guides/time_of_use.rst +++ b/docs/guides/time_of_use.rst @@ -221,6 +221,86 @@ Retrieves stored TOU configuration from the Navien cloud API. zip_code: int # ZIP code schedule: List[TOUSchedule] # TOU schedule periods +REST API: Convert TOU Rate Plans +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + async def convert_tou( + source_data: List[Dict[str, Any]], + source_type: str = "openei", + source_version: int = 7 + ) -> List[ConvertedTOUPlan] + +Sends raw OpenEI rate plan data to the Navien backend for conversion into +device-ready TOU schedules with season/week bitfields and scaled pricing. + +**Parameters:** + +* ``source_data``: List of OpenEI rate plan dictionaries (from ``OpenEIClient.fetch_rates()``) +* ``source_type``: Data source type (default: ``"openei"``) +* ``source_version``: OpenEI API version (default: ``7``) + +**Returns:** + +List of ``ConvertedTOUPlan`` objects, each containing: + +* ``utility``: Utility company name +* ``name``: Rate plan name +* ``schedule``: List of ``TOUSchedule`` with device-ready intervals + +REST API: Apply TOU Rate Plan +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + async def update_tou( + mac_address: str, + additional_value: str, + tou_info: Dict[str, Any], + source_data: Dict[str, Any], + zip_code: str, + register_path: str = "wifi", + source_type: str = "openei", + user_type: str = "O" + ) -> TOUInfo + +Applies a converted TOU rate plan to a device. + +**Parameters:** + +* ``mac_address``: Device MAC address +* ``additional_value``: Additional device identifier +* ``tou_info``: Converted TOU schedule (from ``convert_tou()``) +* ``source_data``: Original OpenEI rate plan dictionary +* ``zip_code``: Service area zip code +* ``register_path``: Connection type (``"wifi"`` or ``"bt"``) + +**Returns:** + +``TOUInfo`` object with the applied configuration. + +OpenEI Client +~~~~~~~~~~~~~ + +.. code-block:: python + + from nwp500 import OpenEIClient + + async with OpenEIClient() as client: + # List utilities for a zip code + utilities = await client.list_utilities("94903") + + # List rate plans (optionally filtered by utility) + plans = await client.list_rate_plans("94903", utility="Pacific Gas") + + # Get a specific rate plan + plan = await client.get_rate_plan("94903", "EV Rate A") + +The ``OpenEIClient`` reads the API key from the ``OPENEI_API_KEY`` environment +variable or accepts it as a constructor parameter. Get a free key at +https://openei.org/services/api/signup/ + MQTT: Configure TOU Schedule ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -652,167 +732,77 @@ Enable or disable TOU operation: # Disable TOU asyncio.run(toggle_tou(False)) -Example 5: Retrieve Schedule from OpenEI API -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Example 5: Apply Rate Plan from OpenEI +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This example demonstrates the complete workflow of retrieving utility rate -data from the OpenEI API and configuring it on your device: +Use the ``OpenEIClient`` and ``convert_tou()``/``update_tou()`` methods to +browse, convert, and apply a rate plan from OpenEI — the same workflow the +Navien mobile app uses: .. code-block:: python import asyncio - import aiohttp - from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient, build_tou_period - - OPENEI_API_URL = "https://api.openei.org/utility_rates" - OPENEI_API_KEY = "DEMO_KEY" # Get your own key at openei.org - - async def fetch_openei_rates(zip_code: str, api_key: str): - """Fetch utility rates from OpenEI API.""" - params = { - "version": 7, - "format": "json", - "api_key": api_key, - "detail": "full", - "address": zip_code, - "sector": "Residential", - "orderby": "startdate", - "direction": "desc", - "limit": 100, - } - - async with aiohttp.ClientSession() as session: - async with session.get(OPENEI_API_URL, params=params) as response: - response.raise_for_status() - return await response.json() - - def select_tou_rate_plan(rate_data): - """Select first approved residential TOU plan.""" - for plan in rate_data.get("items", []): - if ( - plan.get("approved") - and plan.get("sector") == "Residential" - and "energyweekdayschedule" in plan - and "energyratestructure" in plan - ): - return plan - return None - - def convert_openei_to_tou_periods(rate_plan): - """Convert OpenEI rate structure to Navien TOU periods.""" - weekday_schedule = rate_plan["energyweekdayschedule"][0] - rate_structure = rate_plan["energyratestructure"][0] - - # Map period indices to rates - period_rates = {} - for idx, tier in enumerate(rate_structure): - period_rates[idx] = tier.get("rate", 0.0) - - # Find continuous time blocks - periods = [] - current_period = None - start_hour = 0 - - for hour in range(24): - period_idx = weekday_schedule[hour] - - if period_idx != current_period: - if current_period is not None: - # Save previous period - periods.append({ - "start_hour": start_hour, - "end_hour": hour - 1, - "end_minute": 59, - "rate": period_rates.get(current_period, 0.0), - }) - current_period = period_idx - start_hour = hour - - # Last period - periods.append({ - "start_hour": start_hour, - "end_hour": 23, - "end_minute": 59, - "rate": period_rates.get(current_period, 0.0), - }) - - # Convert to TOU format - weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] - return [ - build_tou_period( - season_months=range(1, 13), - week_days=weekdays, - start_hour=p["start_hour"], - start_minute=0, - end_hour=p["end_hour"], - end_minute=p["end_minute"], - price_min=p["rate"], - price_max=p["rate"], - decimal_point=5, - ) - for p in periods - ] - - async def configure_openei_schedule(): - """Main function to retrieve and configure TOU from OpenEI.""" - zip_code = "94103" # San Francisco example - - # Fetch and parse OpenEI data - rate_data = await fetch_openei_rates(zip_code, OPENEI_API_KEY) - rate_plan = select_tou_rate_plan(rate_data) - - if not rate_plan: - print("No suitable TOU rate plan found") - return - - print(f"Using plan: {rate_plan['name']}") - print(f"Utility: {rate_plan['utility']}") - - tou_periods = convert_openei_to_tou_periods(rate_plan) - - # Configure on device + from nwp500 import ( + NavienAPIClient, + NavienAuthClient, + NavienMqttClient, + OpenEIClient, + ) + + async def apply_openei_rate_plan(): async with NavienAuthClient("user@example.com", "password") as auth: api_client = NavienAPIClient(auth_client=auth) device = await api_client.get_first_device() - - mqtt_client = NavienMqttClient(auth) - await mqtt_client.connect() - - # Get controller serial (see Example 1 for full code) - # ... obtain controller_serial ... - - # Configure the schedule - await mqtt_client.control.configure_tou_schedule( - device=device, - controller_serial_number=controller_serial, - periods=tou_periods, - enabled=True, - ) - - print(f"Configured {len(tou_periods)} TOU periods from OpenEI") - await mqtt_client.disconnect() - asyncio.run(configure_openei_schedule()) + # 1. Browse available rate plans + async with OpenEIClient() as openei: + rates = await openei.fetch_rates("94903") + items = rates.get("items", []) -**Key Points:** + # 2. Convert all plans to device format + converted = await api_client.convert_tou(source_data=items) -* The OpenEI API requires a free API key (register at openei.org) -* The ``DEMO_KEY`` is rate-limited and suitable for testing only -* Rate structures vary by utility - this example handles simple TOU plans -* Complex tiered rates may require additional logic to flatten into periods -* The example uses weekday schedules; extend for weekends as needed -* Set ``ZIP_CODE`` environment variable to search your location + # 3. Find the plan you want + plan = next(p for p in converted if "EV" in p.name) -**Required Dependencies:** + # 4. Build tou_info dict for update + tou_info = { + "name": plan.name, + "utility": plan.utility, + "schedule": [s.model_dump() for s in plan.schedule], + "zipCode": "94903", + } -.. code-block:: bash + # 5. Find matching source data + source = next( + i for i in items if i.get("name") == plan.name + ) + + # 6. Apply to device + result = await api_client.update_tou( + mac_address=device.device_info.mac_address, + additional_value=str(device.device_info.additional_value), + tou_info=tou_info, + source_data=source, + zip_code="94903", + ) + print(f"Applied: {result.name} ({result.utility})") + + # 7. Enable TOU via MQTT + mqtt_client = NavienMqttClient(auth) + await mqtt_client.connect() + await mqtt_client.control.set_tou_enabled(device, enabled=True) + await mqtt_client.disconnect() - pip install aiohttp + asyncio.run(apply_openei_rate_plan()) -**Complete Working Example:** +**Key Points:** -See ``examples/tou_openei_example.py`` for a fully working implementation -with error handling, weekend support, and detailed console output. +* Set the ``OPENEI_API_KEY`` environment variable before running +* Get a free key at https://openei.org/services/api/signup/ +* ``convert_tou()`` handles the complex format conversion server-side +* The ``update_tou()`` method stores the plan in the Navien cloud +* Use ``set_tou_enabled()`` to activate TOU mode on the device MQTT Message Format ------------------- @@ -978,4 +968,22 @@ Related Examples * ``examples/tou_schedule_example.py`` - Complete working example of manual TOU configuration * ``examples/tou_openei_example.py`` - Retrieve TOU schedules from OpenEI API and configure device +CLI Commands +~~~~~~~~~~~~ + +The CLI provides commands for the full TOU workflow: + +.. code-block:: bash + + # List utilities and rate plans for a zip code + nwp500 tou rates 94903 + nwp500 tou rates 94903 --utility "Pacific Gas" + + # View converted rate plan details + nwp500 tou plan 94903 "EV Rate A" + + # Apply a rate plan to the water heater + nwp500 tou apply 94903 "EV Rate A" + nwp500 tou apply 94903 "EV Rate A" --enable # also enable TOU + For questions or issues related to TOU functionality, please refer to the project repository. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index dd1fa49..c5ea832 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -513,6 +513,199 @@ paths: zipCode: type: integer example: 94595 + put: + summary: Apply TOU Rate Plan + description: | + Applies a Time of Use rate plan to a device. The request includes the converted + TOU schedule (from POST /device/tou/convert), the original source data from OpenEI, + and device identifiers. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - additionalValue + - macAddress + - sourceData + - sourceType + - touInfo + - userId + - userType + - zipCode + properties: + additionalValue: + type: string + example: "5322" + macAddress: + type: string + example: "04786332fca0" + registerPath: + type: string + example: "wifi" + description: Device connection type (wifi or bt) + sourceData: + type: object + description: Original OpenEI rate plan object + sourceType: + type: string + example: "openei" + touInfo: + type: object + properties: + name: + type: string + example: "Electric Vehicle EV (Sch) Rate A" + schedule: + type: array + items: + type: object + properties: + season: + type: integer + interval: + type: array + items: + $ref: '#/components/schemas/TOUInterval' + utility: + type: string + example: "Pacific Gas & Electric Co" + zipCode: + type: string + example: "94903" + userId: + type: string + example: "user@example.com" + userType: + type: string + example: "O" + zipCode: + type: string + example: "94903" + responses: + '200': + description: Rate plan applied successfully + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + msg: + type: string + example: SUCCESS + data: + type: object + properties: + sourceType: + type: string + example: "openei" + touInfo: + type: object + properties: + name: + type: string + schedule: + type: array + items: + type: object + properties: + season: + type: integer + interval: + type: array + items: + $ref: '#/components/schemas/TOUInterval' + utility: + type: string + zipCode: + type: integer + controllerId: + type: string + /device/tou/convert: + post: + summary: Convert OpenEI Rate Plans to Device Format + description: | + Converts raw OpenEI rate plan data into the device-ready TOU format with + season bitfields, week bitfields, and scaled pricing. This endpoint handles + the complex conversion logic server-side. + + Send all rate plan items from an OpenEI API response and receive back the + converted schedules ready for use with PUT /device/tou. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - sourceData + - sourceType + - sourceVersion + - userId + - userType + properties: + sourceData: + type: array + description: Array of OpenEI rate plan objects + items: + type: object + sourceType: + type: string + example: "openei" + sourceVersion: + type: integer + example: 7 + userId: + type: string + example: "user@example.com" + userType: + type: string + example: "O" + responses: + '200': + description: Converted rate plans + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + msg: + type: string + example: SUCCESS + data: + type: object + properties: + touInfo: + type: array + items: + type: object + properties: + utility: + type: string + example: "Pacific Gas & Electric Co" + name: + type: string + example: "Electric Vehicle EV (Sch) Rate A" + schedule: + type: array + items: + type: object + properties: + season: + type: integer + example: 3087 + description: Season bitfield + interval: + type: array + items: + $ref: '#/components/schemas/TOUInterval' /app/update-push-token: post: summary: Update Push Token @@ -551,6 +744,44 @@ paths: type: string example: SUCCESS components: + schemas: + TOUInterval: + type: object + properties: + week: + type: integer + example: 124 + description: > + Days of week bitfield (62=weekdays, 65=weekend, + 127=all; Sun=1, Mon=2, ..., Sat=64) + startHour: + type: integer + example: 0 + startMinute: + type: integer + example: 0 + endHour: + type: integer + example: 6 + endMinute: + type: integer + example: 59 + priceMin: + type: integer + example: 31794 + description: > + Minimum price (divide by 10^decimalPoint) + priceMax: + type: integer + example: 31794 + description: > + Maximum price (divide by 10^decimalPoint) + decimalPoint: + type: integer + example: 5 + description: > + Decimal places for price (e.g., 5 = divide by + 100000) securitySchemes: bearerAuth: type: http diff --git a/examples/advanced/tou_openei.py b/examples/advanced/tou_openei.py index e34f160..4fa8530 100755 --- a/examples/advanced/tou_openei.py +++ b/examples/advanced/tou_openei.py @@ -1,324 +1,117 @@ #!/usr/bin/env python3 """ -Example: Retrieve TOU schedule from OpenEI API and configure device. +Example: Browse OpenEI rate plans and apply one to a Navien device. -This example demonstrates how to: -1. Query the OpenEI Utility Rates API for electricity rate plans -2. Parse the rate structure from the API response -3. Convert OpenEI rate schedules into TOU periods -4. Configure the TOU schedule on a Navien device via MQTT +This example demonstrates the full TOU workflow using the library's +OpenEI client and Navien API methods — the same flow the mobile app +uses: + +1. Query OpenEI for utility rate plans by zip code +2. Convert plans to device-ready format via Navien backend +3. Apply the selected plan to the water heater +4. Enable TOU mode via MQTT """ import asyncio import os import sys -from typing import Any - -import aiohttp from nwp500 import ( NavienAPIClient, NavienAuthClient, NavienMqttClient, - build_tou_period, + OpenEIClient, decode_price, decode_week_bitfield, ) -# OpenEI API configuration -OPENEI_API_URL = "https://api.openei.org/utility_rates" -OPENEI_API_VERSION = 7 - -# You can get a free API key from https://openei.org/services/api/signup/ -# For testing purposes, you can use the demo key (rate limited) -OPENEI_API_KEY = "DEMO_KEY" - - -async def fetch_openei_rates( - zip_code: str, api_key: str = OPENEI_API_KEY -) -> dict[str, Any]: - """ - Fetch utility rate information from OpenEI API. - - Args: - zip_code: ZIP code to search for rates - api_key: OpenEI API key (default: DEMO_KEY) - - Returns: - Dictionary containing rate plan data from OpenEI - - Raises: - aiohttp.ClientError: If the API request fails - """ - params = { - "version": OPENEI_API_VERSION, - "format": "json", - "api_key": api_key, - "detail": "full", - "address": zip_code, - "sector": "Residential", - "orderby": "startdate", - "direction": "desc", - "limit": 100, - } - - async with aiohttp.ClientSession() as session: - async with session.get(OPENEI_API_URL, params=params) as response: - response.raise_for_status() - data = await response.json() - return data - - -def select_rate_plan(rate_data: dict[str, Any]) -> dict[str, Any] | None: - """ - Select a suitable rate plan from OpenEI response. - - This example selects the first approved residential rate plan - with time-of-use pricing (has energyweekdayschedule). - - Args: - rate_data: Response data from OpenEI API - - Returns: - Selected rate plan dictionary, or None if no suitable plan found - """ - items = rate_data.get("items", []) - - for plan in items: - # Look for approved residential plans with TOU schedules - if ( - plan.get("approved") - and plan.get("sector") == "Residential" - and "energyweekdayschedule" in plan - and "energyratestructure" in plan - ): - return plan - - return None - - -def convert_openei_to_tou_periods( - rate_plan: dict[str, Any], -) -> list[dict[str, Any]]: - """ - Convert OpenEI rate plan to TOU period format for Navien device. - - This is a simplified conversion that handles basic TOU schedules. - More complex rate structures (e.g., tiered rates, demand charges) - may require additional logic. - - Args: - rate_plan: Rate plan data from OpenEI - - Returns: - List of TOU period dictionaries ready for device configuration - """ - weekday_schedule = rate_plan.get("energyweekdayschedule", [[]]) - # Note: weekend_schedule available but not used in this simplified example - # weekend_schedule = rate_plan.get("energyweekendschedule", [[]]) - rate_structure = rate_plan.get("energyratestructure", [[]]) - - # For simplicity, we'll use the first month's schedule - # A production implementation would handle all 12 months - if not weekday_schedule or not weekday_schedule[0]: - print("Warning: No weekday schedule found in rate plan") - return [] - - hourly_schedule = weekday_schedule[0] # 24-hour array - - # Extract unique rate periods from the hourly schedule - # Build a map of period_index -> rate - period_to_rate = {} - for month_idx, month_tiers in enumerate(rate_structure): - if month_tiers: - for tier_idx, tier in enumerate(month_tiers): - if tier_idx not in period_to_rate: - period_to_rate[tier_idx] = tier.get("rate", 0.0) - - # Find continuous time blocks with the same rate period - periods = [] - current_period = None - start_hour = 0 - - for hour in range(24): - period_idx = hourly_schedule[hour] - - if current_period is None: - # Start of first period - current_period = period_idx - start_hour = hour - elif period_idx != current_period: - # Period changed, save previous period - rate = period_to_rate.get(current_period, 0.0) - periods.append( - { - "start_hour": start_hour, - "end_hour": hour - 1, - "end_minute": 59, - "rate": rate, - } - ) - - # Start new period - current_period = period_idx - start_hour = hour - - # Don't forget the last period - if current_period is not None: - rate = period_to_rate.get(current_period, 0.0) - periods.append( - { - "start_hour": start_hour, - "end_hour": 23, - "end_minute": 59, - "rate": rate, - } - ) - - # Convert to TOU period format - tou_periods = [] - weekdays = [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - ] - - for period in periods: - tou_period = build_tou_period( - season_months=range(1, 13), # All months - week_days=weekdays, - start_hour=period["start_hour"], - start_minute=0, - end_hour=period["end_hour"], - end_minute=period["end_minute"], - price_min=period["rate"], - price_max=period["rate"], - decimal_point=5, - ) - tou_periods.append(tou_period) - - return tou_periods - - -async def _wait_for_controller_serial(mqtt_client: NavienMqttClient, device) -> str: - """Get controller serial number from device.""" - loop = asyncio.get_running_loop() - feature_future: asyncio.Future = loop.create_future() - - def capture_feature(feature) -> None: - if not feature_future.done(): - feature_future.set_result(feature) - - await mqtt_client.subscribe_device_feature(device, capture_feature) - await mqtt_client.control.request_device_info(device) - feature = await asyncio.wait_for(feature_future, timeout=15) - return feature.controller_serial_number - async def main() -> None: - # Check for required environment variables email = os.getenv("NAVIEN_EMAIL") password = os.getenv("NAVIEN_PASSWORD") - zip_code = os.getenv("ZIP_CODE", "94103") # Default to SF + zip_code = os.getenv("ZIP_CODE", "94903") if not email or not password: print("Error: Set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables") sys.exit(1) - # Optional: Use custom OpenEI API key - api_key = os.getenv("OPENEI_API_KEY", OPENEI_API_KEY) + # --- Step 1: Browse rate plans from OpenEI --- + print(f"Fetching utility rates for ZIP {zip_code}…") + async with OpenEIClient() as openei: + rates = await openei.fetch_rates(zip_code) + items = rates.get("items", []) - print(f"Fetching utility rates for ZIP code: {zip_code}") - print("(This may take a few seconds...)") - - # Step 1: Fetch rate data from OpenEI - try: - rate_data = await fetch_openei_rates(zip_code, api_key) - except Exception as e: - print(f"Error fetching OpenEI data: {e}") + if not items: + print("No rate plans found for this location") sys.exit(1) - # Step 2: Select a suitable rate plan - rate_plan = select_rate_plan(rate_data) - if not rate_plan: - print("No suitable TOU rate plan found for this location") - sys.exit(1) - - print("\nSelected rate plan:") - print(f" Utility: {rate_plan.get('utility')}") - print(f" Name: {rate_plan.get('name')}") - print(f" EIA ID: {rate_plan.get('eiaid')}") - - # Step 3: Convert rate plan to TOU periods - tou_periods = convert_openei_to_tou_periods(rate_plan) - - if not tou_periods: - print("Could not convert rate plan to TOU periods") - sys.exit(1) - - print(f"\nConverted to {len(tou_periods)} TOU periods:") - for i, period in enumerate(tou_periods, 1): - # Decode for display - days = decode_week_bitfield(period["week"]) - price = decode_price(period["priceMin"], period["decimalPoint"]) - print( - f" {i}. {period['startHour']:02d}:{period['startMinute']:02d}" - f"-{period['endHour']:02d}:{period['endMinute']:02d} " - f"@ ${price:.5f}/kWh ({', '.join(days[:2])}...)" - ) - - # Step 4: Connect to Navien device - print("\nConnecting to Navien device...") - async with NavienAuthClient(email, password) as auth_client: - api_client = NavienAPIClient(auth_client=auth_client) - device = await api_client.get_first_device() - - if not device: - print("No devices found for this account") - return - - mqtt_client = NavienMqttClient(auth_client) - await mqtt_client.connect() - - print("Getting controller serial number...") - try: - controller_serial = await _wait_for_controller_serial(mqtt_client, device) - except asyncio.TimeoutError: - print("Timed out waiting for device info") - await mqtt_client.disconnect() - return - - # Step 5: Configure TOU schedule on device - print("\nConfiguring TOU schedule on device...") - - response_topic = ( - f"cmd/{device.device_info.device_type}/" - f"{mqtt_client.config.client_id}/res/tou/rd" - ) - - def on_tou_response(topic: str, message: dict[str, Any]) -> None: - response = message.get("response", {}) - enabled = response.get("reservationUse") - print("\nDevice confirmed TOU schedule configured") - print(f" Enabled: {enabled == 2}") - print(f" Periods: {len(response.get('reservation', []))}") - - await mqtt_client.subscribe(response_topic, on_tou_response) + # Show available utilities + utilities = sorted({i["utility"] for i in items}) + print(f"\nFound {len(items)} plans from {len(utilities)} utilities:") + for u in utilities: + count = sum(1 for i in items if i["utility"] == u) + print(f" • {u} ({count} plans)") + + # --- Step 2: Convert plans via Navien backend --- + print("\nConverting plans to device format…") + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth_client=auth) + device = await api.get_first_device() + converted = await api.convert_tou(source_data=items) + + print(f"Converted {len(converted)} plans:") + for i, plan in enumerate(converted[:10], 1): + print(f" {i}. {plan.name} ({plan.utility})") + if len(converted) > 10: + print(f" … and {len(converted) - 10} more") + + # --- Step 3: Select a plan --- + # For this example, pick the first EV plan (or first plan) + selected = next((p for p in converted if "EV" in p.name), converted[0]) + source = next(i for i in items if i.get("name") == selected.name) + + print(f"\nSelected: {selected.name}") + print(f"Utility: {selected.utility}") + for sched in selected.schedule: + for iv in sched.interval: + days = decode_week_bitfield(iv.week) + price = decode_price(iv.price_min, iv.decimal_point) + print( + f" {iv.start_hour:02d}:{iv.start_minute:02d}" + f"–{iv.end_hour:02d}:{iv.end_minute:02d}" + f" ${price:.5f}/kWh" + f" ({', '.join(days[:3])}…)" + ) - await mqtt_client.control.configure_tou_schedule( - device=device, - controller_serial_number=controller_serial, - periods=tou_periods, - enabled=True, + # --- Step 4: Apply to device --- + print("\nApplying rate plan to device…") + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth_client=auth) + device = await api.get_first_device() + + tou_info = { + "name": selected.name, + "utility": selected.utility, + "schedule": [s.model_dump() for s in selected.schedule], + "zipCode": zip_code, + } + result = await api.update_tou( + mac_address=device.device_info.mac_address, + additional_value=str(device.device_info.additional_value), + tou_info=tou_info, + source_data=source, + zip_code=zip_code, ) + print(f"Applied: {result.name} ({result.utility})") - print("Waiting for device confirmation...") - await asyncio.sleep(5) + # --- Step 5: Enable TOU via MQTT --- + print("Enabling TOU mode…") + mqtt = NavienMqttClient(auth) + await mqtt.connect() + await mqtt.control.set_tou_enabled(device, enabled=True) + await mqtt.disconnect() - await mqtt_client.disconnect() - print("\nTOU schedule from OpenEI successfully configured!") + print("\nDone! TOU schedule configured and enabled.") if __name__ == "__main__": diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index d309c4d..f5824ae 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -98,6 +98,7 @@ create_navien_clients, ) from nwp500.models import ( + ConvertedTOUPlan, Device, DeviceFeature, DeviceInfo, @@ -128,6 +129,9 @@ from nwp500.mqtt_events import ( MqttClientEvents, ) +from nwp500.openei import ( + OpenEIClient, +) from nwp500.unit_system import ( get_unit_system, reset_unit_system, @@ -147,6 +151,7 @@ # Factory functions "create_navien_clients", # Models + "ConvertedTOUPlan", "DeviceStatus", "DeviceFeature", "DeviceInfo", @@ -212,6 +217,8 @@ "DeviceOperationError", # API Client "NavienAPIClient", + # OpenEI Client + "OpenEIClient", # MQTT Client "NavienMqttClient", "MqttConnectionConfig", diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index 2e4a325..b88df7e 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -14,7 +14,7 @@ from .auth import NavienAuthClient from .config import API_BASE_URL from .exceptions import APIError, AuthenticationError, TokenRefreshError -from .models import Device, FirmwareInfo, TOUInfo +from .models import ConvertedTOUPlan, Device, FirmwareInfo, TOUInfo from .unit_system import set_unit_system __author__ = "Emmanuel Levijarvi" @@ -379,6 +379,109 @@ async def get_tou_info( _logger.info("Retrieved TOU info for device") return tou_info + async def convert_tou( + self, + source_data: list[dict[str, Any]], + source_type: str = "openei", + source_version: int = 7, + ) -> list[ConvertedTOUPlan]: + """ + Convert OpenEI rate plans to device TOU format. + + Sends raw OpenEI rate plan data to the Navien backend for + conversion into device-ready TOU schedules with season/week + bitfields and scaled pricing. + + Args: + source_data: List of OpenEI rate plan dictionaries + source_type: Data source type (default: "openei") + source_version: API version (default: 7) + + Returns: + List of ConvertedTOUPlan objects with device-ready schedules + + Raises: + APIError: If API request fails + AuthenticationError: If not authenticated + """ + if not self._auth_client.user_email: + raise AuthenticationError("Must authenticate first") + + response = await self._make_request( + "POST", + "/device/tou/convert", + json_data={ + "sourceData": source_data, + "sourceType": source_type, + "sourceVersion": source_version, + "userId": self._auth_client.user_email, + "userType": "O", + }, + ) + + data = response.get("data", {}) + tou_list = data.get("touInfo", []) + plans = [ConvertedTOUPlan.model_validate(item) for item in tou_list] + + _logger.info("Converted %d rate plans to device format", len(plans)) + return plans + + async def update_tou( + self, + mac_address: str, + additional_value: str, + tou_info: dict[str, Any], + source_data: dict[str, Any], + zip_code: str, + register_path: str = "wifi", + source_type: str = "openei", + user_type: str = "O", + ) -> TOUInfo: + """ + Apply a TOU rate plan to a device. + + Args: + mac_address: Device MAC address + additional_value: Additional device identifier + tou_info: Converted TOU info dict (name, schedule, utility, zipCode) + source_data: Original OpenEI rate plan dictionary + zip_code: Service area zip code + register_path: Device connection type (default: "wifi") + source_type: Data source type (default: "openei") + user_type: User type (default: "O") + + Returns: + TOUInfo object with the applied configuration + + Raises: + APIError: If API request fails + AuthenticationError: If not authenticated + """ + if not self._auth_client.user_email: + raise AuthenticationError("Must authenticate first") + + response = await self._make_request( + "PUT", + "/device/tou", + json_data={ + "additionalValue": additional_value, + "macAddress": mac_address, + "registerPath": register_path, + "sourceData": source_data, + "sourceType": source_type, + "touInfo": tou_info, + "userId": self._auth_client.user_email, + "userType": user_type, + "zipCode": zip_code, + }, + ) + + data = response.get("data", {}) + result = TOUInfo.model_validate(data) + + _logger.info("Applied TOU rate plan to device") + return result + async def update_push_token( self, push_token: str, diff --git a/src/nwp500/cli/__init__.py b/src/nwp500/cli/__init__.py index 72b3205..ca5d623 100644 --- a/src/nwp500/cli/__init__.py +++ b/src/nwp500/cli/__init__.py @@ -13,6 +13,9 @@ handle_set_mode_request, handle_set_tou_enabled_request, handle_status_request, + handle_tou_apply_request, + handle_tou_plan_request, + handle_tou_rates_request, handle_update_reservations_request, ) from .monitoring import handle_monitoring @@ -39,6 +42,9 @@ "handle_set_mode_request", "handle_set_tou_enabled_request", "handle_status_request", + "handle_tou_apply_request", + "handle_tou_plan_request", + "handle_tou_rates_request", "handle_update_reservations_request", # Output formatters "format_json_output", diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 54b9430..741285b 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -320,10 +320,12 @@ def tou() -> None: @tou.command("get") # type: ignore[attr-defined] -@click.pass_context # We need context to access api +@click.option("--json", "output_json", is_flag=True, help="Output raw JSON") @async_command async def tou_get( - mqtt: NavienMqttClient, device: Any, ctx: click.Context | None = None + mqtt: NavienMqttClient, + device: Any, + output_json: bool = False, ) -> None: """Get current TOU schedule.""" ctx = click.get_current_context() @@ -331,7 +333,9 @@ async def tou_get( if ctx and hasattr(ctx, "obj") and ctx.obj is not None: api = ctx.obj.get("api") if api: - await handlers.handle_get_tou_request(mqtt, device, api) + await handlers.handle_get_tou_request( + mqtt, device, api, output_json=output_json + ) else: _logger.error("API client not available") @@ -346,6 +350,91 @@ async def tou_set(mqtt: NavienMqttClient, device: Any, state: str) -> None: ) +@tou.command("rates") # type: ignore[attr-defined] +@click.argument("zip_code") +@click.option("--utility", default=None, help="Filter by utility name") +@async_command +async def tou_rates( + mqtt: NavienMqttClient, device: Any, zip_code: str, utility: str | None +) -> None: + """List utility rate plans for a zip code. + + Queries the OpenEI API for residential electricity rate plans. + Requires OPENEI_API_KEY environment variable. + """ + await handlers.handle_tou_rates_request(zip_code, utility=utility) + + +@tou.command("plan") # type: ignore[attr-defined] +@click.argument("zip_code") +@click.argument("plan_name") +@click.option("--utility", default=None, help="Filter by utility name") +@click.option("--json", "output_json", is_flag=True, help="Output raw JSON") +@async_command +async def tou_plan( + mqtt: NavienMqttClient, + device: Any, + zip_code: str, + plan_name: str, + utility: str | None, + output_json: bool = False, +) -> None: + """View converted rate plan details. + + Shows decoded seasons, time intervals, and prices per kWh. + Requires OPENEI_API_KEY environment variable. + """ + ctx = click.get_current_context() + api = ctx.obj.get("api") if ctx and ctx.obj else None + if api: + await handlers.handle_tou_plan_request( + api, + zip_code, + plan_name, + utility=utility, + output_json=output_json, + ) + else: + _logger.error("API client not available") + + +@tou.command("apply") # type: ignore[attr-defined] +@click.argument("zip_code") +@click.argument("plan_name") +@click.option("--utility", default=None, help="Filter by utility name") +@click.option("--enable", is_flag=True, help="Also enable TOU after applying") +@async_command +async def tou_apply( + mqtt: NavienMqttClient, + device: Any, + zip_code: str, + plan_name: str, + utility: str | None, + enable: bool, +) -> None: + """Apply a rate plan to the water heater. + + Fetches the plan from OpenEI, converts it via the Navien backend, + and applies it to the device. Use --enable to also enable TOU mode. + + Requires OPENEI_API_KEY environment variable. + """ + ctx = click.get_current_context() + api = ctx.obj.get("api") if ctx and ctx.obj else None + if api: + await handlers.handle_tou_apply_request( + mqtt, + device, + api, + zip_code, + plan_name, + utility=utility, + enable=enable, + ) + else: + _logger.error("API client not available") + + @cli.command() # type: ignore[attr-defined] @click.option("--year", type=int, required=True, help="Year to query") @click.option( diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index 5d2dc64..0920912 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -392,9 +392,19 @@ async def handle_get_device_info_rest( async def handle_get_tou_request( - mqtt: NavienMqttClient, device: Device, api_client: Any + mqtt: NavienMqttClient, + device: Device, + api_client: Any, + *, + output_json: bool = False, ) -> None: """Request Time-of-Use settings from REST API.""" + from nwp500.encoding import ( + decode_price, + decode_season_bitfield, + decode_week_bitfield, + ) + try: serial = await get_controller_serial_number(mqtt, device) if not serial: @@ -407,16 +417,32 @@ async def handle_get_tou_request( controller_id=serial, user_type="O", ) - print_json( - { - "name": tou_info.name, - "utility": tou_info.utility, - "zipCode": tou_info.zip_code, - "schedule": [ - {"season": s.season, "intervals": s.intervals} - for s in tou_info.schedule - ], - } + + if output_json: + print_json( + { + "name": tou_info.name, + "utility": tou_info.utility, + "zipCode": tou_info.zip_code, + "schedule": [ + { + "season": s.season, + "intervals": s.intervals, + } + for s in tou_info.schedule + ], + } + ) + return + + _formatter.print_tou_schedule( + name=tou_info.name, + utility=tou_info.utility, + zip_code=tou_info.zip_code, + schedules=tou_info.schedule, + decode_season=decode_season_bitfield, + decode_week=decode_week_bitfield, + decode_price_fn=decode_price, ) except Exception as e: _logger.error(f"Error fetching TOU: {e}") @@ -435,6 +461,237 @@ async def handle_set_tou_enabled_request( ) +async def handle_tou_rates_request( + zip_code: str, + utility: str | None = None, +) -> None: + """List utilities and rate plans for a zip code.""" + from nwp500.openei import OpenEIClient + + try: + async with OpenEIClient() as client: + plans = await client.list_rate_plans(zip_code, utility=utility) + + if not plans: + _formatter.print_error( + f"No rate plans found for zip code {zip_code}", + title="No Results", + ) + return + + # Group by utility + utilities: dict[str, list[dict[str, Any]]] = {} + for plan in plans: + util_name = plan["utility"] + utilities.setdefault(util_name, []).append(plan) + + output: list[dict[str, Any]] = [] + for util_name, util_plans in sorted(utilities.items()): + unique_names = sorted({p["name"] for p in util_plans}) + output.append( + { + "utility": util_name, + "planCount": len(unique_names), + "plans": unique_names, + } + ) + + print_json(output) + except ValueError as e: + _formatter.print_error(str(e), title="Configuration Error") + except Exception as e: + _logger.error(f"Error fetching rate plans: {e}") + _formatter.print_error(str(e), title="Error") + + +async def handle_tou_plan_request( + api_client: NavienAPIClient, + zip_code: str, + plan_name: str, + utility: str | None = None, + *, + output_json: bool = False, +) -> None: + """View a converted rate plan's details.""" + from nwp500.encoding import ( + decode_price, + decode_season_bitfield, + decode_week_bitfield, + ) + from nwp500.openei import OpenEIClient + + try: + async with OpenEIClient() as client: + rate_plan = await client.get_rate_plan( + zip_code, plan_name, utility=utility + ) + + if not rate_plan: + _formatter.print_error( + f"Rate plan matching '{plan_name}' not found", + title="Not Found", + ) + return + + # Convert via Navien backend + converted = await api_client.convert_tou([rate_plan]) + + if not converted: + _formatter.print_error( + "Backend returned no converted plans", + title="Conversion Error", + ) + return + + plan = converted[0] + + if output_json: + schedules = [] + for sched in plan.schedule: + months = decode_season_bitfield(sched.season) + intervals = [] + for iv in sched.intervals: + days = decode_week_bitfield(iv.get("week", 0)) + dp = iv.get("decimalPoint", 5) + intervals.append( + { + "days": days, + "time": ( + f"{iv.get('startHour', 0):02d}:" + f"{iv.get('startMinute', 0):02d}-" + f"{iv.get('endHour', 0):02d}:" + f"{iv.get('endMinute', 0):02d}" + ), + "priceMin": ( + "$" + f"{decode_price(iv.get('priceMin', 0), dp):.5f}" + "/kWh" + ), + "priceMax": ( + "$" + f"{decode_price(iv.get('priceMax', 0), dp):.5f}" + "/kWh" + ), + } + ) + schedules.append({"months": months, "intervals": intervals}) + print_json( + { + "utility": plan.utility, + "name": plan.name, + "schedules": schedules, + } + ) + return + + _formatter.print_tou_schedule( + name=plan.name, + utility=plan.utility, + zip_code=int(zip_code) if zip_code.isdigit() else 0, + schedules=plan.schedule, + decode_season=decode_season_bitfield, + decode_week=decode_week_bitfield, + decode_price_fn=decode_price, + ) + except ValueError as e: + _formatter.print_error(str(e), title="Configuration Error") + except Exception as e: + _logger.error(f"Error viewing rate plan: {e}") + _formatter.print_error(str(e), title="Error") + + +async def handle_tou_apply_request( + mqtt: NavienMqttClient, + device: Device, + api_client: NavienAPIClient, + zip_code: str, + plan_name: str, + utility: str | None = None, + enable: bool = False, +) -> None: + """Apply a TOU rate plan to the water heater.""" + from nwp500.openei import OpenEIClient + + try: + # Step 1: Find the rate plan from OpenEI + async with OpenEIClient() as client: + rate_plan = await client.get_rate_plan( + zip_code, plan_name, utility=utility + ) + + if not rate_plan: + _formatter.print_error( + f"Rate plan matching '{plan_name}' not found", + title="Not Found", + ) + return + + # Step 2: Convert via Navien backend + converted = await api_client.convert_tou([rate_plan]) + + if not converted: + _formatter.print_error( + "Backend returned no converted plans", + title="Conversion Error", + ) + return + + plan = converted[0] + + # Step 3: Get device register path from current TOU info + serial = await get_controller_serial_number(mqtt, device) + if not serial: + _logger.error("Failed to get controller serial.") + return + + current_tou = await api_client.get_tou_info( + mac_address=device.device_info.mac_address, + additional_value=device.device_info.additional_value, + controller_id=serial, + ) + register_path = current_tou.register_path or "wifi" + + # Step 4: Apply via PUT /device/tou + tou_info_dict = { + "name": plan.name, + "schedule": [ + {"season": s.season, "interval": s.intervals} + for s in plan.schedule + ], + "utility": plan.utility, + "zipCode": zip_code, + } + + result = await api_client.update_tou( + mac_address=device.device_info.mac_address, + additional_value=device.device_info.additional_value, + tou_info=tou_info_dict, + source_data=rate_plan, + zip_code=zip_code, + register_path=register_path, + ) + + _formatter.print_success( + f"Applied rate plan: {result.name} ({result.utility})" + ) + + # Step 5: Optionally enable TOU + if enable: + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.set_tou_enabled(device, True), + "enabling TOU", + "TOU enabled", + ) + + except ValueError as e: + _formatter.print_error(str(e), title="Configuration Error") + except Exception as e: + _logger.error(f"Error applying rate plan: {e}") + _formatter.print_error(str(e), title="Error") + + async def handle_get_energy_request( mqtt: NavienMqttClient, device: Device, year: int, months: list[int] ) -> None: diff --git a/src/nwp500/cli/rich_output.py b/src/nwp500/cli/rich_output.py index 6488c9f..13f13b1 100644 --- a/src/nwp500/cli/rich_output.py +++ b/src/nwp500/cli/rich_output.py @@ -3,6 +3,7 @@ import json import logging import os +from collections.abc import Callable from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: @@ -50,6 +51,118 @@ def _should_use_rich() -> bool: return os.getenv("NWP500_NO_RICH", "0") != "1" +_MONTH_ABBR = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] + +_DAY_ABBR: dict[str, str] = { + "Sunday": "Sun", + "Monday": "Mon", + "Tuesday": "Tue", + "Wednesday": "Wed", + "Thursday": "Thu", + "Friday": "Fri", + "Saturday": "Sat", +} + + +def _format_months(month_nums: list[int]) -> str: + """Format month numbers into a compact string. + + Collapses consecutive months into ranges + (e.g. ``[6,7,8,9]`` → ``"Jun–Sep"``). + """ + if len(month_nums) == 12: + return "All year" + return _collapse_ranges( + month_nums, + lambda m: _MONTH_ABBR[int(m)], + cycle_size=12, + ) + + +# Canonical ordering used by _abbreviate_days +_DAY_ORDER = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] + + +def _abbreviate_days(day_names: list[str]) -> str: + """Format day names into a compact string. + + Collapses consecutive days into ranges + (e.g. ``['Tue','Wed','Thu','Fri','Sat']`` → ``"Tue–Sat"``). + """ + if len(day_names) == 7: + return "Every day" + s = set(day_names) + if s == {"Saturday", "Sunday"}: + return "Sat–Sun" + # Sort into canonical week order + ordered = sorted(day_names, key=lambda d: _DAY_ORDER.index(d)) + return _collapse_ranges( + ordered, + lambda d: _DAY_ABBR.get(str(d), str(d)[:3]), + cycle_size=7, + ) + + +def _collapse_ranges( + items: list[Any], + label_fn: Callable[[Any], str], + cycle_size: int, +) -> str: + """Collapse consecutive items into 'start–end' ranges. + + Works for both day names (given in canonical order) and + month numbers (1-based ints). + """ + if not items: + return "" + + # Build groups of consecutive items + groups: list[list[Any]] = [[items[0]]] + for prev, curr in zip(items, items[1:], strict=False): + if isinstance(prev, int): + consecutive = (curr - prev) == 1 or ( + prev == cycle_size and curr == 1 + ) + else: + pi = _DAY_ORDER.index(prev) + ci = _DAY_ORDER.index(curr) + consecutive = (ci - pi) == 1 or (pi == 6 and ci == 0) + if consecutive: + groups[-1].append(curr) + else: + groups.append([curr]) + + parts: list[str] = [] + for group in groups: + if len(group) >= 3: + parts.append(f"{label_fn(group[0])}–{label_fn(group[-1])}") + else: + parts.extend(label_fn(g) for g in group) + return ", ".join(parts) + + class OutputFormatter: """Unified output formatter with Rich enhancement support. @@ -156,6 +269,48 @@ def print_device_list(self, devices: list[dict[str, Any]]) -> None: else: self._print_device_list_rich(devices) + def print_tou_schedule( + self, + name: str, + utility: str, + zip_code: int, + schedules: Any, + decode_season: Any, + decode_week: Any, + decode_price_fn: Any, + ) -> None: + """Print TOU schedule as a human-readable table. + + Args: + name: Rate plan name + utility: Utility company name + zip_code: Service ZIP code + schedules: List of TOUSchedule objects + decode_season: Function to decode season bitfield + decode_week: Function to decode week bitfield + decode_price_fn: Function to decode price values + """ + if not self.use_rich: + self._print_tou_plain( + name, + utility, + zip_code, + schedules, + decode_season, + decode_week, + decode_price_fn, + ) + else: + self._print_tou_rich( + name, + utility, + zip_code, + schedules, + decode_season, + decode_week, + decode_price_fn, + ) + # Plain text implementations (fallback) def _print_status_plain(self, items: list[tuple[str, str, str]]) -> None: @@ -208,6 +363,50 @@ def _print_device_list_plain(self, devices: list[dict[str, Any]]) -> None: print(f" {name:<20} {status:<15} {temp}") print("-" * 80) + def _print_tou_plain( + self, + name: str, + utility: str, + zip_code: int, + schedules: Any, + decode_season: Any, + decode_week: Any, + decode_price_fn: Any, + ) -> None: + """Plain text TOU schedule output.""" + print("TOU SCHEDULE") + print("=" * 72) + print(f" Plan: {name}") + print(f" Utility: {utility}") + print(f" ZIP: {zip_code}") + print("=" * 72) + + for sched in schedules: + months = decode_season(sched.season) + month_str = _format_months(months) + print(f"\n Season: {month_str}") + print( + f" {'Days':<20} {'Time':>13}" + f" {'Min $/kWh':>10} {'Max $/kWh':>10}" + ) + print(f" {'-' * 57}") + for iv in sched.intervals: + days = decode_week(iv.get("week", 0)) + dp = iv.get("decimalPoint", 5) + p_min = decode_price_fn(iv.get("priceMin", 0), dp) + p_max = decode_price_fn(iv.get("priceMax", 0), dp) + time_str = ( + f"{iv.get('startHour', 0):02d}:" + f"{iv.get('startMinute', 0):02d}" + f"–{iv.get('endHour', 0):02d}:" + f"{iv.get('endMinute', 0):02d}" + ) + day_str = _abbreviate_days(days) + print( + f" {day_str:<20} {time_str:>13}" + f" {p_min:>10.5f} {p_max:>10.5f}" + ) + def _print_error_plain( self, message: str, @@ -282,6 +481,74 @@ def _print_device_list_rich(self, devices: list[dict[str, Any]]) -> None: self.console.print(table) + def _print_tou_rich( + self, + name: str, + utility: str, + zip_code: int, + schedules: Any, + decode_season: Any, + decode_week: Any, + decode_price_fn: Any, + ) -> None: + """Rich-enhanced TOU schedule output.""" + assert self.console is not None + assert _rich_available + + self.console.print() + self.console.print( + cast(Any, Panel)( + f"[bold]{name}[/bold]\n[dim]{utility} • ZIP {zip_code}[/dim]", + title="⚡ TOU Schedule", + border_style="cyan", + ) + ) + + for sched in schedules: + months = decode_season(sched.season) + month_str = _format_months(months) + + table = cast(Any, Table)( + title=f"Season: {month_str}", + show_header=True, + title_style="bold yellow", + ) + table.add_column("Days", style="cyan", width=20) + table.add_column("Time", style="white", width=13, justify="right") + table.add_column( + "Min $/kWh", + style="green", + width=10, + justify="right", + ) + table.add_column( + "Max $/kWh", + style="green", + width=10, + justify="right", + ) + + for iv in sched.intervals: + days = decode_week(iv.get("week", 0)) + dp = iv.get("decimalPoint", 5) + p_min = decode_price_fn(iv.get("priceMin", 0), dp) + p_max = decode_price_fn(iv.get("priceMax", 0), dp) + time_str = ( + f"{iv.get('startHour', 0):02d}:" + f"{iv.get('startMinute', 0):02d}" + f"–{iv.get('endHour', 0):02d}:" + f"{iv.get('endMinute', 0):02d}" + ) + day_str = _abbreviate_days(days) + table.add_row( + day_str, + time_str, + f"{p_min:.5f}", + f"{p_max:.5f}", + ) + + self.console.print(table) + # Rich implementations def _print_status_rich(self, items: list[tuple[str, str, str]]) -> None: diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 4cdd8f5..05a088a 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -292,6 +292,19 @@ class TOUSchedule(NavienBaseModel): ) +class ConvertedTOUPlan(NavienBaseModel): + """A rate plan converted by the Navien backend from OpenEI format. + + Returned by POST /device/tou/convert. Contains the utility name, + plan name, and device-ready schedule with season/week bitfields + and scaled pricing. + """ + + utility: str = "" + name: str = "" + schedule: list[TOUSchedule] = Field(default_factory=list) + + class TOUInfo(NavienBaseModel): """Time of Use information.""" diff --git a/src/nwp500/openei.py b/src/nwp500/openei.py new file mode 100644 index 0000000..f556c37 --- /dev/null +++ b/src/nwp500/openei.py @@ -0,0 +1,213 @@ +""" +OpenEI Utility Rates API client. + +Provides async access to the OpenEI Utility Rates API for querying +electricity rate plans by zip code. Used to populate Time-of-Use (TOU) +schedules on Navien devices. + +API key can be obtained for free at https://openei.org/services/api/signup/ +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +import aiohttp + +__author__ = "Emmanuel Levijarvi" +__copyright__ = "Emmanuel Levijarvi" +__license__ = "MIT" + +_logger = logging.getLogger(__name__) + +OPENEI_API_URL = "https://api.openei.org/utility_rates" +OPENEI_API_VERSION = 7 + +__all__ = [ + "OpenEIClient", +] + + +class OpenEIClient: + """Async client for the OpenEI Utility Rates API. + + Queries residential electricity rate plans by zip code. + Requires an API key from https://openei.org/services/api/signup/ + + The API key is resolved in this order: + 1. ``api_key`` constructor parameter + 2. ``OPENEI_API_KEY`` environment variable + + Example: + >>> async with OpenEIClient() as client: + ... plans = await client.list_rate_plans("94903") + ... for plan in plans: + ... print(f"{plan['utility']}: {plan['name']}") + """ + + def __init__( + self, + api_key: str | None = None, + session: aiohttp.ClientSession | None = None, + ) -> None: + self._api_key = api_key or os.environ.get("OPENEI_API_KEY") + self._session = session + self._owned_session = False + + def _ensure_api_key(self) -> str: + if not self._api_key: + raise ValueError( + "OpenEI API key required. Set OPENEI_API_KEY environment " + "variable or pass api_key to OpenEIClient(). " + "Get a free key at https://openei.org/services/api/signup/" + ) + return self._api_key + + async def __aenter__(self) -> OpenEIClient: + if self._session is None: + self._session = aiohttp.ClientSession() + self._owned_session = True + return self + + async def __aexit__(self, *args: Any) -> None: + if self._owned_session and self._session: + await self._session.close() + self._session = None + self._owned_session = False + + async def fetch_rates( + self, + zip_code: str, + *, + limit: int = 100, + ) -> list[dict[str, Any]]: + """Fetch all residential rate plans for a zip code. + + Args: + zip_code: US zip code to search + limit: Maximum number of results (default: 100) + + Returns: + List of raw OpenEI rate plan dictionaries + + Raises: + ValueError: If no API key is configured + aiohttp.ClientError: If the API request fails + """ + api_key = self._ensure_api_key() + + params: dict[str, str | int] = { + "version": OPENEI_API_VERSION, + "format": "json", + "api_key": api_key, + "detail": "full", + "address": zip_code, + "sector": "Residential", + "orderby": "startdate", + "direction": "desc", + "limit": limit, + } + + if self._session is None: + raise RuntimeError( + "Session not initialized. Use 'async with OpenEIClient()' " + "or call __aenter__() first." + ) + + _logger.debug("Fetching OpenEI rates for zip code %s", zip_code) + async with self._session.get(OPENEI_API_URL, params=params) as resp: + resp.raise_for_status() + data: dict[str, Any] = await resp.json() + items: list[dict[str, Any]] = data.get("items", []) + _logger.info( + "Retrieved %d rate plans for zip %s", + len(items), + zip_code, + ) + return items + + async def list_utilities(self, zip_code: str) -> list[str]: + """List unique utility providers for a zip code. + + Args: + zip_code: US zip code to search + + Returns: + Sorted list of unique utility names + """ + items = await self.fetch_rates(zip_code) + utilities = sorted( + {item.get("utility", "") for item in items if item.get("utility")} + ) + return utilities + + async def list_rate_plans( + self, + zip_code: str, + *, + utility: str | None = None, + ) -> list[dict[str, Any]]: + """List rate plans, optionally filtered by utility. + + Args: + zip_code: US zip code to search + utility: Filter by utility name (case-insensitive substring match) + + Returns: + List of rate plan dictionaries with keys: name, utility, label, + eiaid, approved, has_tou_schedule + """ + items = await self.fetch_rates(zip_code) + plans: list[dict[str, Any]] = [] + + for item in items: + if ( + utility + and utility.lower() not in item.get("utility", "").lower() + ): + continue + plans.append( + { + "name": item.get("name", ""), + "utility": item.get("utility", ""), + "label": item.get("label", ""), + "eiaid": item.get("eiaid"), + "approved": item.get("approved", False), + "has_tou_schedule": "energyweekdayschedule" in item, + "description": item.get("description", ""), + } + ) + return plans + + async def get_rate_plan( + self, + zip_code: str, + plan_name: str, + *, + utility: str | None = None, + ) -> dict[str, Any] | None: + """Get a specific rate plan by name. + + Returns the first matching plan. Use ``utility`` to disambiguate + if multiple utilities serve the same zip code. + + Args: + zip_code: US zip code to search + plan_name: Rate plan name (case-insensitive substring match) + utility: Filter by utility name (case-insensitive substring match) + + Returns: + Full rate plan dictionary or None if not found + """ + items = await self.fetch_rates(zip_code) + for item in items: + if ( + utility + and utility.lower() not in item.get("utility", "").lower() + ): + continue + if plan_name.lower() in item.get("name", "").lower(): + return item + return None diff --git a/tests/test_openei.py b/tests/test_openei.py new file mode 100644 index 0000000..c68a2e4 --- /dev/null +++ b/tests/test_openei.py @@ -0,0 +1,219 @@ +"""Tests for OpenEI client module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nwp500.openei import OpenEIClient + +# Sample OpenEI API response items (realistic data from HAR captures) +SAMPLE_OPENEI_ITEMS = [ + { + "approved": True, + "utility": "Pacific Gas & Electric Co", + "eiaid": 14328, + "name": "E-1 -Residential Service Baseline Region Y", + "label": "67575942fe4f0b50f5027994", + "sector": "Residential", + "description": "Residential service baseline", + "energyratestructure": [[{"rate": 0.40206, "unit": "kWh"}]], + "energyweekdayschedule": [[1] * 24] * 12, + }, + { + "approved": True, + "utility": "Pacific Gas & Electric Co", + "eiaid": 14328, + "name": "Electric Vehicle EV (Sch) Rate A", + "label": "67576350c51426c5b80fdae5", + "sector": "Residential", + "description": "EV charging rate", + "energyratestructure": [ + [{"rate": 0.34761, "unit": "kWh"}], + [{"rate": 0.46016, "unit": "kWh"}], + ], + "energyweekdayschedule": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 1, + 1, + 0, + ] + ] + * 12, + }, + { + "approved": True, + "utility": "SoCal Edison", + "eiaid": 99999, + "name": "TOU-D Residential", + "label": "abc123", + "sector": "Residential", + "description": "SoCal TOU", + }, +] + + +def _make_mock_response(items: list) -> MagicMock: + """Create a mock aiohttp response.""" + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json = AsyncMock(return_value={"items": items}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + return mock_resp + + +@pytest.mark.asyncio +async def test_fetch_rates() -> None: + """Test fetching raw rate plan data.""" + mock_resp = _make_mock_response(SAMPLE_OPENEI_ITEMS) + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + mock_session.close = AsyncMock() + + client = OpenEIClient(api_key="test-key", session=mock_session) + async with client: + items = await client.fetch_rates("94903") + + assert len(items) == 3 + assert items[0]["utility"] == "Pacific Gas & Electric Co" + + +@pytest.mark.asyncio +async def test_list_utilities() -> None: + """Test listing unique utilities.""" + mock_resp = _make_mock_response(SAMPLE_OPENEI_ITEMS) + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + mock_session.close = AsyncMock() + + client = OpenEIClient(api_key="test-key", session=mock_session) + async with client: + utilities = await client.list_utilities("94903") + + assert utilities == [ + "Pacific Gas & Electric Co", + "SoCal Edison", + ] + + +@pytest.mark.asyncio +async def test_list_rate_plans_unfiltered() -> None: + """Test listing all rate plans.""" + mock_resp = _make_mock_response(SAMPLE_OPENEI_ITEMS) + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + mock_session.close = AsyncMock() + + client = OpenEIClient(api_key="test-key", session=mock_session) + async with client: + plans = await client.list_rate_plans("94903") + + assert len(plans) == 3 + assert plans[0]["name"] == "E-1 -Residential Service Baseline Region Y" + assert plans[1]["name"] == "Electric Vehicle EV (Sch) Rate A" + assert plans[1]["has_tou_schedule"] is True + + +@pytest.mark.asyncio +async def test_list_rate_plans_filtered_by_utility() -> None: + """Test filtering rate plans by utility.""" + mock_resp = _make_mock_response(SAMPLE_OPENEI_ITEMS) + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + mock_session.close = AsyncMock() + + client = OpenEIClient(api_key="test-key", session=mock_session) + async with client: + plans = await client.list_rate_plans("94903", utility="SoCal") + + assert len(plans) == 1 + assert plans[0]["utility"] == "SoCal Edison" + + +@pytest.mark.asyncio +async def test_get_rate_plan_found() -> None: + """Test getting a specific rate plan by name.""" + mock_resp = _make_mock_response(SAMPLE_OPENEI_ITEMS) + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + mock_session.close = AsyncMock() + + client = OpenEIClient(api_key="test-key", session=mock_session) + async with client: + plan = await client.get_rate_plan("94903", "EV (Sch) Rate A") + + assert plan is not None + assert plan["name"] == "Electric Vehicle EV (Sch) Rate A" + + +@pytest.mark.asyncio +async def test_get_rate_plan_not_found() -> None: + """Test getting a non-existent rate plan.""" + mock_resp = _make_mock_response(SAMPLE_OPENEI_ITEMS) + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + mock_session.close = AsyncMock() + + client = OpenEIClient(api_key="test-key", session=mock_session) + async with client: + plan = await client.get_rate_plan("94903", "Nonexistent Plan") + + assert plan is None + + +@pytest.mark.asyncio +async def test_no_api_key_raises() -> None: + """Test that missing API key raises ValueError.""" + with patch.dict("os.environ", {}, clear=True): + client = OpenEIClient(api_key=None) + mock_session = MagicMock() + mock_session.close = AsyncMock() + client._session = mock_session + client._owned_session = False + + with pytest.raises(ValueError, match="OpenEI API key required"): + await client.fetch_rates("94903") + + +@pytest.mark.asyncio +async def test_env_var_api_key() -> None: + """Test API key from environment variable.""" + with patch.dict("os.environ", {"OPENEI_API_KEY": "env-key"}): + client = OpenEIClient() + assert client._api_key == "env-key" + + +@pytest.mark.asyncio +async def test_context_manager_creates_session() -> None: + """Test that context manager creates/closes session.""" + with patch("nwp500.openei.aiohttp.ClientSession") as mock_cls: + mock_session = MagicMock() + mock_session.close = AsyncMock() + mock_cls.return_value = mock_session + + async with OpenEIClient(api_key="test-key") as client: + assert client._session is mock_session + assert client._owned_session is True + + mock_session.close.assert_awaited_once() diff --git a/tests/test_tou_api.py b/tests/test_tou_api.py new file mode 100644 index 0000000..c35bb75 --- /dev/null +++ b/tests/test_tou_api.py @@ -0,0 +1,262 @@ +"""Tests for TOU API client methods (convert_tou, update_tou).""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nwp500.api_client import NavienAPIClient +from nwp500.models import ConvertedTOUPlan, TOUInfo + +# Realistic fixture data from HAR captures +SAMPLE_OPENEI_RATE_PLAN = { + "approved": True, + "utility": "Pacific Gas & Electric Co", + "eiaid": 14328, + "name": "Electric Vehicle EV (Sch) Rate A", + "label": "67576350c51426c5b80fdae5", + "sector": "Residential", + "energyratestructure": [ + [{"rate": 0.34761, "unit": "kWh"}], + [{"rate": 0.46016, "unit": "kWh"}], + ], + "energyweekdayschedule": [[0] * 24] * 12, +} + +SAMPLE_CONVERT_RESPONSE = { + "code": 200, + "msg": "SUCCESS", + "data": { + "touInfo": [ + { + "utility": "Pacific Gas & Electric Co", + "name": "Electric Vehicle EV (Sch) Rate A", + "schedule": [ + { + "season": 3087, + "interval": [ + { + "week": 124, + "startHour": 0, + "startMinute": 0, + "endHour": 6, + "endMinute": 59, + "priceMin": 31794, + "priceMax": 31794, + "decimalPoint": 5, + }, + { + "week": 124, + "startHour": 7, + "startMinute": 0, + "endHour": 13, + "endMinute": 59, + "priceMin": 38967, + "priceMax": 38967, + "decimalPoint": 5, + }, + ], + }, + ], + } + ] + }, +} + +SAMPLE_UPDATE_RESPONSE = { + "code": 200, + "msg": "SUCCESS", + "data": { + "sourceType": "openei", + "touInfo": { + "name": "Electric Vehicle EV (Sch) Rate A", + "schedule": [ + { + "season": 3087, + "interval": [ + { + "week": 124, + "startHour": 0, + "startMinute": 0, + "endHour": 6, + "endMinute": 59, + "priceMin": 31794, + "priceMax": 31794, + "decimalPoint": 5, + }, + ], + }, + ], + "utility": "Pacific Gas & Electric Co", + "zipCode": 94903, + }, + }, +} + + +def _make_api_client() -> tuple[NavienAPIClient, AsyncMock]: + """Create a NavienAPIClient with mocked auth and request.""" + mock_auth = MagicMock() + mock_auth.is_authenticated = True + mock_auth.user_email = "test@example.com" + mock_auth.session = MagicMock() + + client = NavienAPIClient(auth_client=mock_auth) + mock_request = AsyncMock() + client._make_request = mock_request + return client, mock_request + + +@pytest.mark.asyncio +async def test_convert_tou() -> None: + """Test POST /device/tou/convert.""" + client, mock_request = _make_api_client() + mock_request.return_value = SAMPLE_CONVERT_RESPONSE + + plans = await client.convert_tou([SAMPLE_OPENEI_RATE_PLAN]) + + assert len(plans) == 1 + assert isinstance(plans[0], ConvertedTOUPlan) + assert plans[0].name == "Electric Vehicle EV (Sch) Rate A" + assert plans[0].utility == "Pacific Gas & Electric Co" + assert len(plans[0].schedule) == 1 + assert plans[0].schedule[0].season == 3087 + assert len(plans[0].schedule[0].intervals) == 2 + + mock_request.assert_awaited_once_with( + "POST", + "/device/tou/convert", + json_data={ + "sourceData": [SAMPLE_OPENEI_RATE_PLAN], + "sourceType": "openei", + "sourceVersion": 7, + "userId": "test@example.com", + "userType": "O", + }, + ) + + +@pytest.mark.asyncio +async def test_convert_tou_empty_response() -> None: + """Test convert_tou with empty response.""" + client, mock_request = _make_api_client() + mock_request.return_value = { + "code": 200, + "msg": "SUCCESS", + "data": {"touInfo": []}, + } + + plans = await client.convert_tou([]) + assert plans == [] + + +@pytest.mark.asyncio +async def test_update_tou() -> None: + """Test PUT /device/tou.""" + client, mock_request = _make_api_client() + mock_request.return_value = SAMPLE_UPDATE_RESPONSE + + tou_info_dict = { + "name": "Electric Vehicle EV (Sch) Rate A", + "schedule": [{"season": 3087, "interval": []}], + "utility": "Pacific Gas & Electric Co", + "zipCode": "94903", + } + + result = await client.update_tou( + mac_address="04786332fca0", + additional_value="5322", + tou_info=tou_info_dict, + source_data=SAMPLE_OPENEI_RATE_PLAN, + zip_code="94903", + ) + + assert isinstance(result, TOUInfo) + assert result.name == "Electric Vehicle EV (Sch) Rate A" + assert result.utility == "Pacific Gas & Electric Co" + assert result.zip_code == 94903 + assert result.source_type == "openei" + + mock_request.assert_awaited_once_with( + "PUT", + "/device/tou", + json_data={ + "additionalValue": "5322", + "macAddress": "04786332fca0", + "registerPath": "wifi", + "sourceData": SAMPLE_OPENEI_RATE_PLAN, + "sourceType": "openei", + "touInfo": tou_info_dict, + "userId": "test@example.com", + "userType": "O", + "zipCode": "94903", + }, + ) + + +@pytest.mark.asyncio +async def test_convert_tou_unauthenticated() -> None: + """Test convert_tou raises when not authenticated.""" + mock_auth = MagicMock() + mock_auth.is_authenticated = True + mock_auth.user_email = None + mock_auth.session = MagicMock() + + client = NavienAPIClient(auth_client=mock_auth) + + from nwp500.exceptions import AuthenticationError + + with pytest.raises(AuthenticationError): + await client.convert_tou([SAMPLE_OPENEI_RATE_PLAN]) + + +@pytest.mark.asyncio +async def test_update_tou_unauthenticated() -> None: + """Test update_tou raises when not authenticated.""" + mock_auth = MagicMock() + mock_auth.is_authenticated = True + mock_auth.user_email = None + mock_auth.session = MagicMock() + + client = NavienAPIClient(auth_client=mock_auth) + + from nwp500.exceptions import AuthenticationError + + with pytest.raises(AuthenticationError): + await client.update_tou( + mac_address="aa:bb:cc", + additional_value="1234", + tou_info={}, + source_data={}, + zip_code="94903", + ) + + +def test_converted_tou_plan_model() -> None: + """Test ConvertedTOUPlan model validation.""" + data = { + "utility": "Pacific Gas & Electric Co", + "name": "EV Rate A", + "schedule": [ + { + "season": 3087, + "interval": [ + { + "week": 124, + "startHour": 0, + "startMinute": 0, + "endHour": 6, + "endMinute": 59, + "priceMin": 31794, + "priceMax": 31794, + "decimalPoint": 5, + } + ], + } + ], + } + plan = ConvertedTOUPlan.model_validate(data) + assert plan.utility == "Pacific Gas & Electric Co" + assert plan.name == "EV Rate A" + assert len(plan.schedule) == 1 + assert plan.schedule[0].season == 3087 + assert len(plan.schedule[0].intervals) == 1