conson-xp 2.0.1__py3-none-any.whl → 2.0.3__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: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -1,8 +1,8 @@
1
- conson_xp-2.0.1.dist-info/METADATA,sha256=tjqY8FAUMgkHQOaEMyu0EPYq-T1JRLs5wD7Tqu2lBJA,11319
2
- conson_xp-2.0.1.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
- conson_xp-2.0.1.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-2.0.1.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=w2LWS-22VYMQH5ujXhsjjaQ9D1sDkzAfiiYv3VkLl9M,181
1
+ conson_xp-2.0.3.dist-info/METADATA,sha256=MacSE_YD6_kKpZQfPXnzZAs9YbYR__cvxg1d4ISQLXg,11319
2
+ conson_xp-2.0.3.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ conson_xp-2.0.3.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-2.0.3.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=Cd7Ua5HD9WOxxHs-xepjLjWZf4c-ue1kISZWRKiqgwM,181
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=9TGP3uTxAU-s-kVChvQ-Fn3-HVYj-QpeQ05Is-20HRo,4788
@@ -81,7 +81,7 @@ xp/models/config/__init__.py,sha256=gEZnX9eE3DjFtLtF32riEjJQLypqQRbyPauBI4Cowbs,
81
81
  xp/models/config/conson_module_config.py,sha256=t1G0LnNNMnjs3ahhz4-Z_5SlEv2FCrcRq13OmvZ2pvA,3009
82
82
  xp/models/homekit/__init__.py,sha256=5HDSOClCu0ArK3IICn3_LDMMLBAzLjBxUUSF73bxSSk,34
83
83
  xp/models/homekit/homekit_accessory.py,sha256=ANjDWlFxeNTstl7lKdmf6vMOC0wc005vpiD6awRcptA,1052
84
- xp/models/homekit/homekit_config.py,sha256=pgZOnocue60LjV8ce46MyJ3mo5CqLix6TmT64qxPOks,3267
84
+ xp/models/homekit/homekit_config.py,sha256=YOhODQpURg_1OU0i-4qMglU4E37feNKKtY1uuZRXSoY,3759
85
85
  xp/models/log_entry.py,sha256=tAiNwouCP2d4jKiHJY9a-2iAi8LWTpG-TZsOPDIstlA,4423
86
86
  xp/models/protocol/__init__.py,sha256=TJ_CJKchA-xgQiv5vCo_ndBBZjrcaTmjT74bR0T-5Cw,38
87
87
  xp/models/protocol/conbus_protocol.py,sha256=gFaXK1VY74aVQhMH69Dr-dTDbDuQDQGs8vKmXqK_te8,9318
@@ -112,7 +112,7 @@ xp/models/term/telegram_display.py,sha256=tXWeEtoIBSnScjha3ZHV9UPICmtBF2bkoLVIjQ
112
112
  xp/models/write_config_type.py,sha256=IqgguaHgKvz4Qt-WaSVu3J2VaXgtS-br9Yp8q_xkIkY,895
113
113
  xp/services/__init__.py,sha256=W9YZyrkh7vm--ZHhAXNQiOYQs5yhhmUHXP5I0Lf1XBg,782
114
114
  xp/services/actiontable/__init__.py,sha256=z6js4EuJ6xKHaseTEhuEvKo1tr9K1XyQiruReJtBiPY,26
115
- xp/services/actiontable/actiontable_serializer.py,sha256=AZpqxfq8-o7FGGtseW52MS6z647x3Ucc1RjR3GLUb20,8335
115
+ xp/services/actiontable/actiontable_serializer.py,sha256=GTpC9_bOdxqQl7cVhpEH9aGusZNRn5WcahwPc2XUWaY,8393
116
116
  xp/services/actiontable/download_state_machine.py,sha256=lqNYN9LGGK2KiVUsmvyRfryWRB4-NOfsp7-9GrFubK4,9978
