Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
Expand Down
37 changes: 31 additions & 6 deletions src/nwp500/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
44 changes: 32 additions & 12 deletions src/nwp500/cli/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down Expand Up @@ -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,
Expand All @@ -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}")

Expand Down
123 changes: 123 additions & 0 deletions src/nwp500/cli/output_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading