Skip to content

Commit 9db600e

Browse files
committed
fix: refactor and cleanup for CLI
1 parent d70d010 commit 9db600e

7 files changed

Lines changed: 424 additions & 166 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,7 @@ cython_debug/
166166
*.vpwhistu
167167
*.vpwwildcardcache
168168
*.vtg
169+
170+
# Testing files for PCAP parseer
171+
*.pcap
172+
*.pcapng

pyomnilogic_local/cli/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""CLI module for OmniLogic local control.
2+
3+
This module provides the command-line interface for controlling Hayward
4+
OmniLogic and OmniHub pool controllers.
5+
"""
6+
7+
from pyomnilogic_local.cli.utils import ensure_connection
8+
9+
__all__ = ["ensure_connection"]

pyomnilogic_local/cli/cli.py

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,42 @@
1-
import asyncio
2-
from typing import Any
3-
41
import click
52

6-
from pyomnilogic_local.api import OmniLogicAPI
73
from pyomnilogic_local.cli.debug import commands as debug
84
from pyomnilogic_local.cli.get import commands as get
9-
from pyomnilogic_local.cli.utils import async_get_mspconfig, async_get_telemetry
105

116

12-
async def get_omni(host: str) -> OmniLogicAPI:
13-
return OmniLogicAPI(host, 10444, 5.0)
7+
@click.group(invoke_without_command=True)
8+
@click.pass_context
9+
@click.option("--host", default="127.0.0.1", help="Hostname or IP address of OmniLogic system (default: 127.0.0.1)")
10+
def entrypoint(ctx: click.Context, host: str) -> None:
11+
"""OmniLogic Local Control - Command line interface for Hayward pool controllers.
1412
13+
This CLI provides local control and monitoring of Hayward OmniLogic and OmniHub
14+
pool controllers using their local UDP API (typically on port 10444).
1515
16-
async def fetch_startup_data(omni: OmniLogicAPI) -> tuple[Any, Any]:
17-
"""Fetch MSPConfig and Telemetry from the controller."""
18-
try:
19-
mspconfig = await async_get_mspconfig(omni)
20-
telemetry = await async_get_telemetry(omni)
21-
except Exception as exc:
22-
raise RuntimeError(f"[ERROR] Failed to fetch config or telemetry from controller: {exc}") from exc
23-
return mspconfig, telemetry
16+
The CLI connects to your pool controller when you run a command and caches
17+
configuration and telemetry data for use by that command.
2418
19+
Examples:
20+
# Connect to controller at default address
21+
omnilogic get lights
2522
26-
@click.group()
27-
@click.pass_context
28-
@click.option("--host", default="127.0.0.1", help="Hostname or IP address of omnilogic system")
29-
def entrypoint(ctx: click.Context, host: str) -> None:
30-
"""Main CLI entrypoint for OmniLogic local control."""
23+
# Connect to specific controller IP
24+
omnilogic --host 192.168.1.100 debug get-telemetry
25+
26+
# Get raw XML responses for debugging
27+
omnilogic debug --raw get-mspconfig
28+
29+
For more information, visit: https://github.com/cryptk/python-omnilogic-local
30+
"""
3131
ctx.ensure_object(dict)
32-
try:
33-
omni = asyncio.run(get_omni(host))
34-
mspconfig, telemetry = asyncio.run(fetch_startup_data(omni))
35-
except Exception as exc: # pylint: disable=broad-except
36-
click.secho(str(exc), fg="red", err=True)
37-
ctx.exit(1)
38-
ctx.obj["OMNI"] = omni
39-
ctx.obj["MSPCONFIG"] = mspconfig
40-
ctx.obj["TELEMETRY"] = telemetry
32+
33+
# Store the host for later connection, but don't connect yet
34+
ctx.obj["HOST"] = host
35+
36+
# If no subcommand was provided, show help and exit
37+
if ctx.invoked_subcommand is None:
38+
click.echo(ctx.get_help())
39+
ctx.exit(0)
4140

4241

4342
entrypoint.add_command(debug.debug)
Lines changed: 69 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,82 @@
11
# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators
22
# mypy: disable-error-code="misc"
33
import asyncio
4-
import xml.etree.ElementTree as ET
5-
import zlib
6-
from collections import defaultdict
74
from pathlib import Path
8-
from typing import Literal, overload
95

106
import click
11-
from scapy.layers.inet import UDP
12-
from scapy.utils import rdpcap
137

148
from pyomnilogic_local.api import OmniLogicAPI
15-
from pyomnilogic_local.cli.utils import async_get_mspconfig, async_get_telemetry
16-
from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics
17-
from pyomnilogic_local.models.leadmessage import LeadMessage
18-
from pyomnilogic_local.omnitypes import MessageType
19-
from pyomnilogic_local.protocol import OmniLogicMessage
9+
from pyomnilogic_local.cli import ensure_connection
10+
from pyomnilogic_local.cli.pcap_utils import parse_pcap_file, process_pcap_messages
11+
from pyomnilogic_local.cli.utils import async_get_filter_diagnostics
2012

2113

2214
@click.group()
2315
@click.option("--raw/--no-raw", default=False, help="Output the raw XML from the OmniLogic, do not parse the response")
2416
@click.pass_context
2517
def debug(ctx: click.Context, raw: bool) -> None:
26-
# Container for all get commands
18+
"""Debug commands for low-level controller access.
2719
20+
These commands provide direct access to controller data and debugging utilities
21+
including configuration, telemetry, diagnostics, and PCAP file analysis.
22+
"""
2823
ctx.ensure_object(dict)
2924
ctx.obj["RAW"] = raw
25+
# Don't connect yet - parse_pcap doesn't need it, others will call ensure_connection individually
3026

3127

3228
@debug.command()
3329
@click.pass_context
3430
def get_mspconfig(ctx: click.Context) -> None:
35-
mspconfig = asyncio.run(async_get_mspconfig(ctx.obj["OMNI"], ctx.obj["RAW"]))
31+
"""Retrieve the MSP configuration from the controller.
32+
33+
The MSP configuration contains all pool equipment definitions, system IDs,
34+
and configuration parameters. Use --raw to see the unprocessed XML.
35+
36+
Example:
37+
omnilogic debug get-mspconfig
38+
omnilogic debug --raw get-mspconfig
39+
"""
40+
ensure_connection(ctx)
41+
omni: OmniLogicAPI = ctx.obj["OMNI"]
42+
mspconfig = asyncio.run(omni.async_get_config(raw=ctx.obj["RAW"]))
3643
click.echo(mspconfig)
3744

3845

3946
@debug.command()
4047
@click.pass_context
4148
def get_telemetry(ctx: click.Context) -> None:
42-
telemetry = asyncio.run(async_get_telemetry(ctx.obj["OMNI"], ctx.obj["RAW"]))
49+
"""Retrieve current telemetry data from the controller.
50+
51+
Telemetry includes real-time sensor readings, equipment states, temperatures,
52+
and other operational data. Use --raw to see the unprocessed XML.
53+
54+
Example:
55+
omnilogic debug get-telemetry
56+
omnilogic debug --raw get-telemetry
57+
"""
58+
ensure_connection(ctx)
59+
omni: OmniLogicAPI = ctx.obj["OMNI"]
60+
telemetry = asyncio.run(omni.async_get_telemetry(raw=ctx.obj["RAW"]))
4361
click.echo(telemetry)
4462

4563

4664
@debug.command()
47-
@click.option("--pool-id", help="System ID of the Body Of Water the filter is associated with")
48-
@click.option("--filter-id", help="System ID of the filter to request diagnostics for")
65+
@click.option(
66+
"--pool-id", required=True, type=int, help="System ID of the Body Of Water the filter is associated with. Example: --pool-id 1"
67+
)
68+
@click.option("--filter-id", required=True, type=int, help="System ID of the filter to request diagnostics for. Example: --filter-id 5")
4969
@click.pass_context
5070
def get_filter_diagnostics(ctx: click.Context, pool_id: int, filter_id: int) -> None:
71+
"""Get diagnostic information for a specific filter/pump.
72+
73+
This command retrieves detailed diagnostic data including firmware versions,
74+
power consumption, and error status for a filter or pump.
75+
76+
Example:
77+
omnilogic debug get-filter-diagnostics --pool-id 1 --filter-id 5
78+
"""
79+
ensure_connection(ctx)
5180
filter_diags = asyncio.run(async_get_filter_diagnostics(ctx.obj["OMNI"], pool_id, filter_id, ctx.obj["RAW"]))
5281
if ctx.obj["RAW"]:
5382
click.echo(filter_diags)
@@ -71,117 +100,39 @@ def get_filter_diagnostics(ctx: click.Context, pool_id: int, filter_id: int) ->
71100
)
72101

73102

74-
@overload
75-
async def async_get_filter_diagnostics(omni: OmniLogicAPI, pool_id: int, filter_id: int, raw: Literal[True]) -> str: ...
76-
@overload
77-
async def async_get_filter_diagnostics(omni: OmniLogicAPI, pool_id: int, filter_id: int, raw: Literal[False]) -> FilterDiagnostics: ...
78-
async def async_get_filter_diagnostics(omni: OmniLogicAPI, pool_id: int, filter_id: int, raw: bool) -> FilterDiagnostics | str:
79-
filter_diags = await omni.async_get_filter_diagnostics(pool_id, filter_id, raw=raw)
80-
return filter_diags
81-
82-
83103
@debug.command()
84104
@click.argument("pcap_file", type=click.Path(exists=True, path_type=Path))
85105
@click.pass_context
86106
def parse_pcap(ctx: click.Context, pcap_file: Path) -> None:
87-
"""Parse a PCAP file and reconstruct Omnilogic protocol communication."""
107+
"""Parse a PCAP file and reconstruct Omnilogic protocol communication.
108+
109+
Analyzes network packet captures to decode OmniLogic protocol messages.
110+
Automatically reassembles multi-part messages (LeadMessage + BlockMessages)
111+
and decompresses payloads.
112+
113+
The PCAP file should contain UDP traffic captured from OmniLogic controller
114+
communication (typically on port 10444).
115+
116+
Example:
117+
omnilogic debug parse-pcap /path/to/capture.pcap
118+
tcpdump -i eth0 -w pool.pcap udp port 10444
119+
omnilogic debug parse-pcap pool.pcap
120+
"""
88121
# Read the PCAP file
89122
try:
90-
packets = rdpcap(str(pcap_file))
123+
packets = parse_pcap_file(str(pcap_file))
91124
except Exception as e:
92125
click.echo(f"Error reading PCAP file: {e}", err=True)
93126
raise click.Abort()
94127

95-
# Track multi-message sequences (LeadMessage + BlockMessages)
96-
# Key: (src_ip, dst_ip, msg_id), Value: list of messages
97-
message_sequences: dict[tuple[str, str, int], list[OmniLogicMessage]] = defaultdict(list)
98-
99-
# Process packets in order
100-
for packet in packets:
101-
if not packet.haslayer(UDP):
102-
click.echo("Not a UDP packet, skipping...", err=True)
103-
continue
104-
105-
udp = packet[UDP]
106-
src_ip = packet.payload.src
107-
dst_ip = packet.payload.dst
108-
109-
# Parse the Omnilogic message
110-
try:
111-
omni_msg = OmniLogicMessage.from_bytes(bytes(udp.payload))
112-
click.echo(f"Parsed Omnilogic message: {omni_msg}")
113-
except Exception: # pylint: disable=broad-except
114-
# Not an Omnilogic message, skip it
115-
click.echo("Not an Omnilogic message, skipping...", err=True)
116-
continue
117-
118-
# Print the basic packet info
119-
click.echo(f"{src_ip} sent {omni_msg.type.name} to {dst_ip}")
120-
121-
# Track LeadMessage/BlockMessage sequences
122-
if omni_msg.type == MessageType.MSP_LEADMESSAGE:
123-
# Start a new sequence
124-
seq_key = (src_ip, dst_ip, omni_msg.id)
125-
message_sequences[seq_key] = [omni_msg]
126-
elif omni_msg.type == MessageType.MSP_BLOCKMESSAGE:
127-
# Find the matching LeadMessage sequence
128-
# We need to find the sequence with the same src/dst and highest ID less than or equal to this message
129-
matching_seq: tuple[str, str, int] = ("", "", 0)
130-
for seq_key in message_sequences:
131-
if seq_key[0] == src_ip and seq_key[1] == dst_ip:
132-
# Check if this is the right sequence (the LeadMessage should have been received before this block)
133-
if not matching_seq or seq_key[2] > matching_seq[2]:
134-
matching_seq = seq_key
135-
136-
if matching_seq:
137-
message_sequences[matching_seq].append(omni_msg)
138-
139-
# Check if we have all the blocks
140-
lead_msg = message_sequences[matching_seq][0]
141-
lead_data = LeadMessage.from_orm(ET.fromstring(lead_msg.payload[:-1]))
142-
143-
# We have LeadMessage + all BlockMessages
144-
if len(message_sequences[matching_seq]) == lead_data.msg_block_count + 1:
145-
# Reassemble and decode
146-
try:
147-
decoded_msg = _reassemble_and_decode(message_sequences[matching_seq])
148-
click.echo(f"\nMessage from {src_ip} decoded:")
149-
click.echo(decoded_msg)
150-
click.echo() # Extra newline for readability
151-
except Exception as e: # pylint: disable=broad-except
152-
click.echo(f"Error decoding message: {e}", err=True)
153-
154-
# Clean up this sequence
155-
del message_sequences[matching_seq]
156-
157-
158-
def _reassemble_and_decode(messages: list[OmniLogicMessage]) -> str:
159-
"""
160-
Reassemble a LeadMessage + BlockMessages sequence and decode the payload.
161-
162-
Args:
163-
messages: List containing LeadMessage followed by BlockMessages
164-
165-
Returns:
166-
Decoded message content as string
167-
"""
168-
lead_msg = messages[0]
169-
block_msgs = messages[1:]
170-
171-
# Reassemble the blocks
172-
# Sort by message ID to ensure correct order
173-
sorted_blocks = sorted(block_msgs, key=lambda m: m.id)
174-
175-
# Concatenate the block payloads (skip the 8-byte header on each block)
176-
reassembled = b""
177-
for block_msg in sorted_blocks:
178-
reassembled += block_msg.payload[8:]
179-
180-
# Decompress if necessary
181-
if lead_msg.compressed:
182-
reassembled = zlib.decompress(reassembled)
128+
# Process all packets and extract OmniLogic messages
129+
results = process_pcap_messages(packets)
183130

184-
# Decode to string
185-
decoded = reassembled.decode("utf-8").strip("\x00")
131+
# Display the results
132+
for src_ip, dst_ip, omni_msg, decoded_content in results:
133+
click.echo(f"\n{src_ip} sent {omni_msg.type.name} to {dst_ip}")
186134

187-
return decoded
135+
if decoded_content:
136+
click.echo("Decoded message content:")
137+
click.echo(decoded_content)
138+
click.echo() # Extra newline for readability

0 commit comments

Comments
 (0)