117
117
  xp/services/actiontable/msactiontable_serializer.py,sha256=RRL6TZ1gpSQw81kAiw2BV3jTqm4fCJC0pWIcO26Cmos,174
118
118
  xp/services/actiontable/msactiontable_xp20_serializer.py,sha256=5K6FxgbV2F4brumNaOH6M8qPyCxIfaqCGOPIYDmFdnk,6998
@@ -124,7 +124,7 @@ xp/services/conbus/actiontable/__init__.py,sha256=oD6vRk_Ye-eZ9s_hldAgtRJFu4mfAn
124
124
  xp/services/conbus/actiontable/actiontable_download_service.py,sha256=41Hr1753IBpUeHQqO57uS7qxOB0rJt8qCpznzKlUPOM,15028
125
125
  xp/services/conbus/actiontable/actiontable_list_service.py,sha256=oTDSpBkp-MJeaF5bhRnwkSy3na55xqQ4e2ykJzbMCUo,3236
126
126
  xp/services/conbus/actiontable/actiontable_show_service.py,sha256=WISY2VsmSlceGa5_9lpFO-gs5TnTjv6YidQksUjCapk,3058
127
- xp/services/conbus/actiontable/actiontable_upload_service.py,sha256=FaQzOSg8s2zUL5xz9qZY9fvzrdDosc3CoxkVDvNg2SU,13252
127
+ xp/services/conbus/actiontable/actiontable_upload_service.py,sha256=bIAN3Ca3BHTPtZYfPijAgsWP91k-uNFAW7-i-LoTE4I,13553
128
128
  xp/services/conbus/conbus_blink_all_service.py,sha256=toDIZDXBGBYnEishcdnJrVzkmfPi7g5nCDXuyA_wFCs,8536
129
129
  xp/services/conbus/conbus_blink_service.py,sha256=ggLuzeq_UsgCoxRxg2bsNs9p8Lw_shjsj-niRzb5dKk,7953
130
130
  xp/services/conbus/conbus_custom_service.py,sha256=9OIRC2CG_rN96vbv_EZXf7BrX_abhqi5MZx0Se8fEhU,7826
@@ -166,12 +166,12 @@ xp/services/telegram/telegram_output_service.py,sha256=9deqtcPndRqJ-3XQUWlJhXaVc
166
166
  xp/services/telegram/telegram_service.py,sha256=jPu0Xrh3IpvqPLyuQT5Vf8HHw00vBingONHdxf_9TkI,13315
167
167
  xp/services/telegram/telegram_version_service.py,sha256=oXnZ_K7OQ7xD-GEj3zDYp52KlkqVuHpO4bf7gMlC_w4,10574
168
168
  xp/services/term/__init__.py,sha256=BIeOK042bMR-0l6MA80wdW5VuHlpWOXtRER9IG5ilQA,245
169
- xp/services/term/homekit_accessory_driver.py,sha256=jHhHHOOvzdDjuYys1cUZHcH2uwZy_Y6o5aGAQqXGAVg,5935
170
- xp/services/term/homekit_service.py,sha256=h1wAguhUG9f8cdolFKsp-V-iZV5x52Wm0L7syKoK6Qs,25183
169
+ xp/services/term/homekit_accessory_driver.py,sha256=asIZz-1aJQv7m0oP28vwMWPh8x6WsMHN9pxJ0jIdUPk,7833
170
+ xp/services/term/homekit_service.py,sha256=FNm01Sp5F4bjJqRLMpl_FGbPgeuUkWxcu7sCBOihR0c,30892
171
171
  xp/services/term/protocol_monitor_service.py,sha256=5YBI0Nu7B7gMhaTbUhL6k9LSRfnCIj6CwrCYHiMHavA,10067
172
172
  xp/services/term/state_monitor_service.py,sha256=EK9tNBfamAIV0z0EMsXDYWC-rXv6l6k_bHsC8xyEFSo,17116
