python-omnilogic-local 0.25.2__tar.gz → 0.27.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/PKG-INFO +1 -1
  2. python_omnilogic_local-0.27.0/pyomnilogic_local/api/mock_api.py +124 -0
  3. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/models/filter_diagnostics.py +12 -2
  4. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/models/leadmessage.py +1 -1
  5. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/models/mspconfig.py +6 -1
  6. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/models/telemetry.py +7 -3
  7. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyproject.toml +3 -8
  8. python_omnilogic_local-0.25.2/pyomnilogic_local/api/mock_api.py +0 -88
  9. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/LICENSE +0 -0
  10. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/README.md +0 -0
  11. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/__init__.py +0 -0
  12. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/_base.py +0 -0
  13. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/api/__init__.py +0 -0
  14. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/api/api.py +0 -0
  15. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/api/constants.py +0 -0
  16. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/api/exceptions.py +0 -0
  17. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/api/protocol.py +0 -0
  18. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/backyard.py +0 -0
  19. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/bow.py +0 -0
  20. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/chlorinator.py +0 -0
  21. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/chlorinator_equip.py +0 -0
  22. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/__init__.py +0 -0
  23. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/cli.py +0 -0
  24. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/debug/__init__.py +0 -0
  25. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/debug/commands.py +0 -0
  26. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/__init__.py +0 -0
  27. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/backyard.py +0 -0
  28. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/bows.py +0 -0
  29. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/chlorinators.py +0 -0
  30. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/commands.py +0 -0
  31. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/csads.py +0 -0
  32. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/filters.py +0 -0
  33. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/groups.py +0 -0
  34. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/heaters.py +0 -0
  35. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/lights.py +0 -0
  36. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/pumps.py +0 -0
  37. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/relays.py +0 -0
  38. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/schedules.py +0 -0
  39. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/sensors.py +0 -0
  40. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/get/valves.py +0 -0
  41. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/pcap_utils.py +0 -0
  42. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/cli/utils.py +0 -0
  43. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/collections.py +0 -0
  44. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/colorlogiclight.py +0 -0
  45. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/csad.py +0 -0
  46. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/csad_equip.py +0 -0
  47. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/decorators.py +0 -0
  48. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/filter.py +0 -0
  49. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/groups.py +0 -0
  50. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/heater.py +0 -0
  51. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/heater_equip.py +0 -0
  52. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/models/__init__.py +0 -0
  53. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/models/const.py +0 -0
  54. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/models/exceptions.py +0 -0
  55. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/omnilogic.py +0 -0
  56. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/omnitypes.py +0 -0
  57. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/pump.py +0 -0
  58. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/py.typed +0 -0
  59. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/relay.py +0 -0
  60. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/schedule.py +0 -0
  61. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/sensor.py +0 -0
  62. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/system.py +0 -0
  63. {python_omnilogic_local-0.25.2 → python_omnilogic_local-0.27.0}/pyomnilogic_local/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-omnilogic-local
3
- Version: 0.25.2
3
+ Version: 0.27.0
4
4
  Summary: A library for local control of Hayward OmniHub/OmniLogic pool controllers using their local API
5
5
  Author: Chris Jowett, djtimca, garionphx
6
6
  Author-email: Chris Jowett <421501+cryptk@users.noreply.github.com>
