Skip to content

Commit 8c9d3fc

Browse files
authored
Add server choice to enum generation scripts and improve CozyTouch enums (#1965)
This pull request adds support for new device types and protocols, and enhances the `utils/generate_enums.py` script by making the Overkiz server configurable via a command-line argument. The main changes include new enum values for Sonos protocol and Swimming Pool Roller Shutter, as well as refactoring the enum generation functions to accept a server parameter. **Support for new device types and protocols:** * Added `SONOS` to the `Protocol` enum in `pyoverkiz/enums/protocol.py`, enabling support for Sonos Cloud Protocol. * Added `SWIMMING_POOL_ROLLER_SHUTTER` to the `UIWidget` enum in `pyoverkiz/enums/ui.py` and to the additional UI widgets in `utils/generate_enums.py`, supporting this device type. [[1]](diffhunk://#diff-368d6a95e10b422ae141b6d3169398def418a225f93d9077d460d25c0ee84c2dR404) [[2]](diffhunk://#diff-19b8541931ee689b317ebeae0e939ee362e28975b186431fbbbdf93abf1ad1a3R37-R50) * Added a comment for `MODBUSLINK` in the `ADDITIONAL_PROTOCOLS` list for clarity. **Enhancements to the enum generation script:** * Refactored `utils/generate_enums.py` so that `generate_protocol_enum`, `generate_ui_enums`, `generate_ui_profiles`, and `generate_all` accept a `server` argument, allowing the Overkiz server to be specified at runtime. [[1]](diffhunk://#diff-19b8541931ee689b317ebeae0e939ee362e28975b186431fbbbdf93abf1ad1a3R37-R50) [[2]](diffhunk://#diff-19b8541931ee689b317ebeae0e939ee362e28975b186431fbbbdf93abf1ad1a3L116-R127) [[3]](diffhunk://#diff-19b8541931ee689b317ebeae0e939ee362e28975b186431fbbbdf93abf1ad1a3L244-R255) [[4]](diffhunk://#diff-19b8541931ee689b317ebeae0e939ee362e28975b186431fbbbdf93abf1ad1a3L683-R718) * Added command-line argument parsing with argparse, enabling users to select the Overkiz server via the `--server` flag. [[1]](diffhunk://#diff-19b8541931ee689b317ebeae0e939ee362e28975b186431fbbbdf93abf1ad1a3R7) [[2]](diffhunk://#diff-19b8541931ee689b317ebeae0e939ee362e28975b186431fbbbdf93abf1ad1a3L683-R718)
1 parent 30a2fb2 commit 8c9d3fc

4 files changed

Lines changed: 151 additions & 79 deletions

File tree

docs/contribute.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ uv run pytest
3636
uv run prek run --all-files
3737
```
3838

39+
## Enum generation
40+
41+
Several enum files in `pyoverkiz/enums/` are **auto-generated** — do not edit them manually. The generator script (`utils/generate_enums.py`) fetches reference data from the Overkiz API and merges it with commands/state values found in local fixture files.
42+
43+
Generated files: `protocol.py`, `ui.py`, `ui_profile.py`, `command.py`.
44+
45+
### Running the generator
46+
47+
Run the script with credentials inline:
48+
49+
```bash
50+
OVERKIZ_USERNAME="your@email.com" OVERKIZ_PASSWORD="your-password" uv run utils/generate_enums.py
51+
```
52+
53+
By default the script connects to `somfy_europe`. Pass `--server` to use a different one (e.g. `atlantic_cozytouch`, `thermor_cozytouch`):
54+
55+
```bash
56+
uv run utils/generate_enums.py --server atlantic_cozytouch
57+
```
58+
59+
The generated files are automatically formatted with `ruff`.
60+
61+
Some protocols and widgets only exist on specific servers. These are hardcoded at the top of the script (`ADDITIONAL_PROTOCOLS`, `ADDITIONAL_WIDGETS`) and merged in automatically.
62+
63+
After regenerating, run linting and tests:
64+
65+
```bash
66+
uv run prek run --all-files
67+
uv run pytest
68+
```
69+
3970
## Project guidelines
4071

4172
- Use Python 3.10+ features and type annotations.

pyoverkiz/enums/protocol.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class Protocol(UnknownEnumMixin, StrEnum):
2929
IO = "io" # 1: IO HomeControl©
3030
JSW = "jsw" # 30: JSW Webservices
3131
MODBUS = "modbus" # 20: Modbus
32-
MODBUSLINK = "modbuslink"
32+
MODBUSLINK = "modbuslink" # 44: ModbusLink
3333
MYFOX = "myfox" # 25: MyFox Webservices
3434
NETATMO = "netatmo" # 38: Netatmo Webservices
3535
OGCP = "ogcp" # 62: Overkiz Generic Cloud Protocol
@@ -43,6 +43,7 @@ class Protocol(UnknownEnumMixin, StrEnum):
4343
RTN = "rtn"
4444
RTS = "rts" # 2: Somfy RTS
4545
SOMFY_THERMOSTAT = "somfythermostat" # 39: Somfy Thermostat Webservice
46+
SONOS = "sonos" # 52: Sonos Cloud Protocol
4647
UPNP_CONTROL = "upnpcontrol" # 43: UPnP Control
4748
VERISURE = "verisure" # 23: Verisure Webservices
4849
WISER = "wiser" # 54: Schneider Wiser

pyoverkiz/enums/ui.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ class UIWidget(UnknownEnumMixin, StrEnum):
401401
SUN_ENERGY_SENSOR = "SunEnergySensor"
402402
SUN_INTENSITY_SENSOR = "SunIntensitySensor"
403403
SWIMMING_POOL = "SwimmingPool"
404+
SWIMMING_POOL_ROLLER_SHUTTER = "SwimmingPoolRollerShutter"
404405
SWINGING_SHUTTER = "SwingingShutter"
405406
TSK_ALARM_CONTROLLER = "TSKAlarmController"
406407
TEMPERATURE_SENSOR = "TemperatureSensor"

utils/generate_enums.py

Lines changed: 117 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44

55
from __future__ import annotations
66

7+
import argparse
8+
import ast
79
import asyncio
10+
import json
811
import os
912
import re
13+
import subprocess
1014
from pathlib import Path
1115
from typing import cast
1216

@@ -17,32 +21,33 @@
1721
from pyoverkiz.models import UIProfileDefinition, ValuePrototype
1822

1923
# Hardcoded protocols that may not be available on all servers
20-
# Format: (name, prefix)
21-
ADDITIONAL_PROTOCOLS = [
22-
("HLRR_WIFI", "hlrrwifi"),
23-
("MODBUSLINK", "modbuslink"),
24-
("RTN", "rtn"),
24+
# Format: (name, prefix, id, label)
25+
ADDITIONAL_PROTOCOLS: list[tuple[str, str, int | None, str | None]] = [
26+
("HLRR_WIFI", "hlrrwifi", None, None),
27+
("MODBUSLINK", "modbuslink", 44, "ModbusLink"), # via Atlantic Cozytouch
28+
("RTN", "rtn", None, None),
2529
]
2630

2731
# Hardcoded widgets that may not be available on all servers
28-
# Format: (enum_name, value)
32+
# Enum names are derived automatically via to_enum_name()
2933
ADDITIONAL_WIDGETS = [
30-
("ALARM_PANEL_CONTROLLER", "AlarmPanelController"),
31-
("CYCLIC_GARAGE_DOOR", "CyclicGarageDoor"),
32-
("CYCLIC_SWINGING_GATE_OPENER", "CyclicSwingingGateOpener"),
33-
("DISCRETE_GATE_WITH_PEDESTRIAN_POSITION", "DiscreteGateWithPedestrianPosition"),
34-
("HLRR_WIFI_BRIDGE", "HLRRWifiBridge"),
35-
("NODE", "Node"),
34+
"AlarmPanelController",
35+
"CyclicGarageDoor",
36+
"CyclicSwingingGateOpener",
37+
"DiscreteGateWithPedestrianPosition",
38+
"HLRRWifiBridge",
39+
"Node",
40+
"SwimmingPoolRollerShutter", # via atlantic_cozytouch
3641
]
3742

3843

39-
async def generate_protocol_enum() -> None:
44+
async def generate_protocol_enum(server: Server) -> None:
4045
"""Generate the Protocol enum from the Overkiz API."""
4146
username = os.environ["OVERKIZ_USERNAME"]
4247
password = os.environ["OVERKIZ_PASSWORD"]
4348

4449
async with OverkizClient(
45-
server=Server.SOMFY_EUROPE,
50+
server=server,
4651
credentials=UsernamePasswordCredentials(username, password),
4752
) as client:
4853
await client.login()
@@ -56,9 +61,9 @@ async def generate_protocol_enum() -> None:
5661

5762
# Add hardcoded protocols that may not be on all servers (avoid duplicates)
5863
fetched_prefixes = {p.prefix for p in protocol_types}
59-
for name, prefix in ADDITIONAL_PROTOCOLS:
64+
for name, prefix, proto_id, proto_label in ADDITIONAL_PROTOCOLS:
6065
if prefix not in fetched_prefixes:
61-
protocols.append((name, prefix, None, None))
66+
protocols.append((name, prefix, proto_id, proto_label))
6267

6368
# Sort by name for consistent output
6469
protocols.sort(key=lambda p: p[0])
@@ -113,13 +118,13 @@ async def generate_protocol_enum() -> None:
113118
print(f"✓ Total: {len(protocols)} protocols")
114119

115120

116-
async def generate_ui_enums() -> None:
121+
async def generate_ui_enums(server: Server) -> None:
117122
"""Generate the UIClass and UIWidget enums from the Overkiz API."""
118123
username = os.environ["OVERKIZ_USERNAME"]
119124
password = os.environ["OVERKIZ_PASSWORD"]
120125

121126
async with OverkizClient(
122-
server=Server.SOMFY_EUROPE,
127+
server=server,
123128
credentials=UsernamePasswordCredentials(username, password),
124129
) as client:
125130
await client.login()
@@ -192,7 +197,7 @@ def to_enum_name(value: str) -> str:
192197

193198
# Add hardcoded widgets that may not be on all servers (avoid duplicates)
194199
fetched_widget_values = set(ui_widgets)
195-
for _enum_name, widget_value in ADDITIONAL_WIDGETS:
200+
for widget_value in ADDITIONAL_WIDGETS:
196201
if widget_value not in fetched_widget_values:
197202
sorted_widgets.append(widget_value)
198203

@@ -230,7 +235,11 @@ def to_enum_name(value: str) -> str:
230235
output_path.write_text("\n".join(lines))
231236

232237
additional_widget_count = len(
233-
[w for w in ADDITIONAL_WIDGETS if w[1] not in fetched_widget_values]
238+
[
239+
widget
240+
for widget in ADDITIONAL_WIDGETS
241+
if widget not in fetched_widget_values
242+
]
234243
)
235244

236245
print(f"✓ Generated {output_path}")
@@ -241,13 +250,13 @@ def to_enum_name(value: str) -> str:
241250
print(f"✓ Added {len(sorted_classifiers)} UI classifiers")
242251

243252

244-
async def generate_ui_profiles() -> None:
253+
async def generate_ui_profiles(server: Server) -> None:
245254
"""Generate the UIProfile enum from the Overkiz API."""
246255
username = os.environ["OVERKIZ_USERNAME"]
247256
password = os.environ["OVERKIZ_PASSWORD"]
248257

249258
async with OverkizClient(
250-
server=Server.SOMFY_EUROPE,
259+
server=server,
251260
credentials=UsernamePasswordCredentials(username, password),
252261
) as client:
253262
await client.login()
@@ -436,8 +445,6 @@ def extract_commands_from_fixtures(fixtures_dir: Path) -> set[str]:
436445
Reads all JSON fixture files and collects unique command names from device
437446
definitions. Commands are returned as camelCase values.
438447
"""
439-
import json
440-
441448
commands: set[str] = set()
442449

443450
for fixture_file in fixtures_dir.glob("*.json"):
@@ -470,8 +477,6 @@ def extract_state_values_from_fixtures(fixtures_dir: Path) -> set[str]:
470477
Reads all JSON fixture files and collects unique state values from device
471478
definitions. Values are extracted from DiscreteState types.
472479
"""
473-
import json
474-
475480
values: set[str] = set()
476481

477482
for fixture_file in fixtures_dir.glob("*.json"):
@@ -514,6 +519,44 @@ def command_to_enum_name(command_name: str) -> str:
514519
return name.upper()
515520

516521

522+
def extract_enum_members(content: str, class_name: str) -> dict[str, str]:
523+
"""Extract enum member names keyed by their string value from a class definition."""
524+
module = ast.parse(content)
525+
526+
for node in module.body:
527+
if not isinstance(node, ast.ClassDef) or node.name != class_name:
528+
continue
529+
530+
members: dict[str, str] = {}
531+
for statement in node.body:
532+
if not isinstance(statement, ast.Assign):
533+
continue
534+
if len(statement.targets) != 1:
535+
continue
536+
537+
target = statement.targets[0]
538+
if not isinstance(target, ast.Name):
539+
continue
540+
if not isinstance(statement.value, ast.Constant):
541+
continue
542+
if not isinstance(statement.value.value, str):
543+
continue
544+
545+
members[statement.value.value] = target.id
546+
547+
return members
548+
549+
raise ValueError(f"Could not find enum class {class_name}")
550+
551+
552+
def find_class_start(content: str, class_name: str) -> int:
553+
"""Return the start index of a generated enum class declaration."""
554+
class_start = content.find(f"@unique\nclass {class_name}")
555+
if class_start == -1:
556+
raise ValueError(f"Could not find class {class_name}")
557+
return class_start
558+
559+
517560
async def generate_command_enums() -> None:
518561
"""Generate the OverkizCommand enum and update OverkizCommandParam from fixture files."""
519562
fixtures_dir = Path(__file__).parent.parent / "tests" / "fixtures" / "setup"
@@ -526,51 +569,10 @@ async def generate_command_enums() -> None:
526569
command_file = Path(__file__).parent.parent / "pyoverkiz" / "enums" / "command.py"
527570
content = command_file.read_text()
528571

529-
# Find the OverkizCommandParam class
530-
param_class_start_idx = content.find("@unique\nclass OverkizCommandParam")
531-
command_mode_class_start_idx = content.find("@unique\nclass CommandMode")
572+
find_class_start(content, "CommandMode")
532573

533-
# Parse existing commands from OverkizCommand
534-
existing_commands: dict[str, str] = {}
535-
in_overkiz_command = False
536-
lines_before_param = content[:param_class_start_idx].split("\n")
537-
538-
for line in lines_before_param:
539-
if "class OverkizCommand" in line:
540-
in_overkiz_command = True
541-
continue
542-
if in_overkiz_command and line.strip() and not line.startswith(" "):
543-
break
544-
if in_overkiz_command and " = " in line and not line.strip().startswith("#"):
545-
parts = line.strip().split(" = ")
546-
if len(parts) == 2:
547-
enum_name = parts[0].strip()
548-
value_part = parts[1].split("#")[0].strip()
549-
if value_part.startswith('"') and value_part.endswith('"'):
550-
command_value = value_part[1:-1]
551-
existing_commands[command_value] = enum_name
552-
553-
# Parse existing parameters from OverkizCommandParam
554-
existing_params: dict[str, str] = {}
555-
in_param_class = False
556-
lines_param_section = content[
557-
param_class_start_idx:command_mode_class_start_idx
558-
].split("\n")
559-
560-
for line in lines_param_section:
561-
if "class OverkizCommandParam" in line:
562-
in_param_class = True
563-
continue
564-
if in_param_class and line.strip() and not line.startswith(" "):
565-
break
566-
if in_param_class and " = " in line and not line.strip().startswith("#"):
567-
parts = line.strip().split(" = ")
568-
if len(parts) == 2:
569-
enum_name = parts[0].strip()
570-
value_part = parts[1].split("#")[0].strip()
571-
if value_part.startswith('"') and value_part.endswith('"'):
572-
param_value = value_part[1:-1]
573-
existing_params[param_value] = enum_name
574+
existing_commands = extract_enum_members(content, "OverkizCommand")
575+
existing_params = extract_enum_members(content, "OverkizCommandParam")
574576

575577
# Merge: keep existing commands and add new ones from fixtures
576578
all_command_values = set(existing_commands.keys()) | fixture_commands
@@ -614,9 +616,6 @@ async def generate_command_enums() -> None:
614616
# Sort alphabetically by enum name
615617
param_tuples.sort(key=lambda x: x[0])
616618

617-
# Sort alphabetically by enum name
618-
param_tuples.sort(key=lambda x: x[0])
619-
620619
# Generate the enum file content
621620
lines = [
622621
'"""Command-related enums and parameters used by device commands."""',
@@ -680,16 +679,56 @@ async def generate_command_enums() -> None:
680679
print(f"✓ Total: {len(all_param_values)} parameters")
681680

682681

683-
async def generate_all() -> None:
682+
def format_generated_files() -> None:
683+
"""Run ruff fixes and formatting on all generated enum files."""
684+
enums_dir = Path(__file__).parent.parent / "pyoverkiz" / "enums"
685+
generated_files = [
686+
str(enums_dir / "protocol.py"),
687+
str(enums_dir / "ui.py"),
688+
str(enums_dir / "ui_profile.py"),
689+
str(enums_dir / "command.py"),
690+
]
691+
subprocess.run( # noqa: S603
692+
["uv", "run", "ruff", "check", "--fix", *generated_files], # noqa: S607
693+
check=True,
694+
)
695+
subprocess.run( # noqa: S603
696+
["uv", "run", "ruff", "format", *generated_files], # noqa: S607
697+
check=True,
698+
)
699+
print("✓ Formatted generated files with ruff")
700+
701+
702+
async def generate_all(server: Server) -> None:
684703
"""Generate all enums from the Overkiz API."""
685-
await generate_protocol_enum()
704+
print(f"Using server: {server.name} ({server.value})")
705+
print()
706+
await generate_protocol_enum(server)
686707
print()
687-
await generate_ui_enums()
708+
await generate_ui_enums(server)
688709
print()
689-
await generate_ui_profiles()
710+
await generate_ui_profiles(server)
690711
print()
691712
await generate_command_enums()
713+
print()
714+
format_generated_files()
715+
716+
717+
def parse_args() -> argparse.Namespace:
718+
"""Parse command-line arguments."""
719+
server_choices = [s.value for s in Server]
720+
parser = argparse.ArgumentParser(
721+
description="Generate enum files from the Overkiz API."
722+
)
723+
parser.add_argument(
724+
"--server",
725+
choices=server_choices,
726+
default=Server.SOMFY_EUROPE.value,
727+
help=f"Server to connect to (default: {Server.SOMFY_EUROPE.value})",
728+
)
729+
return parser.parse_args()
692730

693731

694732
if __name__ == "__main__":
695-
asyncio.run(generate_all())
733+
args = parse_args()
734+
asyncio.run(generate_all(Server(args.server)))

0 commit comments

Comments
 (0)