173
173
  xp/term/__init__.py,sha256=Xg2DhBeI3xQJLfc7_BPWI1por-rUXemyer5OtOt9Cus,51
174
- xp/term/homekit.py,sha256=A9l9zK6KSBx_Cc8I_AZhAWdQjZYnTGLxkln6CzFn3-Q,8935
174
+ xp/term/homekit.py,sha256=HJH3dZQsdp5rqcuV4EWJbytk7glCyDmj27614nRbIyI,9909
175
175
  xp/term/homekit.tcss,sha256=A1f5-V3mvxAMZK_ERq8lLjNcOWH0U5tblIBbeL3OYYM,1382
176
176
  xp/term/protocol.py,sha256=6MX3mduLei-AgLGaIe8lfOSu4Hi0y3KGePFFM2ssstc,3475
177
177
  xp/term/protocol.tcss,sha256=r_KfxrbpycGHLVXqZc6INBBcUJME0hLrAZkF1oqnab4,2126
@@ -191,4 +191,4 @@ xp/utils/logging.py,sha256=wJ1d-yg97NiZUrt2F8iDMcmnHVwC-PErcI-7dpyiRDc,3777
191
191
  xp/utils/serialization.py,sha256=TS1OwpTOemSvXsCGw3js4JkYYFEqkzrPe8V9QYQefdw,4684
192
192
  xp/utils/state_machine.py,sha256=W9AY4ntRZnFeHAa5d43hm37j53uJPlqkRvWTPiBhJ_0,2464
193
193
  xp/utils/time_utils.py,sha256=K17godWpL18VEypbTlvNOEDG6R3huYnf29yjkcnwRpU,3796
194
- conson_xp-2.0.1.dist-info/RECORD,,
194
+ conson_xp-2.0.3.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__ = "2.0.1"
7
+ __version__ = "2.0.3"
8
8
  __manufacturer__ = "salchichon"
9
9
  __model__ = "xp.cli"
10
10
  __serial__ = "2025.09.23.000"
@@ -66,6 +66,10 @@ class HomekitAccessoryConfig(BaseModel):
66
66
  on_action: on code for the accessory.
67
67
  off_action: off code for the accessory.
68
68
  toggle_action: Optional toggle action code for the accessory.
69
+ dimup_action: Optional dim up action code for the dimmable accessory.
70
+ dimdown_action: Optional dim down action code for the dimmable accessory.
71
+ levelup_action: Optional level up action code for the dimmable accessory.
72
+ leveldown_action: Optional level down action code for the dimmable accessory.
69
73
  hap_accessory: Optional HAP accessory identifier.