@@ -0,0 +1,124 @@
1
+ """Mock API for simulation mode — loads data from a local JSON file instead of a live controller."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Any, Literal, TypedDict, overload
9
+
10
+ from pyomnilogic_local.models.mspconfig import MSPConfig
11
+ from pyomnilogic_local.models.telemetry import Telemetry
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ class SimData(TypedDict):
17
+ telemetry: str
18
+ msp_config: str
19
+ filepath: str
20
+
21
+
22
+ class OmniLogicMockAPI:
23
+ """Drop-in replacement for OmniLogicAPI that serves pre-recorded data from one or more JSON files.
24
+
25
+ Each JSON file must contain the simulation data at the paths:
26
+ - ``.data.telemetry`` — raw XML telemetry string
27
+ - ``.data.msp_config`` — raw XML MSP config string
28
+
29
+ When multiple files are provided the class maintains a round-robin pointer into the
30
+ list. Whether the pointer advances after each call is controlled by two attributes:
31
+
32
+ - ``increment_on_mspconfig`` (default ``False``) — advance after ``async_get_mspconfig``
33
+ - ``increment_on_telemetry`` (default ``True``) — advance after ``async_get_telemetry``
34
+
35
+ Any API call other than ``async_get_telemetry`` or ``async_get_mspconfig`` is silently
36
+ absorbed and logged at INFO level; no network traffic is generated.
37
+ """
38
+
39
+ def __init__(self, filepath: str) -> None:
40
+ """Load simulation data from *filepath*.
41
+
42
+ Args:
43
+ filepath: Comma-separated path(s) to JSON simulation data file(s).
44
+ A single path with no commas is treated as a one-element list.
45
+
46
+ Raises:
47
+ FileNotFoundError: If any file does not exist.
48
+ KeyError: If the expected JSON structure is not present in a file.
49
+ """
50
+ paths = filepath.split(",")
51
+
52
+ self._sim_data: list[SimData] = []
53
+ for fp in paths:
54
+ path = Path(fp)
55
+ if not path.exists():
56
+ msg = f"Simulation data file not found: {fp}"
57
+ raise FileNotFoundError(msg)
58
+ data = json.loads(path.read_text(encoding="utf-8"))
59
+ data["data"]["filepath"] = fp
60
+ self._sim_data.append(data["data"])
61
+
62
+ self._index = 0
63
+ self.increment_on_mspconfig = False
64
+ self.increment_on_telemetry = True
65
+
66
+ _LOGGER.warning(
67
+ "Running in simulation mode using data from %s. No API calls will be made to the OmniLogic controller.",
68
+ paths,
69
+ )
70
+
71
+ @overload
72
+ async def async_get_mspconfig(self, raw: Literal[True]) -> str: ...
73
+ @overload
74
+ async def async_get_mspconfig(self, raw: Literal[False]) -> MSPConfig: ...
75
+ @overload
76
+ async def async_get_mspconfig(self) -> MSPConfig: ...
77
+ async def async_get_mspconfig(self, raw: bool = False) -> MSPConfig | str:
78
+ """Return the pre-loaded MSP config from the current simulation file."""
79
+ data = self._sim_data[self._index]
80
+ if self.increment_on_mspconfig:
81
+ _LOGGER.debug(
82
+ "Advancing simulation file index from %s to %s, filepath: %s",
83
+ self._index,
84
+ (self._index + 1) % len(self._sim_data),
85
+ data["filepath"],
86
+ )
87
+ self._index = (self._index + 1) % len(self._sim_data)
88
+ if raw:
89
+ return data["msp_config"]
90
+ return MSPConfig.load_xml(data["msp_config"])
91
+
92
+ @overload
93
+ async def async_get_telemetry(self, raw: Literal[True]) -> str: ...
94
+ @overload
95
+ async def async_get_telemetry(self, raw: Literal[False]) -> Telemetry: ...
96
+ @overload
97
+ async def async_get_telemetry(self) -> Telemetry: ...
98
+ async def async_get_telemetry(self, raw: bool = False) -> Telemetry | str:
99
+ """Return the pre-loaded telemetry from the current simulation file."""
100
+ data = self._sim_data[self._index]
101
+ if self.increment_on_telemetry:
102
+ _LOGGER.debug(
103
+ "Advancing simulation file index from %s to %s, filepath: %s",
104
+ self._index,
105
+ (self._index + 1) % len(self._sim_data),
106
+ data["filepath"],
107
+ )
108
+ self._index = (self._index + 1) % len(self._sim_data)
109
+ if raw:
110
+ return data["telemetry"]
111
+ return Telemetry.load_xml(data["telemetry"])
112
+
113
+ def __getattr__(self, name: str) -> Any:
114
+ """Return a no-op async callable for any API method not explicitly implemented."""
115
+
116
+ async def _noop(*args: Any, **kwargs: Any) -> None:
117
+ _LOGGER.info(
118
+ "Simulation mode: ignoring call to %s (args=%s, kwargs=%s)",
119
+ name,
120
+ args,
121
+ kwargs,
122
+ )
123
+
124
+ return _noop
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from pydantic import BaseModel, ConfigDict, Field
3
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError
4
4
  from xmltodict import parse as xml_parse
5
5
 
6
+ from pyomnilogic_local.models.exceptions import OmniParsingError
7
+
6
8
  # Example Filter Diagnostics XML:
7
9
  #
8
10
  # <?xml version="1.0" encoding="UTF-8" ?>
@@ -46,6 +48,7 @@ class FilterDiagnosticsParameters(BaseModel):
46
48
 
47
49
  class FilterDiagnostics(BaseModel):
48
50
  model_config = ConfigDict(from_attributes=True)
51
+ _raw: str = PrivateAttr(default="")
49
52
 
50
53
  name: str = Field(alias="Name")
51
54
  parameters: list[FilterDiagnosticsParameter] = Field(alias="Parameters")
@@ -64,4 +67,11 @@ class FilterDiagnostics(BaseModel):
64
67
  # The XML nests the Parameter entries under a Parameters entry, this is annoying to work with. Here we are adjusting the data to
65
68
  # remove that extra level in the data
66
69
  data["Response"]["Parameters"] = data["Response"]["Parameters"]["Parameter"]
67
- return FilterDiagnostics.model_validate(data["Response"])
70
+ try:
71
+ instance = FilterDiagnostics.model_validate(data["Response"])
72
+ instance._raw = xml
73
+ except ValidationError as exc:
74
+ msg = f"Failed to parse Filter Diagnostics: {exc}"
75
+ raise OmniParsingError(msg) from exc
76
+ else:
77
+ return instance
@@ -40,4 +40,4 @@ class LeadMessage(BaseModel):
40
40
  if name := param.get("name"):
41
41
  result[name] = int(param.text) if param.text else 0
42
42
  return result
43
- return data
43
+ return data # type: ignore[no-any-return]
@@ -9,6 +9,7 @@ from pydantic import (
9
9
  BaseModel,
10
10
  ConfigDict,
11
11
  Field,
12
+ PrivateAttr,
12
13
  ValidationError,
13
14
  computed_field,
14
15
  model_validator,
@@ -410,6 +411,7 @@ type MSPConfigType = MSPSystem | MSPEquipmentType
410
411
 
411
412
  class MSPConfig(BaseModel):
412
413
  model_config = ConfigDict(from_attributes=True)
414
+ _raw: str = PrivateAttr(default="")
413
415
 
414
416
  system: MSPSystem = Field(alias="System")
415
417
  backyard: MSPBackyard = Field(alias="Backyard")
@@ -459,7 +461,10 @@ class MSPConfig(BaseModel):
459
461
  ),
460
462
  )
461
463
  try:
462
- return MSPConfig.model_validate(data["MSPConfig"], from_attributes=True)
464
+ instance = MSPConfig.model_validate(data["MSPConfig"], from_attributes=True)
465
+ instance._raw = xml
463
466
  except ValidationError as exc:
464
467
  msg = f"Failed to parse MSP Configuration: {exc}"
465
468
  raise OmniParsingError(msg) from exc
469
+ else:
470
+ return instance
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  from typing import Any, SupportsInt, cast, overload
5
5
 
6
- from pydantic import BaseModel, ConfigDict, Field, ValidationError, computed_field
6
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError, computed_field
7
7
  from xmltodict import parse as xml_parse
8
8
 
9
9
  from pyomnilogic_local.omnitypes import (
@@ -492,6 +492,7 @@ class Telemetry(BaseModel):
492
492
  """
