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"
33import asyncio
4- import xml .etree .ElementTree as ET
5- import zlib
6- from collections import defaultdict
74from pathlib import Path
8- from typing import Literal , overload
95
106import click
11- from scapy .layers .inet import UDP
12- from scapy .utils import rdpcap
137
148from 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
2517def 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
3430def 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
4148def 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
5070def 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
86106def 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"\n Message 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