70
74
  """
71
75
 
@@ -78,6 +82,10 @@ class HomekitAccessoryConfig(BaseModel):
78
82
  on_action: str
79
83
  off_action: str
80
84
  toggle_action: Optional[str] = None
85
+ dimup_action: Optional[str] = None
86
+ dimdown_action: Optional[str] = None
87
+ levelup_action: Optional[str] = None
88
+ leveldown_action: Optional[str] = None
81
89
  hap_accessory: Optional[int] = None
82
90
 
83
91
 
@@ -65,8 +65,8 @@ class ActionTableSerializer(ActionTableSerializerProtocol):
65
65
  link_number = de_bcd(data[i + 1])
66
66
  module_input = de_bcd(data[i + 2])
67
67
 
68
- # Extract output and command from byte 3
69
- module_output = lower3(data[i + 3])
68
+ # Extract output (0-indexed in wire format, convert to 1-indexed) and command
69
+ module_output = lower3(data[i + 3]) + 1
70
70
  command_raw = upper5(data[i + 3])
71
71
 
72
72
  parameter_raw = byte_to_unsigned(data[i + 4])
@@ -125,8 +125,8 @@ class ActionTableSerializer(ActionTableSerializerProtocol):
125
125
  link_byte = to_bcd(entry.link_number)
126
126
  input_byte = to_bcd(entry.module_input)
127
127
 
128
- # Combine output (lower 3 bits) and command (upper 5 bits)
129
- output_command_byte = (entry.module_output & 0x07) | (
128
+ # Combine output (lower 3 bits, 0-indexed) and command (upper 5 bits)
129
+ output_command_byte = ((entry.module_output - 1) & 0x07) | (
130
130
  (entry.command.value & 0x1F) << 3
131
131
  )
132
132
 
@@ -81,6 +81,7 @@ class ActionTableUploadService:
81
81
  # Upload state
82
82
  self.upload_data_chunks: list[str] = []
83
83
  self.current_chunk_index: int = 0
84
+ self._eof_sent: bool = False
84
85
 
85
86
  # Set up logging
86
87
  self.logger = logging.getLogger(__name__)
@@ -173,7 +174,7 @@ class ActionTableUploadService:
173
174
  )
174
175
  self.current_chunk_index += 1
175
176
  self.on_progress.emit(".")
176
- else:
177
+ elif not self._eof_sent:
177
178
  # All chunks sent, send EOF
178
179
  self.logger.debug("All chunks sent, sending EOF")
179
180
  self.conbus_protocol.send_telegram(
@@ -182,7 +183,13 @@ class ActionTableUploadService:
182
183
  system_function=SystemFunction.EOF,
183
184
  data_value="00",
184
185
  )
186
+ self.on_progress.emit("END")
187
+ self.logger.debug("EOF sent, waiting for last ACK")
188
+ self._eof_sent = True
189
+ else:
190
+ self.logger.debug("Last ACK received, closing connection")
185
191
  self.on_finish.emit(True)
192
+
186
193
  elif reply_telegram.system_function == SystemFunction.NAK:
187
194
  self.logger.debug("Received NAK during upload")
188
195
  self.failed("Upload failed: NAK received")
@@ -10,9 +10,18 @@ from pyhap.const import CATEGORY_LIGHTBULB, CATEGORY_OUTLET
10
10
 
11
11
  from xp.models.homekit.homekit_config import HomekitConfig
12
12
 
13
+ # Callback type: (accessory_name, is_on, brightness_or_none)
14
+ OnSetCallback = Callable[[str, bool, Optional[int]], None]
15
+
13
16
 
14
17
  class XPAccessory(Accessory):
15
- """Single accessory wrapping a Conbus output."""
18
+ """
19
+ Single accessory wrapping a Conbus output.
20
+
21
+ Attributes:
22
+ logger: Logger instance for this accessory.
23
+ current_brightness: Current brightness value 0-100.
24
+ """
16
25
 
17
26
  def __init__(
18
27
  self,
@@ -35,12 +44,19 @@ class XPAccessory(Accessory):
35
44
  super().__init__(driver._driver, display_name, aid=aid)
36
45
  self._hk_driver = driver
37
46
  self._accessory_id = name
47
+ self._is_dimmable = service_type == "dimminglight"
48
+ self._char_brightness: Optional[object] = None
49
+ self._current_brightness: int = 100
38
50
  self.logger = logging.getLogger(__name__)
39
51
 
40
- if service_type == "dimminglight":
52
+ if self._is_dimmable:
41
53
  self.category = CATEGORY_LIGHTBULB
42
54
  serv = self.add_preload_service("Lightbulb", chars=["On", "Brightness"])
43
- # Note: Brightness setter_callback deferred to future update
55
+ self._char_brightness = serv.configure_char(
56
+ "Brightness",
57
+ setter_callback=self._set_brightness,
58
+ value=self._current_brightness,
59
+ )
44
60
  elif service_type == "outlet":
45
61
  self.category = CATEGORY_OUTLET
46
62
  serv = self.add_preload_service("Outlet")
@@ -58,16 +74,36 @@ class XPAccessory(Accessory):
58
74
  value: True for on, False for off.
59
75
  """