493
493
 
494
494
  model_config = ConfigDict(from_attributes=True)
495
+ _raw: str = PrivateAttr(default="")
495
496
 
496
497
  version: str = Field(alias="@version")
497
498
  backyard: TelemetryBackyard = Field(alias="Backyard")
@@ -525,7 +526,7 @@ class Telemetry(BaseModel):
525
526
 
526
527
  try:
527
528
  newvalue = int(value)
528
- except (ValueError, TypeError):
529
+ except ValueError, TypeError:
529
530
  newvalue = value
530
531
 
531
532
  return key, newvalue
@@ -552,10 +553,13 @@ class Telemetry(BaseModel):
552
553
  ),
553
554
  )
554
555
  try:
555
- return Telemetry.model_validate(data["STATUS"])
556
+ instance = Telemetry.model_validate(data["STATUS"])
557
+ instance._raw = xml
556
558
  except ValidationError as exc:
557
559
  msg = f"Failed to parse Telemetry: {exc}"
558
560
  raise OmniParsingError(msg) from exc
561
+ else:
562
+ return instance
559
563
 
560
564
  def get_telem_by_systemid(self, system_id: int) -> TelemetryType | None:
561
565
  for field_name, value in self:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-omnilogic-local"
3
- version = "0.25.2"
3
+ version = "0.27.0"
4
4
  description = "A library for local control of Hayward OmniHub/OmniLogic pool controllers using their local API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14.2"
