Skip to content

Commit e7ace90

Browse files
committed
CLI Enhancements
- new debug command to parse PCAP data - remove functions that no longer appear to be supported by modern firmware - general cleanup and improved error handling
1 parent 43b7d46 commit e7ace90

9 files changed

Lines changed: 189 additions & 174 deletions

File tree

poetry.lock

Lines changed: 19 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyomnilogic_local/api.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,6 @@ async def async_send_message(self, message_type: MessageType, message: str | Non
6161

6262
return resp
6363

64-
async def async_get_alarm_list(self) -> str:
65-
"""Retrieve a list of alarms from the Omni.
66-
67-
Returns:
68-
str: An XML body indicating any alarms that are present
69-
"""
70-
body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"})
71-
72-
name_element = ET.SubElement(body_element, "Name")
73-
name_element.text = "GetAllAlarmList"
74-
75-
req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode")
76-
77-
return await self.async_send_message(MessageType.GET_ALARM_LIST, req_body, True)
78-
7964
@to_pydantic(pydantic_type=MSPConfig)
8065
async def async_get_config(self) -> str:
8166
"""Retrieve the MSPConfig from the Omni, optionally parse it into a pydantic model.
@@ -125,14 +110,6 @@ async def async_get_filter_diagnostics(
125110

126111
return await self.async_send_message(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body, True)
127112

128-
async def async_get_log_config(self) -> str:
129-
"""Retrieve the logging configuration from the Omni.
130-
131-
Returns:
132-
str: An XML body describing the logging configuration
133-
"""
134-
return await self.async_send_message(MessageType.REQUEST_LOG_CONFIG, None, True)
135-
136113
@to_pydantic(pydantic_type=Telemetry)
137114
async def async_get_telemetry(self) -> str:
138115
"""Retrieve the current telemetry data from the Omni, optionally parse it into a pydantic model.

pyomnilogic_local/cli/cli.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,43 @@
11
import asyncio
2+
from typing import Any
23

34
import click
45

56
from pyomnilogic_local.api import OmniLogicAPI
67
from pyomnilogic_local.cli.debug import commands as debug
78
from pyomnilogic_local.cli.get import commands as get
9+
from pyomnilogic_local.cli.utils import async_get_mspconfig, async_get_telemetry
810

911

1012
async def get_omni(host: str) -> OmniLogicAPI:
1113
return OmniLogicAPI(host, 10444, 5.0)
1214

1315

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
24+
25+
1426
@click.group()
1527
@click.pass_context
1628
@click.option("--host", default="127.0.0.1", help="Hostname or IP address of omnilogic system")
1729
def entrypoint(ctx: click.Context, host: str) -> None:
30+
"""Main CLI entrypoint for OmniLogic local control."""
1831
ctx.ensure_object(dict)
19-
omni = asyncio.run(get_omni(host))
20-
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)
2138
ctx.obj["OMNI"] = omni
39+
ctx.obj["MSPCONFIG"] = mspconfig
40+
ctx.obj["TELEMETRY"] = telemetry
2241

2342

2443
entrypoint.add_command(debug.debug)

pyomnilogic_local/cli/debug/commands.py

Lines changed: 112 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
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
7+
from pathlib import Path
48
from typing import Literal, overload
59

610
import click
11+
from scapy.layers.inet import UDP
12+
from scapy.utils import rdpcap
713

814
from pyomnilogic_local.api import OmniLogicAPI
915
from pyomnilogic_local.cli.utils import async_get_mspconfig, async_get_telemetry
1016
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
1120

1221

1322
@click.group()
@@ -34,18 +43,6 @@ def get_telemetry(ctx: click.Context) -> None:
3443
click.echo(telemetry)
3544

3645

37-
@debug.command()
38-
@click.pass_context
39-
def get_alarm_list(ctx: click.Context) -> None:
40-
alarm_list = asyncio.run(async_get_alarm_list(ctx.obj["OMNI"]))
41-
click.echo(alarm_list)
42-
43-
44-
async def async_get_alarm_list(omni: OmniLogicAPI) -> str:
45-
alarm_list = await omni.async_get_alarm_list()
46-
return alarm_list
47-
48-
4946
@debug.command()
5047
@click.option("--pool-id", help="System ID of the Body Of Water the filter is associated with")
5148
@click.option("--filter-id", help="System ID of the filter to request diagnostics for")
@@ -84,12 +81,107 @@ async def async_get_filter_diagnostics(omni: OmniLogicAPI, pool_id: int, filter_
8481

8582

8683
@debug.command()
84+
@click.argument("pcap_file", type=click.Path(exists=True, path_type=Path))
8785
@click.pass_context
88-
def get_log_config(ctx: click.Context) -> None:
89-
log_config = asyncio.run(async_get_log_config(ctx.obj["OMNI"]))
90-
click.echo(log_config)
91-
92-
93-
async def async_get_log_config(omni: OmniLogicAPI) -> str:
94-
log_config = await omni.async_get_log_config()
95-
return log_config
86+
def parse_pcap(ctx: click.Context, pcap_file: Path) -> None:
87+
"""Parse a PCAP file and reconstruct Omnilogic protocol communication."""
88+
# Read the PCAP file
89+
try:
90+
packets = rdpcap(str(pcap_file))
91+
except Exception as e:
92+
click.echo(f"Error reading PCAP file: {e}", err=True)
93+
raise click.Abort()
94+
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)
183+
184+
# Decode to string
185+
decoded = reassembled.decode("utf-8").strip("\x00")
186+
187+
return decoded

pyomnilogic_local/cli/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
from functools import cache
2+
13
from pyomnilogic_local.api import OmniLogicAPI
24
from pyomnilogic_local.models.mspconfig import MSPConfig
35
from pyomnilogic_local.models.telemetry import Telemetry
46

57

8+
@cache
69
async def async_get_mspconfig(omni: OmniLogicAPI, raw: bool = False) -> MSPConfig:
710
mspconfig: MSPConfig
811
mspconfig = await omni.async_get_config(raw=raw)
912
return mspconfig
1013

1114

15+
@cache
1216
async def async_get_telemetry(omni: OmniLogicAPI, raw: bool = False) -> Telemetry:
1317
telemetry: Telemetry
1418
telemetry = await omni.async_get_telemetry(raw=raw)

pyomnilogic_local/cli_legacy.py

Lines changed: 0 additions & 102 deletions
This file was deleted.

0 commit comments

Comments
 (0)