60
76
  if self._hk_driver._on_set:
61
- self._hk_driver._on_set(self._accessory_id, value)
77
+ self._hk_driver._on_set(self._accessory_id, value, None)
78
+
79
+ def _set_brightness(self, value: int) -> None:
80
+ """
81
+ Handle HomeKit set brightness request.
82
+
83
+ Args:
84
+ value: Brightness value 0-100.
85
+ """
86
+ if self._hk_driver._on_set:
87
+ self._hk_driver._on_set(self._accessory_id, True, value)
88
+ self._current_brightness = value
62
89
 
63
- def update_state(self, is_on: bool) -> None:
90
+ def update_state(self, is_on: bool, brightness: Optional[int] = None) -> None:
64
91
  """
65
92
  Update accessory state from Conbus event.
66
93
 
67
94
  Args:
68
95
  is_on: True if accessory is on, False otherwise.
96
+ brightness: Optional brightness value 0-100.
69
97
  """
70
98
  self._char_on.set_value(is_on)
99
+ if brightness is not None and self._char_brightness:
100
+ self._char_brightness.set_value(brightness) # type: ignore[attr-defined]
101
+ self._current_brightness = brightness
102
+
103
+ @property
104
+ def current_brightness(self) -> int:
105
+ """Get current brightness value."""
106
+ return self._current_brightness
71
107
 
72
108
 
73
109
  class HomekitAccessoryDriver:
@@ -84,14 +120,15 @@ class HomekitAccessoryDriver:
84
120
  self._homekit_config = homekit_config
85
121
  self._driver: Optional[AccessoryDriver] = None
86
122
  self._accessories: Dict[str, XPAccessory] = {}
87
- self._on_set: Optional[Callable[[str, bool], None]] = None
123
+ self._on_set: Optional[OnSetCallback] = None
88
124
 
89
- def set_callback(self, on_set: Callable[[str, bool], None]) -> None:
125
+ def set_callback(self, on_set: OnSetCallback) -> None:
90
126
  """
91
127
  Set callback for HomeKit set events.
92
128
 
93
129
  Args:
94
- on_set: Callback(accessory_name, is_on) called when HomeKit app toggles.
130
+ on_set: Callback(accessory_name, is_on, brightness) called when HomeKit app changes state.
131
+ brightness is None for on/off only, or 0-100 for dimming.
95
132
  """
96
133
  self._on_set = on_set
97
134
 
@@ -157,15 +194,34 @@ class HomekitAccessoryDriver:
157
194
  except Exception as e:
158
195
  self.logger.error(f"Error stopping AccessoryDriver: {e}", exc_info=True)
159
196
 
160
- def update_state(self, accessory_name: str, is_on: bool) -> None:
197
+ def update_state(
198
+ self, accessory_name: str, is_on: bool, brightness: Optional[int] = None
199
+ ) -> None:
161
200
  """
162
201
  Update accessory state from Conbus event.
163
202
 
164
203
  Args:
165
204
  accessory_name: Accessory name to update.
166
205
  is_on: True if accessory is on, False otherwise.
206
+ brightness: Optional brightness value 0-100.
167
207
  """
168
- if acc := self._accessories.get(accessory_name):
169
- acc.update_state(is_on)
208
+ acc = self._accessories.get(accessory_name)
209
+ if acc:
210
+ acc.update_state(is_on, brightness)
170
211
  else:
171
212
  self.logger.warning(f"Unknown accessory name: {accessory_name}")
213
+
214
+ def get_brightness(self, accessory_name: str) -> int:
215
+ """
216
+ Get current brightness for an accessory.
217
+
218
+ Args:
219
+ accessory_name: Accessory name.
220
+
221
+ Returns:
222
+ Current brightness 0-100, defaults to 100 if not found.
223
+ """
224
+ acc = self._accessories.get(accessory_name)
225
+ if acc:
226
+ return acc.current_brightness
227
+ return 100
@@ -81,6 +81,9 @@ class HomekitService:
81
81
  # Set up HomeKit callback
