diff --git a/README.rst b/README.rst index 5aa732a..9bcae41 100644 --- a/README.rst +++ b/README.rst @@ -29,10 +29,28 @@ Quick Start Installation ------------ +Basic Installation (Library Only) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For using the library as a Python package without the CLI: + .. code-block:: bash pip install nwp500-python +This installs the core library with support for API and MQTT clients. No CLI framework is required. + +Installation with CLI Support +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To use the command-line interface with rich formatting and colors: + +.. code-block:: bash + + pip install nwp500-python[cli] + +This includes both the ``click`` CLI framework and the ``rich`` formatting library for enhanced terminal output with formatted tables, progress bars, and colored output. + Basic Usage ----------- @@ -93,7 +111,16 @@ Monitor your device in real-time using MQTT: Command Line Interface ====================== -The library includes a command line interface for monitoring and controlling your Navien water heater: +The library includes a command line interface for monitoring and controlling your Navien water heater. + +**Installation Requirement:** The CLI requires the ``cli`` extra: + +.. code-block:: bash + + pip install nwp500-python[cli] + +Quick Reference +--------------- .. code-block:: bash diff --git a/setup.cfg b/setup.cfg index 32515a8..256c2f1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,7 +54,6 @@ install_requires = aiohttp>=3.8.0 awsiotsdk>=1.27.0 pydantic>=2.0.0 - click>=8.0.0 [options.packages.find] @@ -67,8 +66,9 @@ exclude = # `pip install nwp500-python[PDF]` like: # PDF = ReportLab; RXP -# CLI enhancements with rich library +# CLI - command line interface with optional rich formatting cli = + click>=8.0.0 rich>=13.0.0 # Add here test requirements (semicolon/line-separated) diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index db3400a..1b51251 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -323,17 +323,42 @@ async def tou_set(mqtt: NavienMqttClient, device: Any, state: str) -> None: @cli.command() # type: ignore[attr-defined] -@click.option("--year", type=int, required=True) +@click.option("--year", type=int, required=True, help="Year to query") @click.option( - "--months", required=True, help="Comma-separated months (e.g. 1,2,3)" + "--months", required=False, help="Comma-separated months (e.g. 1,2,3)" +) +@click.option( + "--month", + type=int, + required=False, + help="Show daily breakdown for a specific month (1-12)", ) @async_command async def energy( - mqtt: NavienMqttClient, device: Any, year: int, months: str + mqtt: NavienMqttClient, + device: Any, + year: int, + months: str | None, + month: int | None, ) -> None: - """Query historical energy usage.""" - month_list = [int(m.strip()) for m in months.split(",")] - await handlers.handle_get_energy_request(mqtt, device, year, month_list) + """Query historical energy usage. + + Use either --months for monthly summary or --month for daily breakdown. + """ + if month is not None: + # Daily breakdown for a single month + if month < 1 or month > 12: + raise click.ClickException("Month must be between 1 and 12") + await handlers.handle_get_energy_request(mqtt, device, year, [month]) + elif months is not None: + # Monthly summary + month_list = [int(m.strip()) for m in months.split(",")] + await handlers.handle_get_energy_request(mqtt, device, year, month_list) + else: + raise click.ClickException( + "Either --months (for monthly summary) or --month " + "(for daily breakdown) required" + ) @cli.command() # type: ignore[attr-defined] diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index 366b4c0..d82246d 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -360,22 +360,30 @@ async def handle_get_device_info_rest( if raw: print_json(device_info_obj.model_dump()) else: - # Print simple formatted output + # Print formatted output with rich support info = device_info_obj.device_info install_type_str = info.install_type if info.install_type else "N/A" - print("\n=== Device Info (REST API) ===\n") - print(f"Device Name: {info.device_name}") mac_display = ( redact_serial(info.mac_address) if info.mac_address else "N/A" ) - print(f"MAC Address: {mac_display}") - print(f"Device Type: {info.device_type}") - print(f"Home Seq: {info.home_seq}") - print(f"Connected: {info.connected}") - print(f"Install Type: {install_type_str}") - print(f"Additional Value: {info.additional_value or 'N/A'}") - print() + + # Collect items for rich formatter + all_items = [ + ("DEVICE INFO", "Device Name", info.device_name), + ("DEVICE INFO", "MAC Address", mac_display), + ("DEVICE INFO", "Device Type", str(info.device_type)), + ("DEVICE INFO", "Home Seq", str(info.home_seq)), + ("DEVICE INFO", "Connected", str(info.connected)), + ("DEVICE INFO", "Install Type", install_type_str), + ( + "DEVICE INFO", + "Additional Value", + info.additional_value or "N/A", + ), + ] + + _formatter.print_status_table(all_items) except Exception as e: _logger.error(f"Error fetching device info: {e}") @@ -427,7 +435,11 @@ async def handle_set_tou_enabled_request( async def handle_get_energy_request( mqtt: NavienMqttClient, device: Device, year: int, months: list[int] ) -> None: - """Request energy usage data.""" + """Request energy usage data. + + If a single month is provided, shows daily breakdown. + If multiple months are provided, shows monthly summary. + """ try: res: Any = await _wait_for_response( mqtt.subscribe_energy_usage, @@ -436,7 +448,15 @@ async def handle_get_energy_request( action_name="energy usage", timeout=15, ) - print_energy_usage(cast(EnergyUsageResponse, res)) + # If single month requested, show daily breakdown + if len(months) == 1: + from .output_formatters import print_daily_energy_usage + + print_daily_energy_usage( + cast(EnergyUsageResponse, res), year, months[0] + ) + else: + print_energy_usage(cast(EnergyUsageResponse, res)) except Exception as e: _logger.error(f"Error getting energy data: {e}") diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index a802bd1..41a0fe1 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -218,6 +218,129 @@ def print_energy_usage(energy_response: Any) -> None: formatter.print_energy_table(months_data) +def format_daily_energy_usage( + energy_response: Any, year: int, month: int +) -> str: + """ + Format daily energy usage for a specific month as a human-readable table. + + Args: + energy_response: EnergyUsageResponse object + year: Year to filter for (e.g., 2025) + month: Month to filter for (1-12) + + Returns: + Formatted string with daily energy usage data in tabular form + """ + lines = [] + + # Add header + lines.append("=" * 100) + month_str = ( + f"{month_name[month]} {year}" + if 1 <= month <= 12 + else f"Month {month} {year}" + ) + lines.append(f"DAILY ENERGY USAGE - {month_str}") + lines.append("=" * 100) + + # Find the month data + month_data = energy_response.get_month_data(year, month) + if not month_data or not month_data.data: + lines.append("No data available for this month") + lines.append("=" * 100) + return "\n".join(lines) + + # Total summary for the month + total = energy_response.total + total_usage_wh = total.total_usage + total_time_hours = total.total_time + + lines.append("") + lines.append("TOTAL SUMMARY") + lines.append("-" * 100) + lines.append( + f"Total Energy Used: {total_usage_wh:,} Wh ({total_usage_wh / 1000:.2f} kWh)" # noqa: E501 + ) + lines.append( + f" Heat Pump: {total.heat_pump_usage:,} Wh ({total.heat_pump_percentage:.1f}%)" # noqa: E501 + ) + lines.append( + f" Heat Element: {total.heat_element_usage:,} Wh ({total.heat_element_percentage:.1f}%)" # noqa: E501 + ) + lines.append(f"Total Time Running: {total_time_hours} hours") + lines.append(f" Heat Pump: {total.heat_pump_time} hours") + lines.append(f" Heat Element: {total.heat_element_time} hours") + + # Daily breakdown + lines.append("") + lines.append("DAILY BREAKDOWN") + lines.append("-" * 100) + lines.append( + f"{'Day':<5} {'Energy (Wh)':<18} {'HP (Wh)':<15} {'HE (Wh)':<15} {'HP Time':<12} {'HE Time':<12}" # noqa: E501 + ) + lines.append("-" * 100) + + for day_num, day_data in enumerate(month_data.data, start=1): + total_wh = day_data.total_usage + hp_wh = day_data.heat_pump_usage + he_wh = day_data.heat_element_usage + hp_time = day_data.heat_pump_time + he_time = day_data.heat_element_time + + lines.append( + f"{day_num:<5} {total_wh:>16,} {hp_wh:>13,} {he_wh:>13,} {hp_time:>10} {he_time:>10}" # noqa: E501 + ) + + lines.append("=" * 100) + return "\n".join(lines) + + +def print_daily_energy_usage( + energy_response: Any, year: int, month: int +) -> None: + """ + Print daily energy usage data in human-readable tabular format. + + Uses Rich formatting when available, falls back to plain text otherwise. + + Args: + energy_response: EnergyUsageResponse object + year: Year to filter for (e.g., 2025) + month: Month to filter for (1-12) + """ + # First, print the plain text summary (always works) + print(format_daily_energy_usage(energy_response, year, month)) + + # Also prepare and print rich table if available + month_data = energy_response.get_month_data(year, month) + if not month_data or not month_data.data: + return + + days_data = [] + for day_num, day_data in enumerate(month_data.data, start=1): + total_wh = day_data.total_usage + hp_wh = day_data.heat_pump_usage + he_wh = day_data.heat_element_usage + hp_pct = (hp_wh / total_wh * 100) if total_wh > 0 else 0 + he_pct = (he_wh / total_wh * 100) if total_wh > 0 else 0 + + days_data.append( + { + "day": day_num, + "total_kwh": total_wh / 1000, + "hp_kwh": hp_wh / 1000, + "hp_pct": hp_pct, + "he_kwh": he_wh / 1000, + "he_pct": he_pct, + } + ) + + # Print rich energy table if available + formatter = get_formatter() + formatter.print_daily_energy_table(days_data, year, month) + + def write_status_to_csv(file_path: str, status: DeviceStatus) -> None: """ Append device status to a CSV file. diff --git a/src/nwp500/cli/rich_output.py b/src/nwp500/cli/rich_output.py index c3838e7..6488c9f 100644 --- a/src/nwp500/cli/rich_output.py +++ b/src/nwp500/cli/rich_output.py @@ -90,6 +90,21 @@ def print_energy_table(self, months: list[dict[str, Any]]) -> None: else: self._print_energy_rich(months) + def print_daily_energy_table( + self, days: list[dict[str, Any]], year: int, month: int + ) -> None: + """Print daily energy usage data as a formatted table. + + Args: + days: List of daily energy data dictionaries + year: Year for the data + month: Month for the data + """ + if not self.use_rich: + self._print_daily_energy_plain(days, year, month) + else: + self._print_daily_energy_rich(days, year, month) + def print_error( self, message: str, @@ -365,6 +380,85 @@ def _create_progress_bar(self, percentage: float, width: int = 10) -> str: bar = "█" * filled + "░" * (width - filled) return f"[{bar}]" + def _print_daily_energy_plain( + self, days: list[dict[str, Any]], year: int, month: int + ) -> None: + """Plain text daily energy output (fallback).""" + # This is a simplified version - the actual rendering comes from + # output_formatters.format_daily_energy_usage() + from calendar import month_name + + month_str = ( + f"{month_name[month]} {year}" + if 1 <= month <= 12 + else f"Month {month} {year}" + ) + print(f"DAILY ENERGY USAGE - {month_str}") + print("=" * 100) + for day in days: + print(f"{day}") + + def _print_daily_energy_rich( + self, days: list[dict[str, Any]], year: int, month: int + ) -> None: + """Rich-enhanced daily energy output.""" + from calendar import month_name + + assert self.console is not None + assert _rich_available + + month_str = ( + f"{month_name[month]} {year}" + if 1 <= month <= 12 + else f"Month {month} {year}" + ) + table = cast(Any, Table)( + title=f"DAILY ENERGY USAGE - {month_str}", show_header=True + ) + table.add_column("Day", style="cyan", width=6) + table.add_column( + "Total kWh", style="magenta", justify="right", width=12 + ) + table.add_column("HP Usage", width=18) + table.add_column("HE Usage", width=18) + + for day in days: + day_num = day.get("day", "N/A") + total_kwh = day.get("total_kwh", 0) + hp_kwh = day.get("hp_kwh", 0) + he_kwh = day.get("he_kwh", 0) + hp_pct = day.get("hp_pct", 0) + he_pct = day.get("he_pct", 0) + + # Create progress bar representations + hp_bar = self._create_progress_bar(hp_pct, 10) + he_bar = self._create_progress_bar(he_pct, 10) + + # Color code based on efficiency + hp_color = ( + "green" + if hp_pct >= 70 + else ("yellow" if hp_pct >= 50 else "red") + ) + he_color = ( + "red" + if he_pct >= 50 + else ("yellow" if he_pct >= 30 else "green") + ) + + hp_text = ( + f"{hp_kwh:.1f} kWh " + f"[{hp_color}]{hp_pct:.0f}%[/{hp_color}]\n{hp_bar}" + ) + he_text = ( + f"{he_kwh:.1f} kWh " + f"[{he_color}]{he_pct:.0f}%[/{he_color}]\n{he_bar}" + ) + + table.add_row(str(day_num), f"{total_kwh:.1f}", hp_text, he_text) + + self.console.print(table) + def _print_error_rich( self, message: str, diff --git a/tox.ini b/tox.ini index 7f00677..9047447 100644 --- a/tox.ini +++ b/tox.ini @@ -17,6 +17,7 @@ passenv = HOME SETUPTOOLS_* extras = + cli testing deps = pyright>=1.1.0