conson-xp 1.51.0__py3-none-any.whl → 1.51.1__py3-none-any.whl
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.
- {conson_xp-1.51.0.dist-info → conson_xp-1.51.1.dist-info}/METADATA +2 -1
- {conson_xp-1.51.0.dist-info → conson_xp-1.51.1.dist-info}/RECORD +18 -13
- xp/__init__.py +1 -1
- xp/cli/commands/term/term_commands.py +23 -0
- xp/models/homekit/homekit_config.py +2 -0
- xp/models/term/__init__.py +2 -0
- xp/models/term/accessory_state.py +50 -0
- xp/services/homekit/homekit_config_validator.py +1 -1
- xp/services/term/homekit_service.py +512 -0
- xp/services/term/state_monitor_service.py +1 -1
- xp/term/homekit.py +116 -0
- xp/term/homekit.tcss +86 -0
- xp/term/widgets/room_list.py +232 -0
- xp/term/widgets/status_footer.py +6 -3
- xp/utils/dependencies.py +21 -0
- {conson_xp-1.51.0.dist-info → conson_xp-1.51.1.dist-info}/WHEEL +0 -0
- {conson_xp-1.51.0.dist-info → conson_xp-1.51.1.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.51.0.dist-info → conson_xp-1.51.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: conson-xp
|
|
3
|
-
Version: 1.51.
|
|
3
|
+
Version: 1.51.1
|
|
4
4
|
Summary: XP Protocol Communication Tools
|
|
5
5
|
Author-Email: ldvchosal <ldvchosal@github.com>
|
|
6
6
|
License: MIT License
|
|
@@ -450,6 +450,7 @@ xp telegram version
|
|
|
450
450
|
|
|
451
451
|
|
|
452
452
|
xp term
|
|
453
|
+
xp term homekit
|
|
453
454
|
xp term protocol
|
|
454
455
|
xp term state
|
|
455
456
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
conson_xp-1.51.
|
|
2
|
-
conson_xp-1.51.
|
|
3
|
-
conson_xp-1.51.
|
|
4
|
-
conson_xp-1.51.
|
|
5
|
-
xp/__init__.py,sha256=
|
|
1
|
+
conson_xp-1.51.1.dist-info/METADATA,sha256=15wTMgVInRD7kUjYvZe3AtZPgmPGG_XcXsaGRB3Vjds,11448
|
|
2
|
+
conson_xp-1.51.1.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
|
|
3
|
+
conson_xp-1.51.1.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
|
|
4
|
+
conson_xp-1.51.1.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
|
|
5
|
+
xp/__init__.py,sha256=PTmuTUb4TXiPuSSkKX3UT63IhH_fx4EpMP33cMsA6Nc,182
|
|
6
6
|
xp/cli/__init__.py,sha256=QjnKB1KaI2aIyKlzrnvCwfbBuUj8HNgwNMvNJVQofbI,81
|
|
7
7
|
xp/cli/__main__.py,sha256=l2iKwMdat5rTGd3JWs-uGksnYYDDffp_Npz05QdKEeU,117
|
|
8
8
|
xp/cli/commands/__init__.py,sha256=G7A1KFRSV0CEeDTqr_khu-K9_sc01CTI2KSfkFcaBRM,4949
|
|
@@ -43,7 +43,7 @@ xp/cli/commands/telegram/telegram_parse_commands.py,sha256=xCDRRFgj41RtStvwROfi-
|
|
|
43
43
|
xp/cli/commands/telegram/telegram_version_commands.py,sha256=hAMjSAa7zfMNfNFln63sKeNPcmW89bISkcVs6BgsqOg,1558
|
|
44
44
|
xp/cli/commands/term/__init__.py,sha256=1NNST_8YJfj5LCujQISwQflK6LyEn7mDmZpMpvI9d-o,116
|
|
45
45
|
xp/cli/commands/term/term.py,sha256=gjvsv2OE-F_KNWQrWi04fXQ5cGo0l8P-Ortbb5KTA-A,309
|
|
46
|
-
xp/cli/commands/term/term_commands.py,sha256=
|
|
46
|
+
xp/cli/commands/term/term_commands.py,sha256=Z3pq_xzP0j5YfYFOwASaZUxXDkikSV_E4cFjMb69LUU,1796
|
|
47
47
|
xp/cli/main.py,sha256=Wbtji5ddW3IEoAfecHrEPk8W_w1bGD20B-NqAWfI_F4,1968
|
|
48
48
|
xp/cli/utils/__init__.py,sha256=gTGIj60Uai0iE7sr9_TtEpl04fD7krtTzbbigXUsUVU,46
|
|
49
49
|
xp/cli/utils/click_tree.py,sha256=sr4l9RWTCnASkdvkJKnRRxWSQPlF1DbFdBNu9gL7Ekc,1693
|
|
@@ -84,7 +84,7 @@ xp/models/config/__init__.py,sha256=gEZnX9eE3DjFtLtF32riEjJQLypqQRbyPauBI4Cowbs,
|
|
|
84
84
|
xp/models/config/conson_module_config.py,sha256=t1G0LnNNMnjs3ahhz4-Z_5SlEv2FCrcRq13OmvZ2pvA,3009
|
|
85
85
|
xp/models/homekit/__init__.py,sha256=5HDSOClCu0ArK3IICn3_LDMMLBAzLjBxUUSF73bxSSk,34
|
|
86
86
|
xp/models/homekit/homekit_accessory.py,sha256=ANjDWlFxeNTstl7lKdmf6vMOC0wc005vpiD6awRcptA,1052
|
|
87
|
-
xp/models/homekit/homekit_config.py,sha256=
|
|
87
|
+
xp/models/homekit/homekit_config.py,sha256=EqoiZ1E6l9bBjxKqK1nxVGRfFY5ZtRHH-jZhYtRH2gU,3048
|
|
88
88
|
xp/models/log_entry.py,sha256=tAiNwouCP2d4jKiHJY9a-2iAi8LWTpG-TZsOPDIstlA,4423
|
|
89
89
|
xp/models/protocol/__init__.py,sha256=TJ_CJKchA-xgQiv5vCo_ndBBZjrcaTmjT74bR0T-5Cw,38
|
|
90
90
|
xp/models/protocol/conbus_protocol.py,sha256=hF78N5xvBzMiyWoKd8i_avA8kJ1As_9Pplkw1GMqKzk,9145
|
|
@@ -105,7 +105,8 @@ xp/models/telegram/system_telegram.py,sha256=064AlFi_WghoYlVDUtIIbvwtZyUEZj7_auK
|
|
|
105
105
|
xp/models/telegram/telegram.py,sha256=-kNloBlwMJ5w1-FAMSLzBPnyOGUNEBG3SG2d0eTi2PY,847
|
|
106
106
|
xp/models/telegram/telegram_type.py,sha256=IjGEosbs7IDqYT7ktn-FcKS-kAJ4eXW-KJGkkoAGysw,428
|
|
107
107
|
xp/models/telegram/timeparam_type.py,sha256=z5EQ32SQjDi7zKshtkvDzqaMfPMUeXCWKEGI5VgvBvU,1142
|
|
108
|
-
xp/models/term/__init__.py,sha256=
|
|
108
|
+
xp/models/term/__init__.py,sha256=VVZsEyXBEr-TnBlrFFifZ6PjJHUl2kwnRUZx_kC2Ljg,343
|
|
109
|
+
xp/models/term/accessory_state.py,sha256=GcMtCxOeHpc3IPDO1F9j2I6rdhaNV75iQ2md--XY6jo,1650
|
|
109
110
|
xp/models/term/connection_state.py,sha256=oYcst01uH35kO541jGuXMqvJ2iduiHYryUsMK0d89pQ,1807
|
|
110
111
|
xp/models/term/module_state.py,sha256=i7u8y_B5ScMRULQb_kMSD_wwKzbrLHlkECsTgNS46PQ,939
|
|
111
112
|
xp/models/term/protocol_keys_config.py,sha256=tSlkxEwgQuVRYLTaUNd569osQsNCdb9ED4InNgX9rKo,1223
|
|
@@ -145,7 +146,7 @@ xp/services/conbus/write_config_service.py,sha256=BCfmLNPRDpwSwRMRYJvx2FXA8IZsdg
|
|
|
145
146
|
xp/services/homekit/__init__.py,sha256=xAMKmln_AmEFdOOJGKWYi96seRlKDQpKx3-hm7XbdIo,36
|
|
146
147
|
xp/services/homekit/homekit_cache_service.py,sha256=z1TB6icEqd1paoilVTewuFL0lXVCQbvrOJkJvvQECJY,11060
|
|
147
148
|
xp/services/homekit/homekit_conbus_service.py,sha256=XPKv7Mit1rn7XLaQZcKmlMMUlyj-o0J2z8XBH3NaEIM,3390
|
|
148
|
-
xp/services/homekit/homekit_config_validator.py,sha256=
|
|
149
|
+
xp/services/homekit/homekit_config_validator.py,sha256=jf09jHIFbZg7YpDbGsGHT1p4a1vpUED2xR6iZN19cfM,10875
|
|
149
150
|
xp/services/homekit/homekit_conson_validator.py,sha256=tmUxBzytX9FbUWTR1XdbAi_qb_whAdGPSaml98Czszg,3858
|
|
150
151
|
xp/services/homekit/homekit_dimminglight.py,sha256=EzfGhy3zZkbFPfN72Dh_eSb5mJQOpxGi6ZwnyEOSHxU,5819
|
|
151
152
|
xp/services/homekit/homekit_dimminglight_service.py,sha256=0Ve6cXtY7v7JCv7gibOBWjPfCU7KK4Lk6GjIu9_GhyE,5282
|
|
@@ -184,9 +185,12 @@ xp/services/telegram/telegram_output_service.py,sha256=9deqtcPndRqJ-3XQUWlJhXaVc
|
|
|
184
185
|
xp/services/telegram/telegram_service.py,sha256=jPu0Xrh3IpvqPLyuQT5Vf8HHw00vBingONHdxf_9TkI,13315
|
|
185
186
|
xp/services/telegram/telegram_version_service.py,sha256=oXnZ_K7OQ7xD-GEj3zDYp52KlkqVuHpO4bf7gMlC_w4,10574
|
|
186
187
|
xp/services/term/__init__.py,sha256=BIeOK042bMR-0l6MA80wdW5VuHlpWOXtRER9IG5ilQA,245
|
|
188
|
+
xp/services/term/homekit_service.py,sha256=3g6Twhg6wcRT2E01Daa2J41lzdyX6gVjfz5wgutOvgQ,19307
|
|
187
189
|
xp/services/term/protocol_monitor_service.py,sha256=5YBI0Nu7B7gMhaTbUhL6k9LSRfnCIj6CwrCYHiMHavA,10067
|
|
188
|
-
xp/services/term/state_monitor_service.py,sha256=
|
|
190
|
+
xp/services/term/state_monitor_service.py,sha256=EK9tNBfamAIV0z0EMsXDYWC-rXv6l6k_bHsC8xyEFSo,17116
|
|
189
191
|
xp/term/__init__.py,sha256=Xg2DhBeI3xQJLfc7_BPWI1por-rUXemyer5OtOt9Cus,51
|
|
192
|
+
xp/term/homekit.py,sha256=IYJEVhQUFUcmC_TubYxcvlU5IKF-2sL_giBNKO35fjU,3642
|
|
193
|
+
xp/term/homekit.tcss,sha256=qeR_OV8D_9Mxb-aPNz-MH0ZJOsdCk-fJ-zv6CQV5ihw,1382
|
|
190
194
|
xp/term/protocol.py,sha256=6MX3mduLei-AgLGaIe8lfOSu4Hi0y3KGePFFM2ssstc,3475
|
|
191
195
|
xp/term/protocol.tcss,sha256=r_KfxrbpycGHLVXqZc6INBBcUJME0hLrAZkF1oqnab4,2126
|
|
192
196
|
xp/term/state.py,sha256=FBpYV_bWYJh9o17qcMx6sHgUARQS-uNOtUt6G7Vs1n8,3274
|
|
@@ -195,13 +199,14 @@ xp/term/widgets/__init__.py,sha256=ftWmN_fmjxy2E8Qfm-YSRmzKfgL0KTBCTpgvYWCPbUY,2
|
|
|
195
199
|
xp/term/widgets/help_menu.py,sha256=KLkdIXfhARLFNEs2lv1u0sYBz9LzOCcDLxbMGzc7e5Y,1812
|
|
196
200
|
xp/term/widgets/modules_list.py,sha256=qAG-n0nK0YdNE9v4C3-sHgxLvF1i1FR7v_GArdaoUQw,7831
|
|
197
201
|
xp/term/widgets/protocol_log.py,sha256=E68QmSMpOFrvrPTo_gOQVfyiDqY5c_y8fkNKnQw6Vwo,2650
|
|
198
|
-
xp/term/widgets/
|
|
202
|
+
xp/term/widgets/room_list.py,sha256=3q3otusnQn4qFRbTY0-QbpMP3vPmywM0izYRA_KjXn0,7871
|
|
203
|
+
xp/term/widgets/status_footer.py,sha256=biV3EzfVSgm1T7Ofi88LXsTFCkD5mI_6Cpe-RpuOSxA,3429
|
|
199
204
|
xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
|
|
200
205
|
xp/utils/checksum.py,sha256=Px1S3dFGA-_plavBxrq3IqmprNlgtNDunE3whg6Otwg,1722
|
|
201
|
-
xp/utils/dependencies.py,sha256=
|
|
206
|
+
xp/utils/dependencies.py,sha256=XHOAq5nIbXD8oq4FBp3wWvinDa4Ti7cUktJtUB0z51A,25339
|
|
202
207
|
xp/utils/event_helper.py,sha256=zD0K3TPfGEThU9vUNlDtglTai3Cmm30727iwjDZy6Dk,1007
|
|
203
208
|
xp/utils/logging.py,sha256=wJ1d-yg97NiZUrt2F8iDMcmnHVwC-PErcI-7dpyiRDc,3777
|
|
204
209
|
xp/utils/serialization.py,sha256=TS1OwpTOemSvXsCGw3js4JkYYFEqkzrPe8V9QYQefdw,4684
|
|
205
210
|
xp/utils/state_machine.py,sha256=W9AY4ntRZnFeHAa5d43hm37j53uJPlqkRvWTPiBhJ_0,2464
|
|
206
211
|
xp/utils/time_utils.py,sha256=K17godWpL18VEypbTlvNOEDG6R3huYnf29yjkcnwRpU,3796
|
|
207
|
-
conson_xp-1.51.
|
|
212
|
+
conson_xp-1.51.1.dist-info/RECORD,,
|
xp/__init__.py
CHANGED
|
@@ -48,3 +48,26 @@ def state_monitor(ctx: Context) -> None:
|
|
|
48
48
|
|
|
49
49
|
# Resolve StateMonitorApp from container and run
|
|
50
50
|
ctx.obj.get("container").get_container().resolve(StateMonitorApp).run()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@term.command("homekit")
|
|
54
|
+
@click.pass_context
|
|
55
|
+
def homekit_monitor(ctx: Context) -> None:
|
|
56
|
+
r"""
|
|
57
|
+
Start TUI for HomeKit accessory monitoring.
|
|
58
|
+
|
|
59
|
+
Displays HomeKit rooms and accessories with real-time state updates
|
|
60
|
+
in an interactive terminal interface. Press action keys (a-z) to
|
|
61
|
+
toggle accessories.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
ctx: Click context object.
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
\b
|
|
68
|
+
xp term homekit
|
|
69
|
+
"""
|
|
70
|
+
from xp.term.homekit import HomekitApp
|
|
71
|
+
|
|
72
|
+
# Resolve HomekitApp from container and run
|
|
73
|
+
ctx.obj.get("container").get_container().resolve(HomekitApp).run()
|
|
@@ -61,6 +61,7 @@ class HomekitAccessoryConfig(BaseModel):
|
|
|
61
61
|
service: Service type for the accessory.
|
|
62
62
|
on_action: on code for the accessory.
|
|
63
63
|
off_action: off code for the accessory.
|
|
64
|
+
toggle_action: Optional toggle action code for the accessory.
|
|
64
65
|
hap_accessory: Optional HAP accessory identifier.
|
|
65
66
|
"""
|
|
66
67
|
|
|
@@ -72,6 +73,7 @@ class HomekitAccessoryConfig(BaseModel):
|
|
|
72
73
|
service: str
|
|
73
74
|
on_action: str
|
|
74
75
|
off_action: str
|
|
76
|
+
toggle_action: Optional[str] = None
|
|
75
77
|
hap_accessory: Optional[int] = None
|
|
76
78
|
|
|
77
79
|
|
xp/models/term/__init__.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Terminal UI models."""
|
|
2
2
|
|
|
3
|
+
from xp.models.term.accessory_state import AccessoryState
|
|
3
4
|
from xp.models.term.module_state import ModuleState
|
|
4
5
|
from xp.models.term.protocol_keys_config import (
|
|
5
6
|
ProtocolKeyConfig,
|
|
@@ -7,6 +8,7 @@ from xp.models.term.protocol_keys_config import (
|
|
|
7
8
|
)
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
11
|
+
"AccessoryState",
|
|
10
12
|
"ModuleState",
|
|
11
13
|
"ProtocolKeyConfig",
|
|
12
14
|
"ProtocolKeysConfig",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Accessory state data model for Homekit TUI."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class AccessoryState:
|
|
10
|
+
"""
|
|
11
|
+
State of a HomeKit accessory for TUI display.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
room_name: Room containing the accessory (e.g., "Salon").
|
|
15
|
+
accessory_name: Accessory display name (e.g., "Variateur salon").
|
|
16
|
+
action: Action key (a-z) for toggle control.
|
|
17
|
+
output_state: Output state ("ON", "OFF", "?").
|
|
18
|
+
dimming_state: Dimming percentage for dimmable modules, "-" if OFF, empty otherwise.
|
|
19
|
+
module_name: Module identifier (e.g., "A12").
|
|
20
|
+
serial_number: Module serial number.
|
|
21
|
+
module_type: Module type (e.g., "XP24", "XP33LED").
|
|
22
|
+
error_status: Status code ("OK" or error like "E10").
|
|
23
|
+
output: Module output number (1-based for display).
|
|
24
|
+
sort: Sort accessories according to homekit.yml configuration.
|
|
25
|
+
last_update: Last communication timestamp. None if never updated.
|
|
26
|
+
toggle_action: Raw toggle action telegram (e.g., "E02L12I02").
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
room_name: str
|
|
30
|
+
accessory_name: str
|
|
31
|
+
action: str
|
|
32
|
+
output_state: str
|
|
33
|
+
dimming_state: str
|
|
34
|
+
module_name: str
|
|
35
|
+
serial_number: str
|
|
36
|
+
module_type: str
|
|
37
|
+
error_status: str
|
|
38
|
+
output: int
|
|
39
|
+
sort: int
|
|
40
|
+
last_update: Optional[datetime] = None
|
|
41
|
+
toggle_action: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
def is_dimmable(self) -> bool:
|
|
44
|
+
"""
|
|
45
|
+
Check if accessory is dimmable.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
True if module type is XP33LR or XP33LED, False otherwise.
|
|
49
|
+
"""
|
|
50
|
+
return self.module_type in ("XP33LR", "XP33LED")
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""HomeKit Service for terminal interface."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from psygnal import Signal
|
|
8
|
+
|
|
9
|
+
from xp.models.config.conson_module_config import ConsonModuleListConfig
|
|
10
|
+
from xp.models.homekit.homekit_config import HomekitAccessoryConfig, HomekitConfig
|
|
11
|
+
from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
|
|
12
|
+
from xp.models.telegram.datapoint_type import DataPointType
|
|
13
|
+
from xp.models.telegram.module_type_code import ModuleTypeCode
|
|
14
|
+
from xp.models.telegram.system_function import SystemFunction
|
|
15
|
+
from xp.models.telegram.telegram_type import TelegramType
|
|
16
|
+
from xp.models.term.accessory_state import AccessoryState
|
|
17
|
+
from xp.models.term.connection_state import ConnectionState
|
|
18
|
+
from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
|
|
19
|
+
from xp.services.telegram.telegram_output_service import TelegramOutputService
|
|
20
|
+
from xp.services.telegram.telegram_service import TelegramService
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HomekitService:
|
|
24
|
+
"""
|
|
25
|
+
Service for HomeKit accessory monitoring in terminal interface.
|
|
26
|
+
|
|
27
|
+
Wraps ConbusEventProtocol, HomekitConfig, and ConsonModuleListConfig to provide
|
|
28
|
+
high-level accessory state tracking for the TUI.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
on_connection_state_changed: Signal emitted when connection state changes.
|
|
32
|
+
on_room_list_updated: Signal emitted when accessory list refreshed from config.
|
|
33
|
+
on_module_state_changed: Signal emitted when individual accessory state updates.
|
|
34
|
+
on_module_error: Signal emitted when module error occurs.
|
|
35
|
+
on_status_message: Signal emitted for status messages.
|
|
36
|
+
connection_state: Property returning current connection state.
|
|
37
|
+
server_info: Property returning server connection info (IP:port).
|
|
38
|
+
accessory_states: Property returning list of all accessory states.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
on_connection_state_changed: Signal = Signal(ConnectionState)
|
|
42
|
+
on_room_list_updated: Signal = Signal(list)
|
|
43
|
+
on_module_state_changed: Signal = Signal(AccessoryState)
|
|
44
|
+
on_module_error: Signal = Signal(str, str)
|
|
45
|
+
on_status_message: Signal = Signal(str)
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
conbus_protocol: ConbusEventProtocol,
|
|
50
|
+
homekit_config: HomekitConfig,
|
|
51
|
+
conson_config: ConsonModuleListConfig,
|
|
52
|
+
telegram_service: TelegramService,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Initialize the HomeKit service.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
conbus_protocol: ConbusEventProtocol instance.
|
|
59
|
+
homekit_config: HomekitConfig for accessory configuration.
|
|
60
|
+
conson_config: ConsonModuleListConfig for module configuration.
|
|
61
|
+
telegram_service: TelegramService for parsing telegrams.
|
|
62
|
+
"""
|
|
63
|
+
self.logger = logging.getLogger(__name__)
|
|
64
|
+
self._conbus_protocol = conbus_protocol
|
|
65
|
+
self._homekit_config = homekit_config
|
|
66
|
+
self._conson_config = conson_config
|
|
67
|
+
self._telegram_service = telegram_service
|
|
68
|
+
self._connection_state = ConnectionState.DISCONNECTED
|
|
69
|
+
self._state_machine = ConnectionState.create_state_machine()
|
|
70
|
+
|
|
71
|
+
# Accessory states keyed by unique identifier (e.g., "A12_1")
|
|
72
|
+
self._accessory_states: Dict[str, AccessoryState] = {}
|
|
73
|
+
|
|
74
|
+
# Action key to accessory ID mapping
|
|
75
|
+
self._action_map: Dict[str, str] = {}
|
|
76
|
+
|
|
77
|
+
# Connect to protocol signals
|
|
78
|
+
self._connect_signals()
|
|
79
|
+
|
|
80
|
+
# Initialize accessory states from config
|
|
81
|
+
self._initialize_accessory_states()
|
|
82
|
+
|
|
83
|
+
def _initialize_accessory_states(self) -> None:
|
|
84
|
+
"""Initialize accessory states from HomekitConfig and ConsonModuleListConfig."""
|
|
85
|
+
action_keys = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
86
|
+
action_index = 0
|
|
87
|
+
sort_order = 0
|
|
88
|
+
|
|
89
|
+
for room in self._homekit_config.bridge.rooms:
|
|
90
|
+
for accessory_name in room.accessories:
|
|
91
|
+
accessory_config = self._find_accessory_config(accessory_name)
|
|
92
|
+
if not accessory_config:
|
|
93
|
+
self.logger.warning(
|
|
94
|
+
f"Accessory config not found for {accessory_name}"
|
|
95
|
+
)
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
module_config = self._conson_config.find_module(
|
|
99
|
+
accessory_config.serial_number
|
|
100
|
+
)
|
|
101
|
+
if not module_config:
|
|
102
|
+
self.logger.warning(
|
|
103
|
+
f"Module config not found for {accessory_config.serial_number}"
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Create unique identifier
|
|
108
|
+
accessory_id = (
|
|
109
|
+
f"{module_config.name}_{accessory_config.output_number + 1}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Assign action key
|
|
113
|
+
action_key = (
|
|
114
|
+
action_keys[action_index] if action_index < len(action_keys) else ""
|
|
115
|
+
)
|
|
116
|
+
action_index += 1
|
|
117
|
+
sort_order += 1
|
|
118
|
+
|
|
119
|
+
state = AccessoryState(
|
|
120
|
+
room_name=room.name,
|
|
121
|
+
accessory_name=accessory_config.description
|
|
122
|
+
or accessory_config.name,
|
|
123
|
+
action=action_key,
|
|
124
|
+
output_state="?",
|
|
125
|
+
dimming_state="",
|
|
126
|
+
module_name=module_config.name,
|
|
127
|
+
serial_number=accessory_config.serial_number,
|
|
128
|
+
module_type=module_config.module_type,
|
|
129
|
+
error_status="OK",
|
|
130
|
+
output=accessory_config.output_number + 1, # 1-based
|
|
131
|
+
sort=sort_order,
|
|
132
|
+
last_update=None,
|
|
133
|
+
toggle_action=accessory_config.toggle_action,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self._accessory_states[accessory_id] = state
|
|
137
|
+
if action_key:
|
|
138
|
+
self._action_map[action_key] = accessory_id
|
|
139
|
+
|
|
140
|
+
def _find_accessory_config(self, name: str) -> Optional[HomekitAccessoryConfig]:
|
|
141
|
+
"""
|
|
142
|
+
Find accessory config by name.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
name: Accessory name to find.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
HomekitAccessoryConfig if found, None otherwise.
|
|
149
|
+
"""
|
|
150
|
+
for accessory in self._homekit_config.accessories:
|
|
151
|
+
if accessory.name == name:
|
|
152
|
+
return accessory
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def _connect_signals(self) -> None:
|
|
156
|
+
"""Connect to protocol signals."""
|
|
157
|
+
self._conbus_protocol.on_connection_made.connect(self._on_connection_made)
|
|
158
|
+
self._conbus_protocol.on_connection_failed.connect(self._on_connection_failed)
|
|
159
|
+
self._conbus_protocol.on_telegram_received.connect(self._on_telegram_received)
|
|
160
|
+
self._conbus_protocol.on_timeout.connect(self._on_timeout)
|
|
161
|
+
self._conbus_protocol.on_failed.connect(self._on_failed)
|
|
162
|
+
|
|
163
|
+
def _disconnect_signals(self) -> None:
|
|
164
|
+
"""Disconnect from protocol signals."""
|
|
165
|
+
self._conbus_protocol.on_connection_made.disconnect(self._on_connection_made)
|
|
166
|
+
self._conbus_protocol.on_connection_failed.disconnect(
|
|
167
|
+
self._on_connection_failed
|
|
168
|
+
)
|
|
169
|
+
self._conbus_protocol.on_telegram_received.disconnect(
|
|
170
|
+
self._on_telegram_received
|
|
171
|
+
)
|
|
172
|
+
self._conbus_protocol.on_timeout.disconnect(self._on_timeout)
|
|
173
|
+
self._conbus_protocol.on_failed.disconnect(self._on_failed)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def connection_state(self) -> ConnectionState:
|
|
177
|
+
"""
|
|
178
|
+
Get current connection state.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Current connection state.
|
|
182
|
+
"""
|
|
183
|
+
return self._connection_state
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def server_info(self) -> str:
|
|
187
|
+
"""
|
|
188
|
+
Get server connection info (IP:port).
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Server address in format "IP:port".
|
|
192
|
+
"""
|
|
193
|
+
return f"{self._conbus_protocol.cli_config.ip}:{self._conbus_protocol.cli_config.port}"
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def accessory_states(self) -> List[AccessoryState]:
|
|
197
|
+
"""
|
|
198
|
+
Get all accessory states.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
List of all accessory states.
|
|
202
|
+
"""
|
|
203
|
+
accessories = list(self._accessory_states.values())
|
|
204
|
+
# Sort modules by link_number
|
|
205
|
+
accessories.sort(key=lambda a: a.sort)
|
|
206
|
+
return accessories
|
|
207
|
+
|
|
208
|
+
def connect(self) -> None:
|
|
209
|
+
"""Initiate connection to server."""
|
|
210
|
+
if not self._state_machine.can_transition("connect"):
|
|
211
|
+
self.logger.warning(
|
|
212
|
+
f"Cannot connect: current state is {self._connection_state.value}"
|
|
213
|
+
)
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
|
|
217
|
+
self._connection_state = ConnectionState.CONNECTING
|
|
218
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
219
|
+
self.on_status_message.emit(f"Connecting to {self.server_info}...")
|
|
220
|
+
|
|
221
|
+
self._conbus_protocol.connect()
|
|
222
|
+
|
|
223
|
+
def disconnect(self) -> None:
|
|
224
|
+
"""Disconnect from server."""
|
|
225
|
+
if not self._state_machine.can_transition("disconnect"):
|
|
226
|
+
self.logger.warning(
|
|
227
|
+
f"Cannot disconnect: current state is {self._connection_state.value}"
|
|
228
|
+
)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
if self._state_machine.transition(
|
|
232
|
+
"disconnecting", ConnectionState.DISCONNECTING
|
|
233
|
+
):
|
|
234
|
+
self._connection_state = ConnectionState.DISCONNECTING
|
|
235
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
236
|
+
self.on_status_message.emit("Disconnecting...")
|
|
237
|
+
|
|
238
|
+
self._conbus_protocol.disconnect()
|
|
239
|
+
|
|
240
|
+
if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
|
|
241
|
+
self._connection_state = ConnectionState.DISCONNECTED
|
|
242
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
243
|
+
self.on_status_message.emit("Disconnected")
|
|
244
|
+
|
|
245
|
+
def toggle_connection(self) -> None:
|
|
246
|
+
"""
|
|
247
|
+
Toggle connection state between connected and disconnected.
|
|
248
|
+
|
|
249
|
+
Disconnects if currently connected or connecting. Connects if currently
|
|
250
|
+
disconnected or failed.
|
|
251
|
+
"""
|
|
252
|
+
if self._connection_state in (
|
|
253
|
+
ConnectionState.CONNECTED,
|
|
254
|
+
ConnectionState.CONNECTING,
|
|
255
|
+
):
|
|
256
|
+
self.disconnect()
|
|
257
|
+
else:
|
|
258
|
+
self.connect()
|
|
259
|
+
|
|
260
|
+
def toggle_accessory(self, action_key: str) -> bool:
|
|
261
|
+
"""
|
|
262
|
+
Toggle accessory by action key.
|
|
263
|
+
|
|
264
|
+
Sends the toggle_action telegram for the accessory mapped to the given key.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
action_key: Action key (a-z).
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
True if toggle was sent, False otherwise.
|
|
271
|
+
"""
|
|
272
|
+
accessory_id = self._action_map.get(action_key)
|
|
273
|
+
if not accessory_id:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
state = self._accessory_states.get(accessory_id)
|
|
277
|
+
if not state or not state.toggle_action:
|
|
278
|
+
self.logger.warning(f"No toggle_action for accessory {accessory_id}")
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
self._conbus_protocol.send_raw_telegram(state.toggle_action)
|
|
282
|
+
self.on_status_message.emit(f"Toggling {state.accessory_name}")
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
def refresh_all(self) -> None:
|
|
286
|
+
"""
|
|
287
|
+
Refresh all module states.
|
|
288
|
+
|
|
289
|
+
Queries module_output_state datapoint for eligible modules (XP24, XP33LR,
|
|
290
|
+
XP33LED). Updates outputs column and last_update timestamp for each queried
|
|
291
|
+
module.
|
|
292
|
+
"""
|
|
293
|
+
self.on_status_message.emit("Refreshing module states...")
|
|
294
|
+
|
|
295
|
+
# Eligible module types that support output state queries
|
|
296
|
+
eligible_types = {"XP24", "XP33LR", "XP33LED"}
|
|
297
|
+
|
|
298
|
+
# Track already queried serial numbers to avoid duplicates
|
|
299
|
+
queried_serials: set[str] = set()
|
|
300
|
+
|
|
301
|
+
for state in self._accessory_states.values():
|
|
302
|
+
if (
|
|
303
|
+
state.module_type in eligible_types
|
|
304
|
+
and state.serial_number not in queried_serials
|
|
305
|
+
):
|
|
306
|
+
self._query_module_output_state(state.serial_number)
|
|
307
|
+
queried_serials.add(state.serial_number)
|
|
308
|
+
self.logger.debug(
|
|
309
|
+
f"Querying output state for {state.module_name} ({state.module_type})"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def _query_module_output_state(self, serial_number: str) -> None:
|
|
313
|
+
"""
|
|
314
|
+
Query module output state datapoint.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
serial_number: Module serial number to query.
|
|
318
|
+
"""
|
|
319
|
+
self._conbus_protocol.send_telegram(
|
|
320
|
+
telegram_type=TelegramType.SYSTEM,
|
|
321
|
+
serial_number=serial_number,
|
|
322
|
+
system_function=SystemFunction.READ_DATAPOINT,
|
|
323
|
+
data_value=str(DataPointType.MODULE_OUTPUT_STATE.value),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def _on_connection_made(self) -> None:
|
|
327
|
+
"""Handle connection made event."""
|
|
328
|
+
if self._state_machine.transition("connected", ConnectionState.CONNECTED):
|
|
329
|
+
self._connection_state = ConnectionState.CONNECTED
|
|
330
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
331
|
+
self.on_status_message.emit(f"Connected to {self.server_info}")
|
|
332
|
+
|
|
333
|
+
# Emit initial accessory list
|
|
334
|
+
self.on_room_list_updated.emit(self.accessory_states)
|
|
335
|
+
|
|
336
|
+
def _on_connection_failed(self, failure: Exception) -> None:
|
|
337
|
+
"""
|
|
338
|
+
Handle connection failed event.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
failure: Exception that caused the failure.
|
|
342
|
+
"""
|
|
343
|
+
if self._state_machine.transition("failed", ConnectionState.FAILED):
|
|
344
|
+
self._connection_state = ConnectionState.FAILED
|
|
345
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
346
|
+
self.on_status_message.emit(f"Connection failed: {failure}")
|
|
347
|
+
|
|
348
|
+
def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
|
|
349
|
+
"""
|
|
350
|
+
Handle telegram received event.
|
|
351
|
+
|
|
352
|
+
Routes telegrams to appropriate handlers based on type.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
event: Telegram received event.
|
|
356
|
+
"""
|
|
357
|
+
if event.telegram_type == TelegramType.REPLY:
|
|
358
|
+
self._handle_reply_telegram(event)
|
|
359
|
+
elif event.telegram_type == TelegramType.EVENT:
|
|
360
|
+
self._handle_event_telegram(event)
|
|
361
|
+
|
|
362
|
+
def _handle_reply_telegram(self, event: TelegramReceivedEvent) -> None:
|
|
363
|
+
"""
|
|
364
|
+
Handle reply telegram for datapoint queries.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
event: Telegram received event.
|
|
368
|
+
"""
|
|
369
|
+
serial_number = event.serial_number
|
|
370
|
+
if not serial_number:
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
# Parse the reply telegram
|
|
374
|
+
reply_telegram = self._telegram_service.parse_reply_telegram(event.frame)
|
|
375
|
+
if not reply_telegram:
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
# Check if this is a module output state response
|
|
379
|
+
if (
|
|
380
|
+
reply_telegram.system_function == SystemFunction.READ_DATAPOINT
|
|
381
|
+
and reply_telegram.datapoint_type == DataPointType.MODULE_OUTPUT_STATE
|
|
382
|
+
):
|
|
383
|
+
self._update_outputs_from_reply(serial_number, reply_telegram.data_value)
|
|
384
|
+
|
|
385
|
+
def _update_outputs_from_reply(self, serial_number: str, data_value: str) -> None:
|
|
386
|
+
"""
|
|
387
|
+
Update accessory outputs from module output state reply.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
serial_number: Module serial number.
|
|
391
|
+
data_value: Output state data value from reply.
|
|
392
|
+
"""
|
|
393
|
+
# Parse output state bits using TelegramOutputService
|
|
394
|
+
outputs = TelegramOutputService.format_output_state(data_value)
|
|
395
|
+
output_list = outputs.split() if outputs else []
|
|
396
|
+
|
|
397
|
+
# Update all accessories for this serial_number
|
|
398
|
+
for state in self._accessory_states.values():
|
|
399
|
+
if state.serial_number == serial_number:
|
|
400
|
+
output_index = state.output - 1 # Convert to 0-based
|
|
401
|
+
|
|
402
|
+
if output_index < len(output_list):
|
|
403
|
+
is_on = output_list[output_index] == "1"
|
|
404
|
+
state.output_state = "ON" if is_on else "OFF"
|
|
405
|
+
|
|
406
|
+
# Update dimming state for dimmable modules
|
|
407
|
+
if state.is_dimmable():
|
|
408
|
+
state.dimming_state = "-" if not is_on else ""
|
|
409
|
+
else:
|
|
410
|
+
state.output_state = "?"
|
|
411
|
+
|
|
412
|
+
state.last_update = datetime.now()
|
|
413
|
+
self.on_module_state_changed.emit(state)
|
|
414
|
+
|
|
415
|
+
def _handle_event_telegram(self, event: TelegramReceivedEvent) -> None:
|
|
416
|
+
"""
|
|
417
|
+
Handle event telegram for output state changes.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
event: Telegram received event.
|
|
421
|
+
"""
|
|
422
|
+
event_telegram = self._telegram_service.parse_event_telegram(event.frame)
|
|
423
|
+
if not event_telegram:
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# Determine output number based on module type
|
|
427
|
+
output_number = None
|
|
428
|
+
|
|
429
|
+
if event_telegram.module_type == ModuleTypeCode.XP24.value:
|
|
430
|
+
if 80 <= event_telegram.input_number <= 83:
|
|
431
|
+
output_number = event_telegram.input_number - 80
|
|
432
|
+
else:
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
elif event_telegram.module_type in (
|
|
436
|
+
ModuleTypeCode.XP33.value,
|
|
437
|
+
ModuleTypeCode.XP33LR.value,
|
|
438
|
+
ModuleTypeCode.XP33LED.value,
|
|
439
|
+
):
|
|
440
|
+
if 80 <= event_telegram.input_number <= 82:
|
|
441
|
+
output_number = event_telegram.input_number - 80
|
|
442
|
+
else:
|
|
443
|
+
return
|
|
444
|
+
else:
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
# Find accessories matching link number and output
|
|
448
|
+
output_1_based = output_number + 1
|
|
449
|
+
for state in self._accessory_states.values():
|
|
450
|
+
module_config = self._conson_config.find_module(state.serial_number)
|
|
451
|
+
if not module_config:
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
if (
|
|
455
|
+
module_config.link_number == event_telegram.link_number
|
|
456
|
+
and state.output == output_1_based
|
|
457
|
+
):
|
|
458
|
+
# Update output state (M=ON, B=OFF)
|
|
459
|
+
is_on = event_telegram.is_button_press
|
|
460
|
+
state.output_state = "ON" if is_on else "OFF"
|
|
461
|
+
|
|
462
|
+
# Update dimming state for dimmable modules
|
|
463
|
+
if state.is_dimmable():
|
|
464
|
+
state.dimming_state = "-" if not is_on else ""
|
|
465
|
+
|
|
466
|
+
state.last_update = datetime.now()
|
|
467
|
+
self.on_module_state_changed.emit(state)
|
|
468
|
+
|
|
469
|
+
self.logger.debug(
|
|
470
|
+
f"Updated {state.accessory_name} to {'ON' if is_on else 'OFF'}"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def _on_timeout(self) -> None:
|
|
474
|
+
"""Handle timeout event."""
|
|
475
|
+
self.on_status_message.emit("Waiting for action")
|
|
476
|
+
|
|
477
|
+
def _on_failed(self, failure: Exception) -> None:
|
|
478
|
+
"""
|
|
479
|
+
Handle protocol failure event.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
failure: Exception that caused the failure.
|
|
483
|
+
"""
|
|
484
|
+
if self._state_machine.transition("failed", ConnectionState.FAILED):
|
|
485
|
+
self._connection_state = ConnectionState.FAILED
|
|
486
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
487
|
+
self.on_status_message.emit(f"Protocol error: {failure}")
|
|
488
|
+
|
|
489
|
+
def cleanup(self) -> None:
|
|
490
|
+
"""Clean up service resources."""
|
|
491
|
+
self._disconnect_signals()
|
|
492
|
+
self.logger.debug("HomekitService cleaned up")
|
|
493
|
+
|
|
494
|
+
def __enter__(self) -> "HomekitService":
|
|
495
|
+
"""
|
|
496
|
+
Context manager entry.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
Self for context manager.
|
|
500
|
+
"""
|
|
501
|
+
return self
|
|
502
|
+
|
|
503
|
+
def __exit__(self, _exc_type: object, _exc_val: object, _exc_tb: object) -> None:
|
|
504
|
+
"""
|
|
505
|
+
Context manager exit.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
_exc_type: Exception type.
|
|
509
|
+
_exc_val: Exception value.
|
|
510
|
+
_exc_tb: Exception traceback.
|
|
511
|
+
"""
|
|
512
|
+
self.cleanup()
|
|
@@ -299,7 +299,7 @@ class StateMonitorService:
|
|
|
299
299
|
|
|
300
300
|
def _on_timeout(self) -> None:
|
|
301
301
|
"""Handle timeout event."""
|
|
302
|
-
self.on_status_message.emit("
|
|
302
|
+
self.on_status_message.emit("Waiting for action")
|
|
303
303
|
|
|
304
304
|
def _on_failed(self, failure: Exception) -> None:
|
|
305
305
|
"""
|
xp/term/homekit.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""HomeKit TUI Application."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
|
|
8
|
+
from xp.services.term.homekit_service import HomekitService
|
|
9
|
+
from xp.term.widgets.room_list import RoomListWidget
|
|
10
|
+
from xp.term.widgets.status_footer import StatusFooterWidget
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HomekitApp(App[None]):
|
|
14
|
+
"""
|
|
15
|
+
Textual app for HomeKit accessory monitoring.
|
|
16
|
+
|
|
17
|
+
Displays rooms and accessories with real-time state updates
|
|
18
|
+
and toggle control via action keys.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
homekit_service: HomekitService for accessory state operations.
|
|
22
|
+
CSS_PATH: Path to CSS stylesheet file.
|
|
23
|
+
BINDINGS: Keyboard bindings for app actions.
|
|
24
|
+
TITLE: Application title displayed in header.
|
|
25
|
+
ENABLE_COMMAND_PALETTE: Disable Textual's command palette feature.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
CSS_PATH = Path(__file__).parent / "homekit.tcss"
|
|
29
|
+
TITLE = "HomeKit"
|
|
30
|
+
ENABLE_COMMAND_PALETTE = False
|
|
31
|
+
|
|
32
|
+
BINDINGS = [
|
|
33
|
+
("Q", "quit", "Quit"),
|
|
34
|
+
("C", "toggle_connection", "Connect"),
|
|
35
|
+
("r", "refresh_all", "Refresh"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def __init__(self, homekit_service: HomekitService) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Initialize the HomeKit app.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
homekit_service: HomekitService for accessory state operations.
|
|
44
|
+
"""
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.homekit_service: HomekitService = homekit_service
|
|
47
|
+
self.room_list_widget: Optional[RoomListWidget] = None
|
|
48
|
+
self.footer_widget: Optional[StatusFooterWidget] = None
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
"""
|
|
52
|
+
Compose the app layout with widgets.
|
|
53
|
+
|
|
54
|
+
Yields:
|
|
55
|
+
RoomListWidget and StatusFooterWidget.
|
|
56
|
+
"""
|
|
57
|
+
self.room_list_widget = RoomListWidget(
|
|
58
|
+
service=self.homekit_service, id="room-list"
|
|
59
|
+
)
|
|
60
|
+
yield self.room_list_widget
|
|
61
|
+
|
|
62
|
+
self.footer_widget = StatusFooterWidget(
|
|
63
|
+
service=self.homekit_service, id="footer-container"
|
|
64
|
+
)
|
|
65
|
+
yield self.footer_widget
|
|
66
|
+
|
|
67
|
+
async def on_mount(self) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Initialize app after UI is mounted.
|
|
70
|
+
|
|
71
|
+
Delays connection by 0.5s to let UI render first. Sets up automatic screen
|
|
72
|
+
refresh every second to update elapsed times.
|
|
73
|
+
"""
|
|
74
|
+
import asyncio
|
|
75
|
+
|
|
76
|
+
# Delay connection to let UI render
|
|
77
|
+
await asyncio.sleep(0.5)
|
|
78
|
+
self.homekit_service.connect()
|
|
79
|
+
|
|
80
|
+
# Set up periodic refresh to update elapsed times
|
|
81
|
+
self.set_interval(1.0, self._refresh_last_update_column)
|
|
82
|
+
|
|
83
|
+
def _refresh_last_update_column(self) -> None:
|
|
84
|
+
"""Refresh only the last_update column to show elapsed time."""
|
|
85
|
+
if self.room_list_widget:
|
|
86
|
+
self.room_list_widget.refresh_last_update_times()
|
|
87
|
+
|
|
88
|
+
def on_key(self, event: Any) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Handle key press events for action keys.
|
|
91
|
+
|
|
92
|
+
Intercepts a-z keys to toggle accessories.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
event: Key press event.
|
|
96
|
+
"""
|
|
97
|
+
key = event.key.lower()
|
|
98
|
+
if len(key) == 1 and "a" <= key <= "z":
|
|
99
|
+
if self.homekit_service.toggle_accessory(key):
|
|
100
|
+
event.prevent_default()
|
|
101
|
+
|
|
102
|
+
def action_toggle_connection(self) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Toggle connection on 'c' key press.
|
|
105
|
+
|
|
106
|
+
Connects if disconnected/failed, disconnects if connected/connecting.
|
|
107
|
+
"""
|
|
108
|
+
self.homekit_service.toggle_connection()
|
|
109
|
+
|
|
110
|
+
def action_refresh_all(self) -> None:
|
|
111
|
+
"""Refresh all module data on 'r' key press."""
|
|
112
|
+
self.homekit_service.refresh_all()
|
|
113
|
+
|
|
114
|
+
def on_unmount(self) -> None:
|
|
115
|
+
"""Clean up service when app unmounts."""
|
|
116
|
+
self.homekit_service.cleanup()
|
xp/term/homekit.tcss
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* HomeKit TUI Styling */
|
|
2
|
+
|
|
3
|
+
/* Color overrides */
|
|
4
|
+
$success: #00ff00;
|
|
5
|
+
|
|
6
|
+
/* App-level styling */
|
|
7
|
+
Screen {
|
|
8
|
+
background: $background;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* Room List Widget */
|
|
12
|
+
RoomListWidget {
|
|
13
|
+
border: solid $success;
|
|
14
|
+
border-title-align: left;
|
|
15
|
+
width: 1fr;
|
|
16
|
+
height: 1fr;
|
|
17
|
+
background: $background;
|
|
18
|
+
padding: 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
RoomListWidget:focus {
|
|
22
|
+
background: $background;
|
|
23
|
+
background-tint: transparent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#rooms-table {
|
|
27
|
+
background: $background !important;
|
|
28
|
+
width: 100%;
|
|
29
|
+
height: 1fr;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#rooms-table:focus {
|
|
33
|
+
background: $background !important;
|
|
34
|
+
background-tint: transparent;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
DataTable {
|
|
38
|
+
background: $background;
|
|
39
|
+
color: $success;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
DataTable > .datatable--header {
|
|
43
|
+
background: $background;
|
|
44
|
+
color: $success;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
DataTable > .datatable--cursor {
|
|
48
|
+
background: $background;
|
|
49
|
+
color: $success;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
DataTable:focus > .datatable--cursor {
|
|
53
|
+
background: $background;
|
|
54
|
+
color: $success;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Footer styling */
|
|
58
|
+
#footer-container {
|
|
59
|
+
dock: bottom;
|
|
60
|
+
height: 1;
|
|
61
|
+
background: $background;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Footer {
|
|
65
|
+
width: auto;
|
|
66
|
+
background: $background;
|
|
67
|
+
color: $text;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#status-text {
|
|
71
|
+
dock: right;
|
|
72
|
+
width: auto;
|
|
73
|
+
padding: 0 3;
|
|
74
|
+
background: $background;
|
|
75
|
+
color: $text;
|
|
76
|
+
text-align: right;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#status-line {
|
|
80
|
+
dock: right;
|
|
81
|
+
width: auto;
|
|
82
|
+
padding: 0 1;
|
|
83
|
+
background: $background;
|
|
84
|
+
color: $text;
|
|
85
|
+
text-align: right;
|
|
86
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Room List Widget for displaying HomeKit accessories table."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, List, Optional
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.widgets import DataTable, Static
|
|
9
|
+
|
|
10
|
+
from xp.models.term.accessory_state import AccessoryState
|
|
11
|
+
from xp.services.term.homekit_service import HomekitService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RoomListWidget(Static):
|
|
15
|
+
"""
|
|
16
|
+
Widget displaying HomeKit accessories in a data table.
|
|
17
|
+
|
|
18
|
+
Shows room/accessory hierarchy with real-time state updates from HomekitService.
|
|
19
|
+
Table displays: room/accessory, action, state, dim, module, serial, type, status, output, updated.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
service: HomekitService for accessory state updates.
|
|
23
|
+
table: DataTable widget displaying accessory information.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
service: Optional[HomekitService] = None,
|
|
29
|
+
*args: Any,
|
|
30
|
+
**kwargs: Any,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Initialize the Room List widget.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
service: Optional HomekitService for signal subscriptions.
|
|
37
|
+
args: Additional positional arguments for Static.
|
|
38
|
+
kwargs: Additional keyword arguments for Static.
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(*args, **kwargs)
|
|
41
|
+
self.service = service
|
|
42
|
+
self.table: Optional[DataTable] = None
|
|
43
|
+
self._row_keys: dict[str, Any] = {} # Map accessory_id to row key
|
|
44
|
+
self._current_room: str = ""
|
|
45
|
+
|
|
46
|
+
def compose(self) -> ComposeResult:
|
|
47
|
+
"""
|
|
48
|
+
Compose the widget layout.
|
|
49
|
+
|
|
50
|
+
Yields:
|
|
51
|
+
DataTable widget.
|
|
52
|
+
"""
|
|
53
|
+
self.table = DataTable(id="rooms-table", cursor_type="row")
|
|
54
|
+
yield self.table
|
|
55
|
+
|
|
56
|
+
def on_mount(self) -> None:
|
|
57
|
+
"""Initialize table and subscribe to service signals when widget mounts."""
|
|
58
|
+
self.border_title = "Rooms"
|
|
59
|
+
|
|
60
|
+
if self.table:
|
|
61
|
+
self.table.add_column("room / accessory", key="name", width=35)
|
|
62
|
+
self.table.add_column("action", key="action", width=8)
|
|
63
|
+
self.table.add_column("state", key="state", width=7)
|
|
64
|
+
self.table.add_column("dim", key="dim", width=6)
|
|
65
|
+
self.table.add_column("module", key="module", width=8)
|
|
66
|
+
self.table.add_column("serial", key="serial", width=12)
|
|
67
|
+
self.table.add_column("type", key="type", width=10)
|
|
68
|
+
self.table.add_column("status", key="status", width=8)
|
|
69
|
+
self.table.add_column("output", key="output", width=7)
|
|
70
|
+
self.table.add_column("updated", key="updated", width=10)
|
|
71
|
+
|
|
72
|
+
if self.service:
|
|
73
|
+
self.service.on_room_list_updated.connect(self.update_accessory_list)
|
|
74
|
+
self.service.on_module_state_changed.connect(self.update_accessory_state)
|
|
75
|
+
|
|
76
|
+
def on_unmount(self) -> None:
|
|
77
|
+
"""Unsubscribe from service signals when widget unmounts."""
|
|
78
|
+
if self.service:
|
|
79
|
+
self.service.on_room_list_updated.disconnect(self.update_accessory_list)
|
|
80
|
+
self.service.on_module_state_changed.disconnect(self.update_accessory_state)
|
|
81
|
+
|
|
82
|
+
def update_accessory_list(self, accessory_states: List[AccessoryState]) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Update entire accessory list from service.
|
|
85
|
+
|
|
86
|
+
Clears existing table and repopulates with all accessories grouped by room.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
accessory_states: List of all accessory states.
|
|
90
|
+
"""
|
|
91
|
+
if not self.table:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
self.table.clear()
|
|
95
|
+
self._row_keys.clear()
|
|
96
|
+
self._current_room = ""
|
|
97
|
+
|
|
98
|
+
for state in accessory_states:
|
|
99
|
+
# Add room header row if new room
|
|
100
|
+
if state.room_name != self._current_room:
|
|
101
|
+
self._current_room = state.room_name
|
|
102
|
+
self.table.add_row()
|
|
103
|
+
self.table.add_row(Text(state.room_name, style="bold"))
|
|
104
|
+
self.table.add_row()
|
|
105
|
+
|
|
106
|
+
self._add_accessory_row(state)
|
|
107
|
+
|
|
108
|
+
def update_accessory_state(self, state: AccessoryState) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Update individual accessory state in table.
|
|
111
|
+
|
|
112
|
+
Updates existing row if accessory exists, otherwise adds new row.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
state: Updated accessory state.
|
|
116
|
+
"""
|
|
117
|
+
if not self.table:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
accessory_id = f"{state.module_name}_{state.output}"
|
|
121
|
+
|
|
122
|
+
if accessory_id in self._row_keys:
|
|
123
|
+
row_key = self._row_keys[accessory_id]
|
|
124
|
+
self.table.update_cell(
|
|
125
|
+
row_key, "state", Text(state.output_state, justify="center")
|
|
126
|
+
)
|
|
127
|
+
self.table.update_cell(
|
|
128
|
+
row_key, "dim", Text(self._format_dim(state), justify="center")
|
|
129
|
+
)
|
|
130
|
+
self.table.update_cell(row_key, "status", state.error_status)
|
|
131
|
+
self.table.update_cell(
|
|
132
|
+
row_key,
|
|
133
|
+
"updated",
|
|
134
|
+
Text(self._format_last_update(state.last_update), justify="center"),
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
self._add_accessory_row(state)
|
|
138
|
+
|
|
139
|
+
def _add_accessory_row(self, state: AccessoryState) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Add an accessory row to the table.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
state: Accessory state to add.
|
|
145
|
+
"""
|
|
146
|
+
if not self.table:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
accessory_id = f"{state.module_name}_{state.output}"
|
|
150
|
+
row_key = self.table.add_row(
|
|
151
|
+
f" - {state.accessory_name}",
|
|
152
|
+
Text(state.action, justify="center"),
|
|
153
|
+
Text(state.output_state, justify="center"),
|
|
154
|
+
Text(self._format_dim(state), justify="center"),
|
|
155
|
+
state.module_name,
|
|
156
|
+
state.serial_number,
|
|
157
|
+
state.module_type,
|
|
158
|
+
state.error_status,
|
|
159
|
+
Text(str(state.output), justify="right"),
|
|
160
|
+
Text(self._format_last_update(state.last_update), justify="center"),
|
|
161
|
+
)
|
|
162
|
+
self._row_keys[accessory_id] = row_key
|
|
163
|
+
|
|
164
|
+
def _format_dim(self, state: AccessoryState) -> str:
|
|
165
|
+
"""
|
|
166
|
+
Format dimming state for display.
|
|
167
|
+
|
|
168
|
+
Shows percentage if dimmable and ON, "-" if dimmable and OFF, empty otherwise.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
state: Accessory state.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Formatted dimming string.
|
|
175
|
+
"""
|
|
176
|
+
if not state.is_dimmable():
|
|
177
|
+
return ""
|
|
178
|
+
if state.output_state == "OFF":
|
|
179
|
+
return "-"
|
|
180
|
+
return state.dimming_state or ""
|
|
181
|
+
|
|
182
|
+
def _format_last_update(self, last_update: Optional[datetime]) -> str:
|
|
183
|
+
"""
|
|
184
|
+
Format last update timestamp for display.
|
|
185
|
+
|
|
186
|
+
Shows elapsed time in HH:MM:SS format or "--:--:--" if never updated.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
last_update: Last update timestamp or None.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Formatted time string.
|
|
193
|
+
"""
|
|
194
|
+
if last_update is None:
|
|
195
|
+
return "--:--:--"
|
|
196
|
+
|
|
197
|
+
elapsed = datetime.now() - last_update
|
|
198
|
+
total_seconds = int(elapsed.total_seconds())
|
|
199
|
+
|
|
200
|
+
hours = total_seconds // 3600
|
|
201
|
+
minutes = (total_seconds % 3600) // 60
|
|
202
|
+
seconds = total_seconds % 60
|
|
203
|
+
|
|
204
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
205
|
+
|
|
206
|
+
def refresh_last_update_times(self) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Refresh only the last_update column for all accessories.
|
|
209
|
+
|
|
210
|
+
Updates the elapsed time display without querying the service.
|
|
211
|
+
"""
|
|
212
|
+
if not self.table or not self.service:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
for accessory_id, row_key in self._row_keys.items():
|
|
216
|
+
state = next(
|
|
217
|
+
(
|
|
218
|
+
s
|
|
219
|
+
for s in self.service.accessory_states
|
|
220
|
+
if f"{s.module_name}_{s.output}" == accessory_id
|
|
221
|
+
),
|
|
222
|
+
None,
|
|
223
|
+
)
|
|
224
|
+
if state:
|
|
225
|
+
self.table.update_cell(
|
|
226
|
+
row_key,
|
|
227
|
+
"updated",
|
|
228
|
+
Text(
|
|
229
|
+
self._format_last_update(state.last_update),
|
|
230
|
+
justify="center",
|
|
231
|
+
),
|
|
232
|
+
)
|
xp/term/widgets/status_footer.py
CHANGED
|
@@ -7,6 +7,7 @@ from textual.containers import Horizontal
|
|
|
7
7
|
from textual.widgets import Footer, Static
|
|
8
8
|
|
|
9
9
|
from xp.models.term.connection_state import ConnectionState
|
|
10
|
+
from xp.services.term.homekit_service import HomekitService
|
|
10
11
|
from xp.services.term.protocol_monitor_service import ProtocolMonitorService
|
|
11
12
|
from xp.services.term.state_monitor_service import StateMonitorService
|
|
12
13
|
|
|
@@ -19,14 +20,16 @@ class StatusFooterWidget(Horizontal):
|
|
|
19
20
|
the current connection state. Subscribes directly to service signals.
|
|
20
21
|
|
|
21
22
|
Attributes:
|
|
22
|
-
service: ProtocolMonitorService or
|
|
23
|
+
service: ProtocolMonitorService, StateMonitorService, or HomekitService for connection state and status updates.
|
|
23
24
|
status_widget: Static widget displaying colored status dot.
|
|
24
25
|
status_text_widget: Static widget displaying status messages.
|
|
25
26
|
"""
|
|
26
27
|
|
|
27
28
|
def __init__(
|
|
28
29
|
self,
|
|
29
|
-
service: Optional[
|
|
30
|
+
service: Optional[
|
|
31
|
+
Union[ProtocolMonitorService, StateMonitorService, HomekitService]
|
|
32
|
+
] = None,
|
|
30
33
|
*args: Any,
|
|
31
34
|
**kwargs: Any,
|
|
32
35
|
) -> None:
|
|
@@ -34,7 +37,7 @@ class StatusFooterWidget(Horizontal):
|
|
|
34
37
|
Initialize the Status Footer widget.
|
|
35
38
|
|
|
36
39
|
Args:
|
|
37
|
-
service: Optional ProtocolMonitorService or
|
|
40
|
+
service: Optional ProtocolMonitorService, StateMonitorService, or HomekitService for signal subscriptions.
|
|
38
41
|
args: Additional positional arguments for Horizontal.
|
|
39
42
|
kwargs: Additional keyword arguments for Horizontal.
|
|
40
43
|
"""
|
xp/utils/dependencies.py
CHANGED
|
@@ -77,8 +77,10 @@ from xp.services.telegram.telegram_discover_service import TelegramDiscoverServi
|
|
|
77
77
|
from xp.services.telegram.telegram_link_number_service import LinkNumberService
|
|
78
78
|
from xp.services.telegram.telegram_output_service import TelegramOutputService
|
|
79
79
|
from xp.services.telegram.telegram_service import TelegramService
|
|
80
|
+
from xp.services.term.homekit_service import HomekitService
|
|
80
81
|
from xp.services.term.protocol_monitor_service import ProtocolMonitorService
|
|
81
82
|
from xp.services.term.state_monitor_service import StateMonitorService
|
|
83
|
+
from xp.term.homekit import HomekitApp
|
|
82
84
|
from xp.term.protocol import ProtocolMonitorApp
|
|
83
85
|
from xp.term.state import StateMonitorApp
|
|
84
86
|
from xp.utils.logging import LoggerService
|
|
@@ -265,6 +267,25 @@ class ServiceContainer:
|
|
|
265
267
|
scope=punq.Scope.singleton,
|
|
266
268
|
)
|
|
267
269
|
|
|
270
|
+
self.container.register(
|
|
271
|
+
HomekitService,
|
|
272
|
+
factory=lambda: HomekitService(
|
|
273
|
+
conbus_protocol=self.container.resolve(ConbusEventProtocol),
|
|
274
|
+
homekit_config=self.container.resolve(HomekitConfig),
|
|
275
|
+
conson_config=self.container.resolve(ConsonModuleListConfig),
|
|
276
|
+
telegram_service=self.container.resolve(TelegramService),
|
|
277
|
+
),
|
|
278
|
+
scope=punq.Scope.singleton,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
self.container.register(
|
|
282
|
+
HomekitApp,
|
|
283
|
+
factory=lambda: HomekitApp(
|
|
284
|
+
homekit_service=self.container.resolve(HomekitService)
|
|
285
|
+
),
|
|
286
|
+
scope=punq.Scope.singleton,
|
|
287
|
+
)
|
|
288
|
+
|
|
268
289
|
self.container.register(
|
|
269
290
|
ConbusEventRawService,
|
|
270
291
|
factory=lambda: ConbusEventRawService(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|