82
82
  self._accessory_driver.set_callback(self._on_homekit_set)
83
83
 
84
+ # Track active level action: (accessory_id, action_type) or None
85
+ self._active_level_action: Optional[tuple[str, str]] = None
86
+
84
87
  # Connect to protocol signals
85
88
  self._connect_signals()
86
89
 
@@ -297,23 +300,82 @@ class HomekitService:
297
300
  await self._accessory_driver.stop()
298
301
  self.cleanup()
299
302
 
300
- def _on_homekit_set(self, accessory_name: str, is_on: bool) -> None:
303
+ def _on_homekit_set(
304
+ self, accessory_name: str, is_on: bool, brightness: Optional[int]
305
+ ) -> None:
301
306
  """
302
- Handle HomeKit app toggle request.
307
+ Handle HomeKit app set request (on/off or brightness).
303
308
 
304
309
  Args:
305
310
  accessory_name: Accessory name from HomeKit.
306
311
  is_on: True for on, False for off.
312
+ brightness: Brightness value 0-100, or None for on/off only.
307
313
  """
308
314
  config = self._find_accessory_config(accessory_name)
309
- if config:
315
+ if not config:
316
+ self.logger.warning(f"No config found for accessory: {accessory_name}")
317
+ return
318
+
319
+ if brightness is not None:
320
+ # Handle brightness change
321
+ self._handle_brightness_change(accessory_name, config, brightness)
322
+ else:
323
+ # Handle on/off toggle
310
324
  action = config.on_action if is_on else config.off_action
311
325
  self.send_action(action)
312
326
  self.on_status_message.emit(
313
327
  f"HomeKit: {accessory_name} {'ON' if is_on else 'OFF'}"
314
328
  )
329
+
330
+ def _handle_brightness_change(
331
+ self,
332
+ accessory_name: str,
333
+ config: "HomekitAccessoryConfig",
334
+ target_brightness: int,
335
+ ) -> None:
336
+ """
337
+ Handle brightness change by sending dimup/dimdown actions.
338
+
339
+ Calculates delta from current brightness and sends appropriate
340
+ number of LEVELINC or LEVELDEC commands (step = 10%).
341
+
342
+ Args:
343
+ accessory_name: Accessory name.
344
+ config: Accessory configuration.
345
+ target_brightness: Target brightness 0-100.
346
+ """
347
+ current = self._accessory_driver.get_brightness(accessory_name)
348
+ delta = target_brightness - current
349
+
350
+ if delta == 0:
351
+ return
352
+
353
+ # Determine action and steps (10% per step)
354
+ step_size = 10
355
+ steps = abs(delta) // step_size
356
+
357
+ if delta > 0:
358
+ # Increase brightness
359
+ if not config.dimup_action:
360
+ self.logger.warning(f"No dimup_action for {accessory_name}")
361
+ return
362
+ action = config.dimup_action
363
+ direction = "+"
315
364
  else:
316
- self.logger.warning(f"No config found for accessory: {accessory_name}")
365
+ # Decrease brightness
366
+ if not config.dimdown_action:
367
+ self.logger.warning(f"No dimdown_action for {accessory_name}")
368
+ return
369
+ action = config.dimdown_action
370
+ direction = "-"
371
+
372
+ # Send action for each step
373
+ for _ in range(steps):
374
+ self.send_action(action)
375
+
376
+ self.on_status_message.emit(
377
+ f"HomeKit: {accessory_name} {current}% → {target_brightness}% ({direction}{steps * step_size}%)"
378
+ )
317
379
 
318
380
  def send_action(self, action: str) -> None:
319
381
  """
@@ -421,12 +483,15 @@ class HomekitService:
421
483
  Returns:
422
484
  True if command was sent, False otherwise.
423
485
  """
