diff --git a/README.rst b/README.rst index 1f02b62..81440da 100644 --- a/README.rst +++ b/README.rst @@ -59,6 +59,46 @@ Basic Usage # Change operation mode await api_client.set_device_mode(device, "heat_pump") +Command Line Interface +====================== + +The library includes a command line interface for quick monitoring and device information retrieval: + +.. code-block:: bash + + # Set credentials via environment variables + export NAVIEN_EMAIL="your_email@example.com" + export NAVIEN_PASSWORD="your_password" + + # Get current device status (one-time) + python -m nwp500.cli --status + + # Get device information + python -m nwp500.cli --device-info + + # Get device feature/capability information + python -m nwp500.cli --device-feature + + # Set operation mode and see response + python -m nwp500.cli --set-mode energy-saver + + # Monitor continuously (default - writes to CSV) + python -m nwp500.cli --monitor + + # Monitor with custom output file + python -m nwp500.cli --monitor --output my_data.csv + +**Available CLI Options:** + +* ``--status``: Print current device status as JSON and exit +* ``--device-info``: Print comprehensive device information (firmware, model, capabilities) via MQTT as JSON and exit +* ``--device-feature``: Print device capabilities and feature settings via MQTT as JSON and exit +* ``--set-mode MODE``: Set operation mode and display response. Valid modes: heat-pump, energy-saver, high-demand, electric, vacation, standby +* ``--monitor``: Continuously monitor status every 30 seconds and log to CSV (default) +* ``-o, --output``: Specify CSV output filename for monitoring mode +* ``--email``: Override email (alternative to environment variable) +* ``--password``: Override password (alternative to environment variable) + Device Status Fields ==================== @@ -145,12 +185,14 @@ Documentation Comprehensive documentation is available in the ``docs/`` directory: * `Device Status Fields`_ - Complete field reference with units and conversions +* `Device Feature Fields`_ - Device capabilities and firmware information reference * `MQTT Messages`_ - MQTT protocol documentation * `MQTT Client`_ - MQTT client usage guide * `Authentication`_ - Authentication module documentation .. _MQTT Protocol Documentation: docs/MQTT_MESSAGES.rst .. _Device Status Fields: docs/DEVICE_STATUS_FIELDS.rst +.. _Device Feature Fields: docs/DEVICE_FEATURE_FIELDS.rst .. _MQTT Messages: docs/MQTT_MESSAGES.rst .. _MQTT Client: docs/MQTT_CLIENT.rst .. _Authentication: docs/AUTHENTICATION.rst @@ -161,6 +203,7 @@ Data Models The library includes type-safe data models with automatic unit conversions: * **DeviceStatus**: Complete device status with 70+ fields +* **DeviceFeature**: Device capabilities, firmware versions, and configuration limits * **OperationMode**: Enumeration of available operation modes * **TemperatureUnit**: Celsius/Fahrenheit handling * **MqttRequest/MqttCommand**: MQTT message structures diff --git a/docs/DEVICE_FEATURE_FIELDS.rst b/docs/DEVICE_FEATURE_FIELDS.rst new file mode 100644 index 0000000..ca82b8f --- /dev/null +++ b/docs/DEVICE_FEATURE_FIELDS.rst @@ -0,0 +1,366 @@ +Device Feature Fields +===================== + +This document lists the fields found in the ``DeviceFeature`` object returned by MQTT device info requests. + +The DeviceFeature data contains comprehensive device capabilities, configuration, and firmware information received via MQTT when calling ``request_device_info()``. This data is much more detailed than the basic device information available through the REST API and corresponds to the actual device specifications and capabilities as documented in the official Navien NWP500 Installation and User manuals. + +.. list-table:: + :header-rows: 1 + :widths: 15 8 8 49 20 + + * - Field Name + - Type + - Units + - Description + - Conversion Formula + * - ``countryCode`` + - int + - None + - Country/region code where device is certified for operation (1=USA, complies with FCC Part 15 Class B, NSF/ANSI 372) + - None + * - ``modelTypeCode`` + - int + - None + - Model type identifier: NWP500 series electric heat pump water heater model variant + - None + * - ``controlTypeCode`` + - int + - None + - Control system type: Advanced digital control with LCD display and WiFi connectivity + - None + * - ``volumeCode`` + - int + - Gallons + - Tank nominal capacity: 50, 65, or 80 gallons (NWP500-50/65/80 models) + - None + * - ``controllerSwVersion`` + - int + - None + - Main controller firmware version - controls heat pump, heating elements, and system logic + - None + * - ``panelSwVersion`` + - int + - None + - Front panel display firmware version - manages LCD display and user interface + - None + * - ``wifiSwVersion`` + - int + - None + - WiFi module firmware version - handles NaviLink app connectivity and cloud communication + - None + * - ``controllerSwCode`` + - int + - None + - Controller firmware variant/branch identifier for support and compatibility + - None + * - ``panelSwCode`` + - int + - None + - Panel firmware variant/branch identifier for display features and UI capabilities + - None + * - ``wifiSwCode`` + - int + - None + - WiFi firmware variant/branch identifier for communication protocol version + - None + * - ``controllerSerialNumber`` + - str + - None + - Unique serial number of the main controller board for warranty and service identification + - None + * - ``powerUse`` + - int + - Boolean + - Power control capability (1=supported) - can be turned on/off via controls (always 1 for NWP500) + - None + * - ``holidayUse`` + - int + - Boolean + - Vacation mode support (1=supported) - energy-saving mode for 0-99 days with minimal operations + - None + * - ``programReservationUse`` + - int + - Boolean + - Scheduled operation support (1=supported) - programmable heating schedules and timers + - None + * - ``dhwUse`` + - int + - Boolean + - Domestic hot water functionality (1=available) - primary function of water heater (always 1) + - None + * - ``dhwTemperatureSettingUse`` + - int + - Boolean + - Temperature adjustment capability (1=supported) - user can modify target temperature + - None + * - ``dhwTemperatureMin`` + - int + - °F + - Minimum DHW temperature setting: 95°F (35°C) - safety and efficiency lower limit + - ``raw + 20`` + * - ``dhwTemperatureMax`` + - int + - °F + - Maximum DHW temperature setting: 150°F (65.5°C) - scald protection upper limit + - ``raw + 20`` + * - ``smartDiagnosticUse`` + - int + - Boolean + - Self-diagnostic capability (1=available) - 10-minute startup diagnostic, error code system + - None + * - ``wifiRssiUse`` + - int + - Boolean + - WiFi signal monitoring (1=supported) - reports signal strength in dBm for connectivity diagnostics + - None + * - ``temperatureType`` + - TemperatureUnit + - Enum + - Default temperature unit preference (CELSIUS=1, FAHRENHEIT=2) - factory set to Fahrenheit for USA + - Enum + * - ``tempFormulaType`` + - int + - None + - Temperature calculation method identifier for internal sensor calibration and conversions + - None + * - ``energyUsageUse`` + - int + - Boolean + - Energy monitoring support (1=available) - tracks kWh consumption for heat pump and electric elements + - None + * - ``freezeProtectionUse`` + - int + - Boolean + - Freeze protection capability (1=available) - automatic heating when tank drops below threshold + - None + * - ``freezeProtectionTempMin`` + - int + - °F + - Minimum freeze protection threshold: 43°F (6°C) - factory default activation temperature + - ``raw + 20`` + * - ``freezeProtectionTempMax`` + - int + - °F + - Maximum freeze protection threshold: typically 65°F - user-adjustable upper limit + - ``raw + 20`` + * - ``mixingValueUse`` + - int + - Boolean + - Thermostatic mixing valve support (1=available) - for temperature limiting at point of use + - None + * - ``drSettingUse`` + - int + - Boolean + - Demand Response support (1=available) - CTA-2045 compliance for utility load management + - None + * - ``antiLegionellaSettingUse`` + - int + - Boolean + - Anti-Legionella function (1=available) - periodic heating to 140°F (60°C) to prevent bacteria + - None + * - ``hpwhUse`` + - int + - Boolean + - Heat Pump Water Heater mode (1=supported) - primary efficient heating method using refrigeration cycle + - None + * - ``dhwRefillUse`` + - int + - Boolean + - Tank refill detection (1=supported) - monitors for "dry fire" conditions during refill + - None + * - ``ecoUse`` + - int + - Boolean + - ECO safety switch (1=available) - Energy Cut Off high-temperature limit protection + - None + * - ``electricUse`` + - int + - Boolean + - Electric-only mode (1=supported) - heating element only operation for maximum recovery speed + - None + * - ``heatpumpUse`` + - int + - Boolean + - Heat pump only mode (1=supported) - most efficient operation using only refrigeration cycle + - None + * - ``energySaverUse`` + - int + - Boolean + - Energy Saver mode (1=supported) - hybrid efficiency mode balancing speed and efficiency (default) + - None + * - ``highDemandUse`` + - int + - Boolean + - High Demand mode (1=supported) - hybrid boost mode prioritizing fast recovery over efficiency + - None + +Operation Mode Support Matrix +----------------------------- + +The NWP500 supports five primary operation modes as indicated by the capability flags: + +.. list-table:: + :header-rows: 1 + :widths: 15 15 15 55 + + * - Mode ID + - Mode Name + - Capability Flag + - Description & Performance Characteristics + * - 1 + - Heat Pump Only + - ``heatpumpUse`` + - **Most Efficient** - Uses only the heat pump compressor and evaporator. Longest recovery time but highest energy efficiency. Performance varies with ambient temperature and humidity. + * - 2 + - Energy Saver (Default) + - ``energySaverUse`` + - **Balanced Efficiency** - Hybrid mode combining heat pump with backup electric elements. Factory default setting balances efficiency with reasonable recovery time. + * - 3 + - High Demand + - ``highDemandUse`` + - **Fastest Recovery** - Hybrid mode prioritizing speed over efficiency. Uses heat pump plus more frequent electric element operation for maximum hot water supply. + * - 4 + - Electric Only + - ``electricUse`` + - **Emergency/Service Mode** - Uses only 3,755W heating elements (upper and lower, not simultaneously). Least efficient but operates in all conditions. Auto-reverts after 72 hours. + * - 5 + - Vacation + - ``holidayUse`` + - **Maximum Energy Savings** - Suspends normal heating for 0-99 days. Only freeze protection and anti-seize operations continue. Heating resumes 9 hours before vacation end. + +Hardware Specifications from Manual Cross-Reference +--------------------------------------------------- + +The device feature data corresponds to these official NWP500 specifications: + +**Electrical System** + * Input: 208-240V AC, 60Hz, 1-Phase + * Current Draw: 208V (25.9A) / 240V (28.8A) + * Circuit Protection: 30A breaker required + * Heating Elements: 3,755W @ 208V or 5,000W @ 240V (upper and lower) + * Heat Pump Compressor: 11.6A + * Evaporator Fan: 0.22A + +**Physical Models** + * NWP500-50: 50 gallon, Ø21.7" × 63" (229 lbs) + * NWP500-65: 65 gallon, Ø25" × 63" (265 lbs) + * NWP500-80: 80 gallon, Ø25" × 71.6" (282 lbs) + +**Safety & Compliance Features** + * FCC ID: P53-EMC3290 (Class B digital device) + * IC: 23507-EMC3290 (Industry Canada RSS-210) + * NSF/ANSI 372 certified (lead-free wetted surfaces <0.25%) + * Temperature & Pressure relief valve (150 psi) + * ECO (Energy Cut Off) high-limit safety switch + +**Smart Features & Connectivity** + * NaviLink WiFi app connectivity + * Self-diagnostic system with error codes + * CTA-2045 Demand Response module support + * Anti-Legionella periodic disinfection (1-30 day intervals) + * Programmable operation schedules + +Firmware Version Interpretation +------------------------------- + +The device returns three separate firmware components for comprehensive system identification: + +**Main Controller (``controllerSwVersion``, ``controllerSwCode``)** + * Manages heat pump compressor, heating elements, temperature sensors + * Controls operation mode logic and safety interlocks + * Handles diagnostic routines and error detection + * Serial number provided for warranty tracking + +**Display Panel (``panelSwVersion``, ``panelSwCode``)** + * User interface and LCD display management + * Button input processing and menu navigation + * Status indicator control and user feedback + +**WiFi Module (``wifiSwVersion``, ``wifiSwCode``)** + * NaviLink cloud connectivity and app communication + * Wireless network management and security + * Remote monitoring and control capabilities + +Temperature Range Validation +---------------------------- + +The reported temperature ranges align with official specifications and use the same conversion patterns as DeviceStatus fields: + +* **DHW Range**: 95°F to 150°F (factory default: 120°F for safety) - uses ``raw + 20`` conversion +* **Freeze Protection**: Activates at 43°F, prevents tank freezing - uses ``raw + 20`` conversion +* **Anti-Legionella**: Heats to 140°F at programmed intervals (requires mixing valve) +* **Scald Protection**: Built-in limits with recommendation for thermostatic mixing valves + +**Conversion Pattern Consistency**: Temperature fields in DeviceFeature use the same ``raw + 20`` +conversion formula as corresponding fields in DeviceStatus, ensuring consistent temperature +handling across all device data structures. + +Usage Example +------------- + +.. code-block:: python + + import asyncio + from nwp500 import NavienAuthClient, NavienMqttClient, NavienAPIClient + + async def analyze_device_capabilities(): + async with NavienAuthClient("email@example.com", "password") as auth_client: + # Get device list + api_client = NavienAPIClient(auth_client) + devices = await api_client.list_devices() + device = devices[0] + + # Connect MQTT and request device features + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + + # Set up callback to analyze device capabilities + def analyze_features(feature): + print(f"=== Device Capability Analysis ===") + print(f"Model: NWP500-{feature.volumeCode} ({feature.volumeCode} gallon)") + print(f"Controller FW: v{feature.controllerSwVersion} (Code: {feature.controllerSwCode})") + print(f"Panel FW: v{feature.panelSwVersion} (Code: {feature.panelSwCode})") + print(f"WiFi FW: v{feature.wifiSwVersion} (Code: {feature.wifiSwCode})") + print(f"Serial: {feature.controllerSerialNumber}") + + print(f"\n=== Temperature Capabilities ===") + print(f"DHW Range: {feature.dhwTemperatureMin}°F - {feature.dhwTemperatureMax}°F") + print(f"Freeze Protection: {feature.freezeProtectionTempMin}°F - {feature.freezeProtectionTempMax}°F") + print(f"Default Unit: {feature.temperatureType.name}") + + print(f"\n=== Supported Operation Modes ===") + modes = [] + if feature.heatpumpUse: modes.append("Heat Pump Only") + if feature.energySaverUse: modes.append("Energy Saver (Default)") + if feature.highDemandUse: modes.append("High Demand") + if feature.electricUse: modes.append("Electric Only") + if feature.holidayUse: modes.append("Vacation Mode") + print(f"Available: {', '.join(modes)}") + + print(f"\n=== Smart Features ===") + features = [] + if feature.smartDiagnosticUse: features.append("Self-Diagnostics") + if feature.wifiRssiUse: features.append("WiFi Monitoring") + if feature.energyUsageUse: features.append("Energy Tracking") + if feature.antiLegionellaSettingUse: features.append("Anti-Legionella") + if feature.drSettingUse: features.append("Demand Response") + if feature.mixingValueUse: features.append("Mixing Valve Support") + print(f"Available: {', '.join(features)}") + + await mqtt_client.subscribe_device_feature(device, analyze_features) + await mqtt_client.request_device_info(device) + + # Wait for response + await asyncio.sleep(5) + await mqtt_client.disconnect() + + asyncio.run(analyze_device_capabilities()) + +See Also +-------- + +* :doc:`DEVICE_STATUS_FIELDS` - Real-time device status field reference +* :doc:`MQTT_CLIENT` - MQTT client usage guide for device communication +* :doc:`API_CLIENT` - REST API client for device management +* :doc:`ERROR_CODES` - Complete error code reference for diagnostics \ No newline at end of file diff --git a/docs/DEVICE_STATUS_FIELDS.rst b/docs/DEVICE_STATUS_FIELDS.rst index 3d7ad51..20a15bb 100644 --- a/docs/DEVICE_STATUS_FIELDS.rst +++ b/docs/DEVICE_STATUS_FIELDS.rst @@ -117,12 +117,12 @@ This document lists the fields found in the ``status`` object of device status m - integer - °F - Temperature of the upper part of the tank. - - ``raw + 20`` + - ``(raw / 10) * 9/5 + 32`` (decicelsius to Fahrenheit) * - ``tankLowerTemperature`` - integer - °F - Temperature of the lower part of the tank. - - ``raw + 20`` + - ``(raw / 10) * 9/5 + 32`` (decicelsius to Fahrenheit) * - ``dischargeTemperature`` - integer - °F @@ -142,7 +142,7 @@ This document lists the fields found in the ``status`` object of device status m - integer - °F - Ambient air temperature measured at the heat pump air intake. - - ``(raw * 9/5) + 32`` + - ``(raw / 22.4) * 9/5 + 32`` * - ``targetSuperHeat`` - integer - °F diff --git a/docs/index.rst b/docs/index.rst index 137b98d..8c86397 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -322,6 +322,7 @@ Documentation API Reference (OpenAPI) Device Status Fields + Device Feature Fields Error Codes MQTT Messages diff --git a/examples/set_mode_example.py b/examples/set_mode_example.py new file mode 100644 index 0000000..3cec4ff --- /dev/null +++ b/examples/set_mode_example.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Example: Setting operation mode via MQTT and displaying response. + +This demonstrates how to programmatically change the water heater operation mode +and receive confirmation of the change. +""" + +import asyncio +import logging +from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + +# Set up logging to see the mode change process +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def set_mode_example(): + """Example of setting operation mode programmatically.""" + + # Use environment variables or replace with your credentials + email = "your_email@example.com" + password = "your_password" + + async with NavienAuthClient(email, password) as auth_client: + # Get device information + api_client = NavienAPIClient(auth_client) + devices = await api_client.list_devices() + + if not devices: + logger.error("No devices found") + return + + device = devices[0] + logger.info(f"Found device: {device.device_info.device_name}") + + # Connect MQTT client + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + logger.info("MQTT client connected") + + try: + # Get current status first + logger.info("Getting current device status...") + current_status = None + + def on_current_status(status): + nonlocal current_status + current_status = status + logger.info(f"Current mode: {status.operationMode.name}") + + await mqtt_client.subscribe_device_status(device, on_current_status) + await mqtt_client.request_device_status(device) + await asyncio.sleep(3) # Wait for current status + + # Change to Energy Saver mode + logger.info("Changing to Energy Saver mode...") + + # Set up callback to capture mode change response + mode_changed = False + + def on_mode_change_response(status): + nonlocal mode_changed + logger.info("Mode change response received!") + logger.info(f"New mode: {status.operationMode.name}") + logger.info(f"DHW Temperature: {status.dhwTemperature}°F") + logger.info(f"Tank Charge: {status.dhwChargePer}%") + mode_changed = True + + await mqtt_client.subscribe_device_status(device, on_mode_change_response) + + # Send mode change command (3 = Energy Saver, per MQTT protocol) + await mqtt_client.set_dhw_mode(device, 3) + + # Wait for confirmation + for i in range(15): # Wait up to 15 seconds + if mode_changed: + logger.info("Mode change confirmed!") + break + await asyncio.sleep(1) + else: + logger.warning("Timeout waiting for mode change confirmation") + + finally: + await mqtt_client.disconnect() + logger.info("Disconnected from MQTT") + + +if __name__ == "__main__": + print("=== Operation Mode Change Example ===") + print("This example demonstrates:") + print("1. Connecting to device via MQTT") + print("2. Getting current operation mode") + print("3. Changing to Energy Saver mode") + print("4. Receiving and displaying the response") + print() + + # Note: This requires valid credentials + print( + "Note: Update email/password or set NAVIEN_EMAIL/NAVIEN_PASSWORD environment variables" + ) + print() + + # Uncomment to run (requires valid credentials) + # asyncio.run(set_mode_example()) + + print("CLI equivalent commands:") + print(" python -m nwp500.cli --set-mode energy-saver") + print(" python -m nwp500.cli --set-mode heat-pump") + print(" python -m nwp500.cli --set-mode electric") + print(" python -m nwp500.cli --set-mode high-demand") + print(" python -m nwp500.cli --set-mode vacation") + print(" python -m nwp500.cli --set-mode standby") diff --git a/setup.cfg b/setup.cfg index c1693d2..513f16d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -86,8 +86,8 @@ dev = # console_scripts = # script_name = nwp500.module:function # For example: -# console_scripts = -# fibonacci = nwp500.skeleton:run +console_scripts = + nwp-cli = nwp500.cli:run # And any other entry points, for example: # pyscaffold.cli = # awesome = pyscaffoldext.awesome.extension:AwesomeExtension diff --git a/src/nwp500/cli.py b/src/nwp500/cli.py new file mode 100644 index 0000000..d647c59 --- /dev/null +++ b/src/nwp500/cli.py @@ -0,0 +1,499 @@ +""" +Navien Water Heater Control Script + +This script provides a command-line interface to monitor and control +Navien water heaters using the nwp500-python library. +""" + +import argparse +import asyncio +import csv +import json +import logging +import os +import sys +from dataclasses import asdict +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional + +from nwp500 import ( + Device, + DeviceStatus, + NavienAPIClient, + NavienAuthClient, + NavienMqttClient, + __version__, +) +from nwp500.auth import AuthTokens, InvalidCredentialsError + +__author__ = "Emmanuel Levijarvi" +__copyright__ = "Emmanuel Levijarvi" +__license__ = "MIT" + +_logger = logging.getLogger(__name__) +TOKEN_FILE = Path.home() / ".nwp500_tokens.json" + + +# ---- Token Management ---- +def save_tokens(tokens: AuthTokens, email: str): + """Saves authentication tokens and user email to a file.""" + try: + with open(TOKEN_FILE, "w") as f: + json.dump( + { + "email": email, + "id_token": tokens.id_token, + "access_token": tokens.access_token, + "refresh_token": tokens.refresh_token, + "authentication_expires_in": tokens.authentication_expires_in, + "issued_at": tokens.issued_at.isoformat(), + # AWS Credentials + "access_key_id": tokens.access_key_id, + "secret_key": tokens.secret_key, + "session_token": tokens.session_token, + "authorization_expires_in": tokens.authorization_expires_in, + }, + f, + ) + _logger.info(f"Tokens saved to {TOKEN_FILE}") + except OSError as e: + _logger.error(f"Failed to save tokens: {e}") + + +def load_tokens() -> tuple[Optional[AuthTokens], Optional[str]]: + """Loads authentication tokens and user email from a file.""" + if not TOKEN_FILE.exists(): + return None, None + try: + with open(TOKEN_FILE) as f: + data = json.load(f) + email = data["email"] + # Reconstruct the AuthTokens object + tokens = AuthTokens( + id_token=data["id_token"], + access_token=data["access_token"], + refresh_token=data["refresh_token"], + authentication_expires_in=data["authentication_expires_in"], + # AWS Credentials (use .get for backward compatibility) + access_key_id=data.get("access_key_id"), + secret_key=data.get("secret_key"), + session_token=data.get("session_token"), + authorization_expires_in=data.get("authorization_expires_in"), + ) + # Manually set the issued_at from the stored ISO format string + tokens.issued_at = datetime.fromisoformat(data["issued_at"]) + _logger.info(f"Tokens loaded from {TOKEN_FILE} for user {email}") + return tokens, email + except (OSError, json.JSONDecodeError, KeyError) as e: + _logger.error(f"Failed to load or parse tokens, will re-authenticate: {e}") + return None, None + + +# ---- CSV Writing ---- +def write_status_to_csv(file_path: str, status: DeviceStatus): + """Appends a device status message to a CSV file.""" + try: + # Convert the entire dataclass to a dictionary to capture all fields + status_dict = asdict(status) + + # Add a timestamp to the beginning of the data + data_to_write = {"timestamp": datetime.now().isoformat()} + + # Convert any Enum objects to their string names for CSV compatibility + for key, value in status_dict.items(): + if isinstance(value, Enum): + data_to_write[key] = value.name + else: + data_to_write[key] = value + + # Dynamically get the fieldnames from the dictionary keys + fieldnames = list(data_to_write.keys()) + + file_exists = os.path.exists(file_path) + with open(file_path, "a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + # Write header only if the file is new + if not file_exists or os.path.getsize(file_path) == 0: + writer.writeheader() + writer.writerow(data_to_write) + _logger.debug(f"Status written to {file_path}") + except (OSError, csv.Error) as e: + _logger.error(f"Failed to write to CSV file {file_path}: {e}") + + +# ---- Main Application Logic ---- +async def get_authenticated_client( + args: argparse.Namespace, +) -> Optional[NavienAuthClient]: + """Handles authentication flow.""" + tokens, email = load_tokens() + + # Use cached tokens only if they are valid, unexpired, and contain AWS credentials + if ( + tokens + and email + and not tokens.is_expired + and tokens.access_key_id + and tokens.secret_key + and tokens.session_token + ): + _logger.info("Using valid cached tokens.") + # The password argument is unused when cached tokens are present. + auth_client = NavienAuthClient(email, "cached_auth") + auth_client._user_email = email + await auth_client._ensure_session() + + # Manually construct the auth response since we are not signing in + from nwp500.auth import AuthenticationResponse, UserInfo + + auth_client._auth_response = AuthenticationResponse( + user_info=UserInfo.from_dict({}), tokens=tokens + ) + return auth_client + + _logger.info("Cached tokens are invalid, expired, or incomplete. Re-authenticating...") + # Fallback to email/password + email = args.email or os.getenv("NAVIEN_EMAIL") + password = args.password or os.getenv("NAVIEN_PASSWORD") + + if not email or not password: + _logger.error( + "Credentials not found. Please provide --email and --password, " + "or set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables." + ) + return None + + try: + auth_client = NavienAuthClient(email, password) + await auth_client.sign_in(email, password) + if auth_client.current_tokens and auth_client.user_email: + save_tokens(auth_client.current_tokens, auth_client.user_email) + return auth_client + except InvalidCredentialsError: + _logger.error("Invalid email or password.") + return None + except Exception as e: + _logger.error(f"An unexpected error occurred during authentication: {e}") + return None + + +def _json_default_serializer(o: object) -> str: + """JSON serializer for objects not serializable by default json code.""" + if isinstance(o, Enum): + return o.name + raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable") + + +async def handle_status_request(mqtt: NavienMqttClient, device: Device): + """Request device status once and print it.""" + future = asyncio.get_running_loop().create_future() + + def on_status(status: DeviceStatus): + if not future.done(): + print(json.dumps(asdict(status), indent=2, default=_json_default_serializer)) + future.set_result(None) + + await mqtt.subscribe_device_status(device, on_status) + _logger.info("Requesting device status...") + await mqtt.request_device_status(device) + + try: + await asyncio.wait_for(future, timeout=10) + except asyncio.TimeoutError: + _logger.error("Timed out waiting for device status response.") + + +async def handle_device_info_request(mqtt: NavienMqttClient, device: Device): + """ + Request comprehensive device information via MQTT and print it. + + This fetches detailed device information including firmware versions, + capabilities, temperature ranges, and feature availability - much more + comprehensive than basic API device data. + """ + future = asyncio.get_running_loop().create_future() + + def on_device_info(info): + if not future.done(): + print(json.dumps(asdict(info), indent=2, default=_json_default_serializer)) + future.set_result(None) + + await mqtt.subscribe_device_feature(device, on_device_info) + _logger.info("Requesting device information...") + await mqtt.request_device_info(device) + + try: + await asyncio.wait_for(future, timeout=10) + except asyncio.TimeoutError: + _logger.error("Timed out waiting for device info response.") + + +async def handle_device_feature_request(mqtt: NavienMqttClient, device: Device): + """Request device feature information once and print it.""" + future = asyncio.get_running_loop().create_future() + + def on_feature(feature): + if not future.done(): + print(json.dumps(asdict(feature), indent=2, default=_json_default_serializer)) + future.set_result(None) + + await mqtt.subscribe_device_feature(device, on_feature) + _logger.info("Requesting device feature information...") + await mqtt.request_device_feature(device) + + try: + await asyncio.wait_for(future, timeout=10) + except asyncio.TimeoutError: + _logger.error("Timed out waiting for device feature response.") + + +async def handle_set_mode_request(mqtt: NavienMqttClient, device: Device, mode_name: str): + """ + Set device operation mode and display the response. + + Args: + mqtt: MQTT client instance + device: Device to control + mode_name: Mode name (heat-pump, energy-saver, etc.) + """ + # Map mode names to mode IDs + # Based on MQTT client documentation in set_dhw_mode method: + # - 1: Heat Pump Only (most efficient, slowest recovery) + # - 2: Electric Only (least efficient, fastest recovery) + # - 3: Energy Saver (balanced, good default) + # - 4: High Demand (maximum heating capacity) + mode_mapping = { + "standby": 0, + "heat-pump": 1, # Heat Pump Only + "electric": 2, # Electric Only + "energy-saver": 3, # Energy Saver + "high-demand": 4, # High Demand + "vacation": 5, + } + + mode_name_lower = mode_name.lower() + if mode_name_lower not in mode_mapping: + valid_modes = ", ".join(mode_mapping.keys()) + _logger.error(f"Invalid mode '{mode_name}'. Valid modes: {valid_modes}") + return + + mode_id = mode_mapping[mode_name_lower] + + # Set up callback to capture status response after mode change + future = asyncio.get_running_loop().create_future() + responses = [] + + def on_status_response(status): + if not future.done(): + responses.append(status) + # Complete after receiving response + future.set_result(None) + + # Subscribe to status updates to see the mode change result + await mqtt.subscribe_device_status(device, on_status_response) + + try: + _logger.info(f"Setting operation mode to '{mode_name}' (mode ID: {mode_id})...") + + # Send the mode change command + await mqtt.set_dhw_mode(device, mode_id) + + # Wait for status response (mode change confirmation) + try: + await asyncio.wait_for(future, timeout=15) + + if responses: + status = responses[0] + print(json.dumps(asdict(status), indent=2, default=_json_default_serializer)) + _logger.info(f"Mode change successful. New mode: {status.operationMode.name}") + else: + _logger.warning("Mode command sent but no status response received") + + except asyncio.TimeoutError: + _logger.error("Timed out waiting for mode change confirmation") + + except Exception as e: + _logger.error(f"Error setting mode: {e}") + + +async def handle_monitoring(mqtt: NavienMqttClient, device: Device, output_file: str): + """Start periodic monitoring and write status to CSV.""" + _logger.info(f"Starting periodic monitoring. Writing updates to {output_file}") + _logger.info("Press Ctrl+C to stop.") + + def on_status_update(status: DeviceStatus): + _logger.info( + f"Received status update: Temp={status.dhwTemperature}°F, " + f"Power={'ON' if status.dhwUse else 'OFF'}" + ) + write_status_to_csv(output_file, status) + + await mqtt.subscribe_device_status(device, on_status_update) + await mqtt.start_periodic_device_status_requests(device, period_seconds=30) + await mqtt.request_device_status(device) # Get an initial status right away + + # Keep the script running indefinitely + await asyncio.Event().wait() + + +async def async_main(args: argparse.Namespace): + """Asynchronous main function.""" + auth_client = await get_authenticated_client(args) + if not auth_client: + return 1 # Authentication failed + + api_client = NavienAPIClient(auth_client=auth_client) + _logger.info("Fetching device information...") + device = await api_client.get_first_device() + + if not device: + _logger.error("No devices found for this account.") + await auth_client.close() + return 1 + + _logger.info(f"Found device: {device.device_info.device_name}") + + mqtt = NavienMqttClient(auth_client) + try: + await mqtt.connect() + _logger.info("MQTT client connected.") + + if args.status: + await handle_status_request(mqtt, device) + elif args.device_info: + await handle_device_info_request(mqtt, device) + elif args.device_feature: + await handle_device_feature_request(mqtt, device) + elif args.set_mode: + await handle_set_mode_request(mqtt, device, args.set_mode) + else: # Default to monitor + await handle_monitoring(mqtt, device, args.output) + + except asyncio.CancelledError: + _logger.info("Monitoring stopped by user.") + except Exception as e: + _logger.error(f"An unexpected error occurred: {e}", exc_info=True) + return 1 + finally: + _logger.info("Disconnecting MQTT client...") + await mqtt.disconnect() + await auth_client.close() + _logger.info("Cleanup complete.") + return 0 + + +# ---- CLI ---- +def parse_args(args): + """Parse command line parameters.""" + parser = argparse.ArgumentParser(description="Navien Water Heater Control Script") + parser.add_argument( + "--version", + action="version", + version=f"nwp500-python {__version__}", + ) + parser.add_argument( + "--email", + type=str, + help="Navien account email. Overrides NAVIEN_EMAIL env var.", + ) + parser.add_argument( + "--password", + type=str, + help="Navien account password. Overrides NAVIEN_PASSWORD env var.", + ) + + # Operation modes + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--status", + action="store_true", + help="Fetch and print the current device status once, then exit.", + ) + group.add_argument( + "--device-info", + action="store_true", + help="Fetch and print comprehensive device information via MQTT, then exit.", + ) + group.add_argument( + "--device-feature", + action="store_true", + help="Fetch and print device feature and capability information via MQTT, then exit.", + ) + group.add_argument( + "--set-mode", + type=str, + metavar="MODE", + help="Set operation mode and display response. " + "Options: heat-pump, electric, energy-saver, high-demand, vacation, standby", + ) + group.add_argument( + "--monitor", + action="store_true", + default=True, # Default action + help="Run indefinitely, polling for status every 30 seconds and logging to a CSV file. " + "(default)", + ) + parser.add_argument( + "-o", + "--output", + type=str, + default="nwp500_status.csv", + help="Output CSV file name for monitoring. (default: nwp500_status.csv)", + ) + + # Logging + parser.add_argument( + "-v", + "--verbose", + dest="loglevel", + help="Set loglevel to INFO", + action="store_const", + const=logging.INFO, + ) + parser.add_argument( + "-vv", + "--very-verbose", + dest="loglevel", + help="Set loglevel to DEBUG", + action="store_const", + const=logging.DEBUG, + ) + return parser.parse_args(args) + + +def setup_logging(loglevel): + """Setup basic logging.""" + logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" + logging.basicConfig( + level=loglevel or logging.WARNING, + stream=sys.stdout, + format=logformat, + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def main(args): + """Wrapper for the asynchronous main function.""" + args = parse_args(args) + # Set default log level for libraries + setup_logging(logging.WARNING) + # Set user-defined log level for this script + _logger.setLevel(args.loglevel or logging.INFO) + # aiohttp is very noisy at INFO level + logging.getLogger("aiohttp").setLevel(logging.WARNING) + + try: + asyncio.run(async_main(args)) + except KeyboardInterrupt: + _logger.info("Script interrupted by user.") + + +def run(): + """Calls main passing the CLI arguments extracted from sys.argv""" + main(sys.argv[1:]) + + +if __name__ == "__main__": + run() diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 655b031..8e38a2b 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -18,14 +18,22 @@ class OperationMode(Enum): The first set of modes (0-5) are used when commanding the device, while the second set (32, 64, 96) are observed in status messages. + + Command mode IDs (based on MQTT protocol): + - 0: Standby (device in idle state) + - 1: Heat Pump Only (most efficient, slowest recovery) + - 2: Electric Only (least efficient, fastest recovery) + - 3: Energy Saver (balanced, good default) + - 4: High Demand (maximum heating capacity) + - 5: Vacation mode """ - # Commanded modes + # Commanded modes (updated to match MQTT protocol) STANDBY = 0 - HEAT_PUMP = 1 - ENERGY_SAVER = 2 - HIGH_DEMAND = 3 - ELECTRIC = 4 + HEAT_PUMP = 1 # Heat Pump Only + ELECTRIC = 2 # Electric Only + ENERGY_SAVER = 3 # Energy Saver + HIGH_DEMAND = 4 # High Demand VACATION = 5 # Observed status modes @@ -33,8 +41,6 @@ class OperationMode(Enum): HYBRID_EFFICIENCY_MODE = 64 HYBRID_BOOST_MODE = 96 - # Aliases - class TemperatureUnit(Enum): """Enumeration for temperature units.""" @@ -329,8 +335,6 @@ def from_dict(cls, data: dict): "dhwTemperature", "dhwTemperatureSetting", "dhwTargetTemperatureSetting", - "tankUpperTemperature", - "tankLowerTemperature", "freezeProtectionTemperature", "dhwTemperature2", "hpUpperOnTempSetting", @@ -371,7 +375,20 @@ def from_dict(cls, data: dict): # Special conversion for ambientTemperature if "ambientTemperature" in converted_data: raw_temp = converted_data["ambientTemperature"] - converted_data["ambientTemperature"] = (raw_temp * 9 / 5) + 32 + # Based on observed data: raw 503.6 corresponds to 72.5°F + # Convert raw value to Celsius first (raw / 22.4 ≈ Celsius) + # Then convert Celsius to Fahrenheit + celsius = raw_temp / 22.4 + converted_data["ambientTemperature"] = (celsius * 9 / 5) + 32 + + # Special conversion for tank temperatures (decicelsius to Fahrenheit) + tank_temp_fields = ["tankUpperTemperature", "tankLowerTemperature"] + for field_name in tank_temp_fields: + if field_name in converted_data: + raw_temp = converted_data[field_name] + # Convert from decicelsius (raw / 10.0) to Celsius, then to Fahrenheit + celsius = raw_temp / 10.0 + converted_data[field_name] = (celsius * 9 / 5) + 32 # Convert enum fields with error handling for unknown values if "operationMode" in converted_data: @@ -452,11 +469,23 @@ def from_dict(cls, data: dict): """ Creates a DeviceFeature object from a raw dictionary. - Handles enum conversion for temperatureType field. + Handles enum conversion for temperatureType field and applies + temperature conversions using the same formulas as DeviceStatus. """ # Copy data to avoid modifying the original dictionary converted_data = data.copy() + # Convert temperature fields with 'raw + 20' formula (same as DeviceStatus) + temp_add_20_fields = [ + "dhwTemperatureMin", + "dhwTemperatureMax", + "freezeProtectionTempMin", + "freezeProtectionTempMax", + ] + for field_name in temp_add_20_fields: + if field_name in converted_data: + converted_data[field_name] += 20 + # Convert temperatureType to enum if "temperatureType" in converted_data: try: diff --git a/src/nwp500/skeleton.py b/src/nwp500/skeleton.py deleted file mode 100644 index d5ac192..0000000 --- a/src/nwp500/skeleton.py +++ /dev/null @@ -1,152 +0,0 @@ -""" -This is a skeleton file that can serve as a starting point for a Python -console script. To run this script uncomment the following lines in the -``[options.entry_points]`` section in ``setup.cfg``:: - - console_scripts = - fibonacci = nwp500.skeleton:run - -Then run ``pip install .`` (or ``pip install -e .`` for editable mode) -which will install the command ``fibonacci`` inside your current environment. - -Besides console scripts, the header (i.e. until ``_logger``...) of this file can -also be used as template for Python modules. - -Note: - This file can be renamed depending on your needs or safely removed if not needed. - -References: - - https://setuptools.pypa.io/en/latest/userguide/entry_point.html - - https://pip.pypa.io/en/stable/reference/pip_install -""" - -import argparse -import logging -import sys - -from nwp500 import __version__ - -__author__ = "Emmanuel Levijarvi" -__copyright__ = "Emmanuel Levijarvi" -__license__ = "MIT" - -_logger = logging.getLogger(__name__) - - -# ---- Python API ---- -# The functions defined in this section can be imported by users in their -# Python scripts/interactive interpreter, e.g. via -# `from nwp500.skeleton import fib`, -# when using this Python module as a library. - - -def fib(n): - """Fibonacci example function - - Args: - n (int): integer - - Returns: - int: n-th Fibonacci number - """ - assert n > 0 - a, b = 1, 1 - for _i in range(n - 1): - a, b = b, a + b - return a - - -# ---- CLI ---- -# The functions defined in this section are wrappers around the main Python -# API allowing them to be called directly from the terminal as a CLI -# executable/script. - - -def parse_args(args): - """Parse command line parameters - - Args: - args (List[str]): command line parameters as list of strings - (for example ``["--help"]``). - - Returns: - :obj:`argparse.Namespace`: command line parameters namespace - """ - parser = argparse.ArgumentParser(description="Just a Fibonacci demonstration") - parser.add_argument( - "--version", - action="version", - version=f"nwp500-python {__version__}", - ) - parser.add_argument(dest="n", help="n-th Fibonacci number", type=int, metavar="INT") - parser.add_argument( - "-v", - "--verbose", - dest="loglevel", - help="set loglevel to INFO", - action="store_const", - const=logging.INFO, - ) - parser.add_argument( - "-vv", - "--very-verbose", - dest="loglevel", - help="set loglevel to DEBUG", - action="store_const", - const=logging.DEBUG, - ) - return parser.parse_args(args) - - -def setup_logging(loglevel): - """Setup basic logging - - Args: - loglevel (int): minimum loglevel for emitting messages - """ - logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" - logging.basicConfig( - level=loglevel, - stream=sys.stdout, - format=logformat, - datefmt="%Y-%m-%d %H:%M:%S", - ) - - -def main(args): - """Wrapper allowing :func:`fib` to be called with string arguments in a CLI fashion - - Instead of returning the value from :func:`fib`, it prints the result to the - ``stdout`` in a nicely formatted message. - - Args: - args (List[str]): command line parameters as list of strings - (for example ``["--verbose", "42"]``). - """ - args = parse_args(args) - setup_logging(args.loglevel) - _logger.debug("Starting crazy calculations...") - print(f"The {args.n}-th Fibonacci number is {fib(args.n)}") - _logger.info("Script ends here") - - -def run(): - """Calls :func:`main` passing the CLI arguments extracted from :obj:`sys.argv` - - This function can be used as entry point to create console scripts with setuptools. - """ - main(sys.argv[1:]) - - -if __name__ == "__main__": - # ^ This is a guard statement that will prevent the following code from - # being executed in the case someone imports this file instead of - # executing it as a script. - # https://docs.python.org/3/library/__main__.html - - # After installing your project with pip, users can also run your Python - # modules as scripts via the ``-m`` flag, as defined in PEP 338:: - # - # python -m nwp500.skeleton 42 - # - run() diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py deleted file mode 100644 index edf77b6..0000000 --- a/tests/test_skeleton.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from nwp500.skeleton import fib, main - -__author__ = "Emmanuel Levijarvi" -__copyright__ = "Emmanuel Levijarvi" -__license__ = "MIT" - - -def test_fib(): - """API Tests""" - assert fib(1) == 1 - assert fib(2) == 1 - assert fib(7) == 13 - with pytest.raises(AssertionError): - fib(-10) - - -def test_main(capsys): - """CLI Tests""" - # capsys is a pytest fixture that allows asserts against stdout/stderr - # https://docs.pytest.org/en/stable/capture.html - main(["7"]) - captured = capsys.readouterr() - assert "The 7-th Fibonacci number is 13" in captured.out