@@ -43,14 +43,9 @@ dev = [
43
43
 
44
44
  [tool.mypy]
45
45
  python_version = "3.14"
46
- plugins = [
47
- "pydantic.mypy"
48
- ]
49
- # follow_imports = "silent"
46
+ plugins = ["pydantic.mypy"]
47
+ follow_imports = "normal"
50
48
  strict = true
51
- ignore_missing_imports = true
52
- disallow_subclassing_any = false
53
- warn_return_any = false
54
49
 
55
50
  [tool.ruff]
56
51
  line-length = 140
@@ -1,88 +0,0 @@
1
- """Mock API for simulation mode — loads data from a local JSON file instead of a live controller."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import logging
7
- from pathlib import Path
8
- from typing import Any, Literal, overload
9
-
10
- from pyomnilogic_local.models.mspconfig import MSPConfig
11
- from pyomnilogic_local.models.telemetry import Telemetry
12
-
13
- _LOGGER = logging.getLogger(__name__)
14
-
15
-
16
- class OmniLogicMockAPI:
17
- """Drop-in replacement for OmniLogicAPI that serves pre-recorded data from a JSON file.
18
-
19
- The JSON file must contain the simulation data at the paths:
20
- - ``.data.telemetry`` — raw XML telemetry string
21
- - ``.data.msp_config`` — raw XML MSP config string
22
-
23
- Any API call other than ``async_get_telemetry`` or ``async_get_mspconfig`` is silently
24
- absorbed and logged at INFO level; no network traffic is generated.
25
- """
26
-
27
- def __init__(self, filepath: str) -> None:
28
- """Load simulation data from *filepath*.
29
-
30
- Args:
31
- filepath: Path to the JSON simulation data file.
32
-
33
- Raises:
34
- FileNotFoundError: If the file does not exist at *filepath*.
35
- KeyError: If the expected JSON structure is not present in the file.
36
- """
37
- path = Path(filepath)
38
- if not path.exists():
39
- msg = f"Simulation data file not found: {filepath}"
40
- raise FileNotFoundError(msg)
41
-
42
- data = json.loads(path.read_text(encoding="utf-8"))
43
- sim_data: dict[str, Any] = data["data"]
44
-
45
- self._mspconfig = sim_data["msp_config"]
46
- self._telemetry = sim_data["telemetry"]
47
-
48
- _LOGGER.warning(
49
- "Running in simulation mode using data from '%s'. No API calls will be made to the OmniLogic controller.",
50
- filepath,
51
- )
52
-
53
- @overload
54
- async def async_get_mspconfig(self, raw: Literal[True]) -> str: ...
55
- @overload
56
- async def async_get_mspconfig(self, raw: Literal[False]) -> MSPConfig: ...
57
- @overload
58
- async def async_get_mspconfig(self) -> MSPConfig: ...
59
- async def async_get_mspconfig(self, raw: bool = False) -> MSPConfig | str:
60
- """Return the pre-loaded MSP config from the simulation file."""
61
- if raw:
62
- return self._mspconfig
63
- return MSPConfig.load_xml(self._mspconfig)
64
-
65
- @overload
66
- async def async_get_telemetry(self, raw: Literal[True]) -> str: ...
67
- @overload
68
- async def async_get_telemetry(self, raw: Literal[False]) -> Telemetry: ...
69
- @overload
70
- async def async_get_telemetry(self) -> Telemetry: ...
71
- async def async_get_telemetry(self, raw: bool = False) -> Telemetry | str:
72
- """Return the pre-loaded telemetry from the simulation file."""
73
- if raw:
74
- return self._telemetry
75
- return Telemetry.load_xml(self._telemetry)
76
-
77
- def __getattr__(self, name: str) -> Any:
78
- """Return a no-op async callable for any API method not explicitly implemented."""
79
-
80
- async def _noop(*args: Any, **kwargs: Any) -> None:
81
- _LOGGER.info(
82
- "Simulation mode: ignoring call to %s (args=%s, kwargs=%s)",
83
- name,
84
- args,
85
- kwargs,
86
- )
87
-
88
- return _noop