486
+ config = self._find_accessory_config_by_id(accessory_id)
424
487
  state = self._accessory_states.get(accessory_id)
425
- if not state:
488
+ if not config or not state or not config.dimup_action:
489
+ self.logger.warning(f"No config for accessory {accessory_id}")
426
490
  return False
427
- # TODO: Implement dimmer control
428
- self.on_status_message.emit(f"Dimmer+ {state.accessory_name} (not implemented)")
429
- return False
491
+
492
+ self.send_action(config.dimup_action)
493
+ self.on_status_message.emit(f"Dim+ {state.accessory_name}")
494
+ return True
430
495
 
431
496
  def decrease_dimmer(self, accessory_id: str) -> bool:
432
497
  """
@@ -438,12 +503,103 @@ class HomekitService:
438
503
  Returns:
439
504
  True if command was sent, False otherwise.
440
505
  """
506
+ config = self._find_accessory_config_by_id(accessory_id)
441
507
  state = self._accessory_states.get(accessory_id)
442
- if not state:
508
+ if not config or not state or not config.dimdown_action:
509
+ self.logger.warning(f"No config for accessory {accessory_id}")
443
510
  return False
444
- # TODO: Implement dimmer control
445
- self.on_status_message.emit(f"Dimmer- {state.accessory_name} (not implemented)")
446
- return False
511
+
512
+ self.send_action(config.dimdown_action)
513
+ self.on_status_message.emit(f"Dim- {state.accessory_name}")
514
+ return True
515
+
516
+ def levelup_selected(self, accessory_id: str) -> bool:
517
+ """
518
+ Increase level for accessory (toggle Make/Break).
519
+
520
+ First press sends Make (M), second press sends Break (B).
521
+
522
+ Args:
523
+ accessory_id: Accessory ID (e.g., "A12_1").
524
+
525
+ Returns:
526
+ True if command was sent, False otherwise.
527
+ """
528
+ config = self._find_accessory_config_by_id(accessory_id)
529
+ state = self._accessory_states.get(accessory_id)
530
+ if not config or not state or not config.levelup_action:
531
+ self.logger.warning(f"No config for accessory {accessory_id}")
532
+ return False
533
+
534
+ return self._send_level_action(
535
+ accessory_id, "levelup", config.levelup_action, state.accessory_name
536
+ )
537
+
538
+ def leveldown_selected(self, accessory_id: str) -> bool:
539
+ """
540
+ Decrease level for accessory (toggle Make/Break).
541
+
542
+ First press sends Make (M), second press sends Break (B).
543
+
544
+ Args:
545
+ accessory_id: Accessory ID (e.g., "A12_1").
546
+
547
+ Returns:
548
+ True if command was sent, False otherwise.
549
+ """
550
+ config = self._find_accessory_config_by_id(accessory_id)
551
+ state = self._accessory_states.get(accessory_id)
552
+ if not config or not state or not config.leveldown_action:
553
+ self.logger.warning(f"No config for accessory {accessory_id}")
554
+ return False
555
+
556
+ return self._send_level_action(
557
+ accessory_id, "leveldown", config.leveldown_action, state.accessory_name
558
+ )
559
+
560
+ def _send_level_action(
561
+ self, accessory_id: str, action_type: str, action: str, name: str
562
+ ) -> bool:
563
+ """
564
+ Send level action with Make/Break toggle.
565
+
566
+ Args:
567
+ accessory_id: Accessory ID.
568
+ action_type: "levelup" or "leveldown".
569
+ action: Action code (e.g., "E02L13I15").
570
+ name: Accessory name for status message.
571
+
572
+ Returns:
573
+ True if command was sent.
574
+ """
575
+ current = self._active_level_action
576
+
577
+ # If same action is active, send Break and clear
578
+ if current and current[0] == accessory_id and current[1] == action_type:
579
+ self._conbus_protocol.send_raw_telegram(f"{action}B")
580
+ self._active_level_action = None
581
+ direction = "+" if action_type == "levelup" else "-"
582
+ self.on_status_message.emit(f"Level{direction} {name} [B]")
583
+ return True
584
+
585
+ # If different action is active, send Break for it first
586
+ if current:
587
+ old_config = self._find_accessory_config_by_id(current[0])
588
+ if old_config:
589
+ old_action = (
590
+ old_config.levelup_action
591
+ if current[1] == "levelup"
592
+ else old_config.leveldown_action
593
+ )
594
+ if old_action:
595
+ self._conbus_protocol.send_raw_telegram(f"{old_action}B")
596
+
597
+ # Send Make for new action
598
+ self._conbus_protocol.send_raw_telegram(f"{action}M")
599
+ self._active_level_action = (accessory_id, action_type)
600
+ direction = "+" if action_type == "levelup" else "-"
601
+ self.on_status_message.emit(f"Level{direction} {name} [M]")
602
+ return True
447
603
 
