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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: conson-xp
3
- Version: 1.51.0
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.0.dist-info/METADATA,sha256=8rmZ2V68vB3gZpx_sScMBXxKXqAei3GeqOOUHGe92mo,11432
2
- conson_xp-1.51.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
- conson_xp-1.51.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.51.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=IbYggn-GNpmY-NkggNp_7SLbVaF6zPk0wENVaZeOIeE,182
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=ypJo9GL0eNwllgJJux2agJkcco6k9HMM-5f3aTbiQ2c,1216
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=OMq0eayAJ6NRr8PXANvQzgEYGW9RN_ycyEmnTlTlHrQ,2938
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=aFvzGZHr_dI6USb8MJuYLSLMvxi_ZWMVtokHDt8428s,263
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=bLDOY8dbXvqvrDLtn4PijZA38qNmQWcQFWIY6hjLrl0,10875
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=4CmeHf5k9mf67AleOB7byM7_g5WwbAOnmgxl6I2vBwg,17116
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/status_footer.py,sha256=YYAT0431p6jmrzzpVgaPhu7yGkRroWGv4e99t2XlkHI,3297
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=I8Z89_iTCeR-7adpae3GVQUGZ-1gY6wg0OGKmdJGg3w,24536
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.0.dist-info/RECORD,,
212
+ conson_xp-1.51.1.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -4,7 +4,7 @@ XP CLI tool for remote console bus operations.
4
4
  conson-xp package.
5
5
  """
6
6
 
7
- __version__ = "1.51.0"
7
+ __version__ = "1.51.1"
8
8
  __manufacturer__ = "salchichon"
9
9
  __model__ = "xp.cli"
10
10
  __serial__ = "2025.09.23.000"
@@ -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
 
@@ -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")
@@ -221,7 +221,7 @@ class CrossReferenceValidator:
221
221
  # Define output limits by module type
222
222
  output_limits = {
223
223
  "XP130": 0, # Example limits
224
- "XP20": 0,
224
+ "XP20": 8,
225
225
  "XP24": 4,
226
226
  "XP33": 3,
227
227
  "XP33LR": 3,
@@ -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("Connection timeout")
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
+ )
@@ -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 StateMonitorService for connection state and status updates.
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[Union[ProtocolMonitorService, StateMonitorService]] = None,
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 StateMonitorService for signal subscriptions.
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(