448
604
  def refresh_all(self) -> None:
449
605
  """
xp/term/homekit.py CHANGED
@@ -41,6 +41,8 @@ class HomekitApp(App[None]):
41
41
  ("minus", "turn_off_selected", "Off"),
42
42
  ("plus", "dim_up", "Dim+"),
43
43
  ("quotation_mark", "dim_down", "Dim-"),
44
+ ("asterisk", "level_up", "Level+"),
45
+ ("ç", "level_down", "Level-"),
44
46
  ]
45
47
 
46
48
  def __init__(self, homekit_service: HomekitService) -> None:
@@ -106,12 +108,17 @@ class HomekitApp(App[None]):
106
108
  - - : Turn OFF
107
109
  - + : Dim up
108
110
  - " : Dim down
111
+ - * : Level up
112
+ - ç : Level down
109
113
 
110
114
  Args:
111
115
  event: Key press event.
112
116
  """
113
117
  key = event.key
114
118
 
119
+ # Debug: show received key
120
+ self.homekit_service.on_status_message.emit(f"Key: {key}")
121
+
115
122
  # Selection keys (a-z0-9)
116
123
  if len(key) == 1 and (("a" <= key <= "z") or ("0" <= key <= "9")):
117
124
  accessory_id = self.homekit_service.select_accessory(key)
@@ -140,6 +147,12 @@ class HomekitApp(App[None]):
140
147
  elif key in ("quotation_mark", '"'):
141
148
  self.homekit_service.decrease_dimmer(self.selected_accessory_id)
142
149
  event.prevent_default()
150
+ elif key in ("asterisk", "star", "*"):
151
+ self.homekit_service.levelup_selected(self.selected_accessory_id)
152
+ event.prevent_default()
153
+ elif key in ("cedille", "ç"):
154
+ self.homekit_service.leveldown_selected(self.selected_accessory_id)
155
+ event.prevent_default()
143
156
 
144
157
  def _select_row(self, action_key: str) -> None:
145
158
  """
@@ -243,6 +256,16 @@ class HomekitApp(App[None]):
243
256
  if self.selected_accessory_id:
244
257
  self.homekit_service.decrease_dimmer(self.selected_accessory_id)
245
258
 
259
+ def action_level_up(self) -> None:
260
+ """Increase level on selected accessory."""
261
+ if self.selected_accessory_id:
262
+ self.homekit_service.levelup_selected(self.selected_accessory_id)
263
+
264
+ def action_level_down(self) -> None:
265
+ """Decrease level on selected accessory."""
266
+ if self.selected_accessory_id:
267
+ self.homekit_service.leveldown_selected(self.selected_accessory_id)
268
+
246
269
  async def on_unmount(self) -> None:
247
270
  """Stop AccessoryDriver and clean up service when app unmounts."""
248
271
  await self.homekit_service.stop()