cfclient 2017.4__py3-none-any.whl → 2025.12.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.
Files changed (140) hide show
  1. cfclient/__init__.py +16 -11
  2. cfclient/configs/config.json +4 -3
  3. cfclient/configs/input/Generic_OS_X.json +1 -0
  4. cfclient/configs/input/Joystick.json +1 -0
  5. cfclient/configs/input/PS3_Mode_1.json +1 -0
  6. cfclient/configs/input/PS3_Mode_2.json +1 -0
  7. cfclient/configs/input/PS3_Mode_3.json +1 -0
  8. cfclient/configs/input/PS4_Mode_1.json +1 -0
  9. cfclient/configs/input/PS4_Mode_2.json +1 -0
  10. cfclient/configs/input/PS4_shoulder_btns_yaw.json +1 -0
  11. cfclient/configs/input/xbox360_mode1.json +1 -0
  12. cfclient/configs/log/PID_tuning/Attitude.json +46 -0
  13. cfclient/configs/log/PID_tuning/Attitude_rate.json +46 -0
  14. cfclient/configs/log/PID_tuning/Position.json +46 -0
  15. cfclient/configs/log/PID_tuning/Velocity.json +46 -0
  16. cfclient/configs/log/PID_tuning_components/Pitch.json +22 -0
  17. cfclient/configs/log/PID_tuning_components/Pitch_rate.json +22 -0
  18. cfclient/configs/log/PID_tuning_components/Position_x.json +22 -0
  19. cfclient/configs/log/PID_tuning_components/Position_y.json +22 -0
  20. cfclient/configs/log/PID_tuning_components/Position_z.json +22 -0
  21. cfclient/configs/log/PID_tuning_components/Roll.json +22 -0
  22. cfclient/configs/log/PID_tuning_components/Roll_rate.json +22 -0
  23. cfclient/configs/log/PID_tuning_components/Velocity_x.json +22 -0
  24. cfclient/configs/log/PID_tuning_components/Velocity_y.json +22 -0
  25. cfclient/configs/log/PID_tuning_components/Velocity_z.json +22 -0
  26. cfclient/configs/log/PID_tuning_components/Yaw.json +22 -0
  27. cfclient/configs/log/PID_tuning_components/Yaw_rate.json +22 -0
  28. cfclient/gui.py +44 -9
  29. cfclient/headless.py +3 -12
  30. cfclient/resources/log_param_doc.json +1 -0
  31. cfclient/ui/connectivity_manager.py +198 -0
  32. cfclient/ui/dialogs/about.py +53 -36
  33. cfclient/ui/dialogs/about.ui +23 -3
  34. cfclient/ui/dialogs/anchor_position_dialog.py +252 -0
  35. cfclient/ui/dialogs/anchor_position_dialog.ui +138 -0
  36. cfclient/ui/dialogs/basestation_mode_dialog.py +185 -0
  37. cfclient/ui/dialogs/basestation_mode_dialog.ui +186 -0
  38. cfclient/ui/dialogs/bootloader.py +448 -85
  39. cfclient/ui/dialogs/bootloader.ui +387 -134
  40. cfclient/ui/dialogs/cf2config.py +4 -4
  41. cfclient/ui/dialogs/cf2config.ui +3 -4
  42. cfclient/ui/dialogs/inputconfigdialogue.py +24 -19
  43. cfclient/ui/dialogs/inputconfigdialogue.ui +53 -30
  44. cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.py +220 -0
  45. cfclient/ui/dialogs/lighthouse_bs_geometry_dialog.ui +110 -0
  46. cfclient/ui/dialogs/lighthouse_system_type_dialog.py +93 -0
  47. cfclient/ui/dialogs/lighthouse_system_type_dialog.ui +121 -0
  48. cfclient/ui/dialogs/logconfigdialogue.py +401 -101
  49. cfclient/ui/dialogs/logconfigdialogue.ui +117 -72
  50. cfclient/ui/icons/bl.webp +0 -0
  51. cfclient/ui/icons/bolt.webp +0 -0
  52. cfclient/ui/icons/cf21.webp +0 -0
  53. cfclient/ui/icons/checkmark_black.png +0 -0
  54. cfclient/ui/icons/checkmark_white.png +0 -0
  55. cfclient/ui/icons/create.png +0 -0
  56. cfclient/ui/icons/delete.png +0 -0
  57. cfclient/ui/icons/flapper.webp +0 -0
  58. cfclient/ui/icons/tag.webp +0 -0
  59. cfclient/ui/main.py +328 -258
  60. cfclient/ui/main.ui +184 -80
  61. cfclient/ui/pluginhelper.py +7 -1
  62. cfclient/ui/pose_logger.py +116 -0
  63. cfclient/ui/tab_toolbox.py +208 -0
  64. cfclient/ui/tabs/ColorLEDTab.py +752 -0
  65. cfclient/ui/tabs/ConsoleTab.py +48 -13
  66. cfclient/ui/{toolboxes → tabs}/CrtpSharkToolbox.py +19 -34
  67. cfclient/ui/tabs/ExampleTab.py +9 -16
  68. cfclient/ui/tabs/FlightTab.py +437 -325
  69. cfclient/ui/tabs/GpsTab.py +14 -20
  70. cfclient/ui/tabs/LEDRingTab.py +277 -0
  71. cfclient/ui/tabs/LogBlockDebugTab.py +20 -27
  72. cfclient/ui/tabs/LogBlockTab.py +35 -35
  73. cfclient/ui/tabs/LogClientTab.py +85 -0
  74. cfclient/ui/tabs/LogTab.py +50 -27
  75. cfclient/ui/tabs/ParamTab.py +443 -57
  76. cfclient/ui/tabs/PlotTab.py +23 -25
  77. cfclient/ui/tabs/TuningTab.py +292 -0
  78. cfclient/ui/tabs/__init__.py +12 -2
  79. cfclient/ui/tabs/colorLEDTab.ui +624 -0
  80. cfclient/ui/tabs/consoleTab.ui +46 -0
  81. cfclient/ui/tabs/flightActionContainer.ui +103 -0
  82. cfclient/ui/tabs/flightTab.ui +724 -237
  83. cfclient/ui/tabs/{ledTab.ui → ledRingTab.ui} +63 -46
  84. cfclient/ui/tabs/lighthouse_tab.py +714 -0
  85. cfclient/ui/tabs/lighthouse_tab.ui +430 -0
  86. cfclient/ui/tabs/locopositioning_tab.py +606 -389
  87. cfclient/ui/tabs/locopositioning_tab.ui +370 -253
  88. cfclient/ui/tabs/logClientTab.ui +52 -0
  89. cfclient/ui/tabs/logTab.ui +1 -1
  90. cfclient/ui/tabs/paramTab.ui +204 -3
  91. cfclient/ui/tabs/tuningTab.ui +773 -0
  92. cfclient/ui/widgets/ai.py +37 -39
  93. cfclient/ui/widgets/hexspinbox.py +16 -10
  94. cfclient/ui/widgets/plotter.ui +39 -47
  95. cfclient/ui/widgets/plotwidget.py +57 -22
  96. cfclient/ui/widgets/super_slider.py +112 -0
  97. cfclient/ui/wizards/__init__.py +0 -0
  98. cfclient/ui/wizards/bslh_1.png +0 -0
  99. cfclient/ui/wizards/bslh_2.png +0 -0
  100. cfclient/ui/wizards/bslh_3.png +0 -0
  101. cfclient/ui/wizards/bslh_4.png +0 -0
  102. cfclient/ui/wizards/bslh_5.png +0 -0
  103. cfclient/ui/wizards/lighthouse_geo_bs_estimation_wizard.py +465 -0
  104. cfclient/utils/config_manager.py +5 -4
  105. cfclient/utils/input/__init__.py +77 -19
  106. cfclient/utils/input/inputinterfaces/wiimote.py +2 -2
  107. cfclient/utils/input/inputreaderinterface.py +17 -7
  108. cfclient/utils/input/inputreaders/__init__.py +17 -0
  109. cfclient/utils/logconfigreader.py +245 -25
  110. cfclient/utils/logdatawriter.py +3 -1
  111. cfclient/utils/periodictimer.py +1 -1
  112. cfclient/utils/ui.py +336 -0
  113. cfclient/utils/zmq_led_driver.py +5 -0
  114. cfclient/utils/zmq_param.py +6 -0
  115. cfclient/version.py +34 -1
  116. cfclient-2025.12.1.dist-info/METADATA +70 -0
  117. cfclient-2025.12.1.dist-info/RECORD +152 -0
  118. {cfclient-2017.4.dist-info → cfclient-2025.12.1.dist-info}/WHEEL +1 -1
  119. {cfclient-2017.4.dist-info → cfclient-2025.12.1.dist-info}/entry_points.txt +0 -1
  120. cfclient-2025.12.1.dist-info/licenses/LICENSE.txt +350 -0
  121. {cfclient-2017.4.dist-info → cfclient-2025.12.1.dist-info}/top_level.txt +1 -0
  122. cfconfig/Makefile +51 -0
  123. cfconfig/configblock.py +111 -0
  124. cfloader/__init__.py +41 -55
  125. cfzmq/__init__.py +22 -14
  126. cfclient/ui/dialogs/cf1config.py +0 -265
  127. cfclient/ui/dialogs/cf1config.ui +0 -260
  128. cfclient/ui/tab.py +0 -96
  129. cfclient/ui/tabs/LEDTab.py +0 -169
  130. cfclient/ui/toolboxes/ConsoleToolbox.py +0 -69
  131. cfclient/ui/toolboxes/DebugDriverToolbox.py +0 -107
  132. cfclient/ui/toolboxes/__init__.py +0 -45
  133. cfclient/ui/toolboxes/consoleToolbox.ui +0 -62
  134. cfclient/ui/toolboxes/debugDriverToolbox.ui +0 -86
  135. cfclient-2017.4.dist-info/DESCRIPTION.rst +0 -3
  136. cfclient-2017.4.dist-info/METADATA +0 -22
  137. cfclient-2017.4.dist-info/RECORD +0 -104
  138. cfclient-2017.4.dist-info/metadata.json +0 -1
  139. /cfclient/{icon-256.png → ui/icons/icon-256.png} +0 -0
  140. /cfclient/ui/{toolboxes → tabs}/crtpSharkToolbox.ui +0 -0
@@ -0,0 +1,752 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ #
4
+ # || ____ _ __
5
+ # +------+ / __ )(_) /_______________ _____ ___
6
+ # | 0xBC | / __ / / __/ ___/ ___/ __ `/_ / / _ \
7
+ # +------+ / /_/ / / /_/ /__/ / / /_/ / / /_/ __/
8
+ # || || /_____/_/\__/\___/_/ \__,_/ /___/\___/
9
+ #
10
+ # Copyright (C) 2011-2025 Bitcraze AB
11
+ #
12
+ # Crazyflie Nano Quadcopter Client
13
+ #
14
+ # This program is free software; you can redistribute it and/or
15
+ # modify it under the terms of the GNU General Public License
16
+ # as published by the Free Software Foundation; either version 2
17
+ # of the License, or (at your option) any later version.
18
+ #
19
+ # This program is distributed in the hope that it will be useful,
20
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
21
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22
+ # GNU General Public License for more details.
23
+
24
+ # You should have received a copy of the GNU General Public License
25
+ # along with this program; if not, write to the Free Software
26
+ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
27
+ # 02110-1301, USA.
28
+
29
+ """
30
+ Basic tab to be able to set (and test) color in Color LED.
31
+ """
32
+
33
+ import logging
34
+ from PyQt6 import uic
35
+ from PyQt6.QtCore import Qt, pyqtSignal
36
+ from PyQt6.QtGui import QColor, QPixmap, QPainter, QLinearGradient, QPen, QPainterPath
37
+ from PyQt6.QtWidgets import QPushButton, QMessageBox
38
+ from cflib.crazyflie.log import LogConfig
39
+
40
+ import cfclient
41
+ from cfclient.ui.tab_toolbox import TabToolbox
42
+ from cfclient.utils.config import Config
43
+
44
+ __author__ = 'Bitcraze AB'
45
+ __all__ = ['ColorLEDTab']
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ color_led_tab_class = uic.loadUiType(cfclient.module_path + "/ui/tabs/colorLEDTab.ui")[0] # type: ignore
50
+
51
+
52
+ class wrgb_t:
53
+ def __init__(self, w: int, r: int, g: int, b: int):
54
+ self.w = w
55
+ self.r = r
56
+ self.g = g
57
+ self.b = b
58
+
59
+ def pack(self) -> int:
60
+ """Pack WRGB values into uint32 format: 0xWWRRGGBB"""
61
+ return (self.w << 24) | (self.r << 16) | (self.g << 8) | self.b
62
+
63
+
64
+ class rgb_t:
65
+ def __init__(self, r: int, g: int, b: int):
66
+ self.r = r
67
+ self.g = g
68
+ self.b = b
69
+
70
+ def __iter__(self):
71
+ return iter([self.r, self.g, self.b])
72
+
73
+ def extract_white(self) -> wrgb_t:
74
+ """Extract white channel and return WRGB color"""
75
+ white = min(self.r, self.g, self.b)
76
+ return wrgb_t(white, self.r - white, self.g - white, self.b - white)
77
+
78
+
79
+ class ThermalMonitor:
80
+ """Monitors thermal throttling status for Color LED decks"""
81
+
82
+ def __init__(self, helper, params_config, log_data_signal, log_error_signal):
83
+ """
84
+ Initialize thermal monitor
85
+
86
+ Args:
87
+ helper: Crazyflie helper instance
88
+ params_config: Dictionary mapping positions to their thermal log names
89
+ log_data_signal: PyQt signal to emit when log data is received
90
+ log_error_signal: PyQt signal to emit when log errors occur
91
+ """
92
+ self._helper = helper
93
+ self._params_config = params_config
94
+ self._log_data_signal = log_data_signal
95
+ self._log_error_signal = log_error_signal
96
+ self._active_positions = []
97
+
98
+ def start_monitoring(self, positions):
99
+ """
100
+ Start thermal monitoring for specified deck positions
101
+
102
+ Args:
103
+ positions: List of position indices to monitor
104
+ """
105
+ self._active_positions = positions
106
+
107
+ for position in positions:
108
+ if position not in self._params_config:
109
+ continue
110
+
111
+ params = self._params_config[position]
112
+ log_name = f"Thermal{['Bottom', 'Top'][position]}"
113
+ lg = LogConfig(log_name, Config().get("ui_update_period"))
114
+
115
+ try:
116
+ lg.add_variable(params['thermal_log'], "uint8_t")
117
+ self._helper.cf.log.add_config(lg)
118
+ lg.data_received_cb.add_callback(self._log_data_signal.emit)
119
+ lg.error_cb.add_callback(self._log_error_signal.emit)
120
+ lg.start()
121
+ logger.debug(f"Started thermal logging for position {position}: {params['thermal_log']}")
122
+ except (KeyError, AttributeError) as e:
123
+ logger.debug(f"Could not start thermal logging for position {position}: {e}")
124
+
125
+ def check_throttling(self, data, positions_to_check):
126
+ """
127
+ Check if any of the specified positions are thermally throttling
128
+
129
+ Args:
130
+ data: Log data dictionary
131
+ positions_to_check: List of position indices to check
132
+
133
+ Returns:
134
+ bool: True if any position is throttling
135
+ """
136
+ return any(
137
+ self._params_config[pos]['thermal_log'] in data and
138
+ data[self._params_config[pos]['thermal_log']]
139
+ for pos in positions_to_check
140
+ if pos in self._params_config
141
+ )
142
+
143
+
144
+ class ColorLEDDeckController:
145
+ """Manages Color LED deck detection and parameter writes"""
146
+
147
+ def __init__(self, helper, params_config):
148
+ """
149
+ Initialize deck controller
150
+
151
+ Args:
152
+ helper: Crazyflie helper instance
153
+ params_config: Dictionary mapping positions to their parameter names
154
+ """
155
+ self._helper = helper
156
+ self._params_config = params_config
157
+ self._deck_present = {} # Maps position -> bool
158
+
159
+ def detect_decks(self):
160
+ """Detect which Color LED decks are present"""
161
+ for position, params in self._params_config.items():
162
+ try:
163
+ deck_param = self._helper.cf.param.get_value(params['deck_param'])
164
+ # Convert to int to handle string values like "0" or "1"
165
+ self._deck_present[position] = bool(int(deck_param))
166
+ logger.debug(f"Color LED deck at position {position} ({'Bottom' if position == 0 else 'Top'}) "
167
+ f"detected: {self._deck_present[position]} (param: {params['deck_param']}={deck_param})")
168
+ except (KeyError, ValueError, TypeError) as e:
169
+ self._deck_present[position] = False
170
+ logger.debug(f"Color LED deck parameter not found for position {position}: {e}")
171
+
172
+ def is_deck_present(self, position):
173
+ """Check if deck at given position is present"""
174
+ return self._deck_present.get(position, False)
175
+
176
+ def get_present_decks(self):
177
+ """Get list of positions where decks are present"""
178
+ return [pos for pos, present in self._deck_present.items() if present]
179
+
180
+ def write_color(self, position, color_uint32):
181
+ """
182
+ Write color to a specific deck position
183
+
184
+ Args:
185
+ position: Deck position (0=bottom, 1=top)
186
+ color_uint32: Packed WRGB color value
187
+ """
188
+ if position not in self._params_config:
189
+ logger.warning(f"Unknown position {position}")
190
+ return
191
+
192
+ if not self.is_deck_present(position):
193
+ logger.debug(f"Color LED deck at position {position} not present, skipping color write")
194
+ return
195
+
196
+ param_name = self._params_config[position]['color']
197
+ self._helper.cf.param.set_value(param_name, str(color_uint32))
198
+
199
+ def clear_deck_state(self):
200
+ """Clear deck presence state (called on disconnect)"""
201
+ self._deck_present.clear()
202
+
203
+
204
+ class ColorLEDTab(TabToolbox, color_led_tab_class):
205
+ """Tab with inline color picker with hue slider, SV area, and hex input."""
206
+
207
+ _colorChanged = pyqtSignal(QColor)
208
+ _connectedSignal = pyqtSignal(str)
209
+ _disconnectedSignal = pyqtSignal(str)
210
+ _log_data_signal = pyqtSignal(int, object, object)
211
+ _log_error_signal = pyqtSignal(object, str)
212
+
213
+ # Parameter and log names by position
214
+ PARAMS_BY_POSITION = {
215
+ 0: { # Bottom
216
+ 'color': 'colorLedBot.wrgb8888',
217
+ 'thermal_log': 'colorLedBot.throttlePct',
218
+ 'brightness': 'colorLedBot.brightCorr',
219
+ 'deck_param': 'deck.bcColorLedBot'
220
+ },
221
+ 1: { # Top
222
+ 'color': 'colorLedTop.wrgb8888',
223
+ 'thermal_log': 'colorLedTop.throttlePct',
224
+ 'brightness': 'colorLedTop.brightCorr',
225
+ 'deck_param': 'deck.bcColorLedTop'
226
+ }
227
+ }
228
+
229
+ def __init__(self, helper):
230
+ super(ColorLEDTab, self).__init__(helper, 'Color LED')
231
+ self.setupUi(self)
232
+
233
+ self.groupBox_color.setEnabled(False)
234
+ self.hue_bar.setEnabled(False)
235
+
236
+ self._populate_position_dropdown()
237
+
238
+ self._hue = 0
239
+ self._saturation = 0
240
+ self._value = 0
241
+
242
+ self.hue_bar.setMinimum(0)
243
+ self.hue_bar.setMaximum(1000)
244
+ self.hue_bar.setValue(int(self._hue * 1000))
245
+ self.hue_bar.valueChanged.connect(self._on_hue_slider_changed)
246
+ self.hue_bar.setMouseTracking(True)
247
+
248
+ self.hex_input.editingFinished.connect(self._on_hex_changed)
249
+
250
+ self.custom_color_buttons = []
251
+ self._connect_color_buttons()
252
+
253
+ self.add_color_button.clicked.connect(self._add_custom_color_button)
254
+
255
+ self.sv_area.setMouseTracking(True)
256
+ self.sv_area.setStyleSheet("""
257
+ QLabel {
258
+ border-radius: 8px;
259
+ border: 1px solid #444;
260
+ background-color: #222;
261
+ }
262
+ """)
263
+
264
+ self._isConnected = False
265
+ self._updating_from_fetch = False # Flag to prevent writes during color fetch
266
+ self._throttling_state = {} # Track last known throttling state per position
267
+
268
+ # Initialize thermal monitor and deck controller
269
+ self._thermal_monitor = ThermalMonitor(
270
+ self._helper,
271
+ self.PARAMS_BY_POSITION,
272
+ self._log_data_signal,
273
+ self._log_error_signal
274
+ )
275
+ self._deck_controller = ColorLEDDeckController(
276
+ self._helper,
277
+ self.PARAMS_BY_POSITION
278
+ )
279
+
280
+ self._connectedSignal.connect(self._connected)
281
+ self._disconnectedSignal.connect(self._disconnected)
282
+ self._colorChanged.connect(self._write_color_parameter)
283
+ self._log_data_signal.connect(self._log_data_received)
284
+ self._log_error_signal.connect(self._logging_error)
285
+
286
+ self._helper.cf.connected.add_callback(self._connectedSignal.emit)
287
+ self._helper.cf.disconnected.add_callback(self._disconnectedSignal.emit)
288
+
289
+ # Handle position dropdown changes
290
+ self.positionDropdown.currentIndexChanged.connect(self._on_position_changed)
291
+
292
+ def _logging_error(self, log_conf, msg):
293
+ QMessageBox.about(self, "Log error",
294
+ "Error when starting log config [%s]: %s" % (
295
+ log_conf.name, msg))
296
+
297
+ def _log_data_received(self, _timestamp, data, _logconf):
298
+ if not self.isVisible():
299
+ return
300
+
301
+ position = self.positionDropdown.currentData()
302
+
303
+ # Determine which positions to check for throttling
304
+ positions_to_check = [0, 1] if position == 2 else [position]
305
+
306
+ # Update throttling state for positions that have data in this update
307
+ has_thermal_data = False
308
+ for pos in positions_to_check:
309
+ if pos in self.PARAMS_BY_POSITION:
310
+ thermal_log = self.PARAMS_BY_POSITION[pos]['thermal_log']
311
+ if thermal_log in data:
312
+ has_thermal_data = True
313
+ # Update state for this position
314
+ self._throttling_state[pos] = bool(data[thermal_log])
315
+
316
+ if has_thermal_data:
317
+ # Check if any selected position is currently throttling based on known state
318
+ is_throttling = any(
319
+ self._throttling_state.get(pos, False)
320
+ for pos in positions_to_check
321
+ )
322
+
323
+ self.information_text.setText(
324
+ "Throttling: Lowering intensity to lower temperature." if is_throttling else ""
325
+ )
326
+
327
+ def _connected(self, _):
328
+ self._isConnected = True
329
+
330
+ # Detect which color LED decks are attached
331
+ self._deck_controller.detect_decks()
332
+
333
+ # Update dropdown based on detected decks
334
+ self._update_position_dropdown()
335
+
336
+ # Enable UI controls only if at least one deck is present
337
+ present_decks = self._deck_controller.get_present_decks()
338
+ has_decks = len(present_decks) > 0
339
+ self.groupBox_color.setEnabled(has_decks)
340
+ self.hue_bar.setEnabled(has_decks)
341
+
342
+ # Set up thermal logging for available decks
343
+ if has_decks:
344
+ self._thermal_monitor.start_monitoring(present_decks)
345
+
346
+ def _disconnected(self, _):
347
+ self._isConnected = False
348
+ self._deck_controller.clear_deck_state()
349
+ self._throttling_state.clear() # Clear throttling state
350
+
351
+ # Disable UI controls
352
+ self.groupBox_color.setEnabled(False)
353
+ self.hue_bar.setEnabled(False)
354
+
355
+ # Disable all position dropdown items
356
+ self.positionDropdown.model().item(0).setEnabled(False)
357
+ self.positionDropdown.model().item(1).setEnabled(False)
358
+ self.positionDropdown.model().item(2).setEnabled(False)
359
+
360
+ self.information_text.setText("") # clear thermal throttling warning
361
+
362
+ def _write_color_parameter(self, color: QColor):
363
+ if not self._isConnected:
364
+ return
365
+
366
+ # Don't write when we're updating UI from a fetch operation
367
+ if self._updating_from_fetch:
368
+ return
369
+
370
+ r, g, b, _ = color.getRgb()
371
+ rgb = rgb_t(r or 0, g or 0, b or 0)
372
+ wrgb = rgb.extract_white()
373
+ color_uint32 = wrgb.pack()
374
+
375
+ position = self.positionDropdown.currentData()
376
+
377
+ # Determine which positions to write to
378
+ positions_to_write = [0, 1] if position == 2 else [position]
379
+
380
+ for pos in positions_to_write:
381
+ self._deck_controller.write_color(pos, color_uint32)
382
+
383
+ def _on_position_changed(self, _):
384
+ """Handle position dropdown changes by fetching current color from Crazyflie"""
385
+ if not self._isConnected:
386
+ return
387
+
388
+ position = self.positionDropdown.currentData()
389
+
390
+ if position == 2: # Both
391
+ # Fetch both colors and compare
392
+ color_bottom = self._fetch_color_from_position(0)
393
+ color_top = self._fetch_color_from_position(1)
394
+
395
+ if color_bottom is not None and color_top is not None:
396
+ if color_bottom == color_top:
397
+ # Both are the same, show that color
398
+ self._update_ui_from_rgb(color_bottom)
399
+ else:
400
+ # Different colors, show black
401
+ self._update_ui_from_rgb((0, 0, 0))
402
+ else:
403
+ # Could not fetch one or both colors, show black
404
+ self._update_ui_from_rgb((0, 0, 0))
405
+ else: # Bottom (0) or Top (1)
406
+ color = self._fetch_color_from_position(position)
407
+ if color is not None:
408
+ self._update_ui_from_rgb(color)
409
+ else:
410
+ # Could not fetch color, show black
411
+ self._update_ui_from_rgb((0, 0, 0))
412
+
413
+ def _fetch_color_from_position(self, position):
414
+ """
415
+ Fetch current color from Crazyflie for given position
416
+
417
+ Args:
418
+ position: 0 for bottom, 1 for top
419
+
420
+ Returns:
421
+ tuple (r, g, b) or None if fetch failed
422
+ """
423
+ if position not in self.PARAMS_BY_POSITION:
424
+ return None
425
+
426
+ if not self._deck_controller.is_deck_present(position):
427
+ return None
428
+
429
+ try:
430
+ param_name = self.PARAMS_BY_POSITION[position]['color']
431
+ color_uint32 = int(self._helper.cf.param.get_value(param_name))
432
+
433
+ # Unpack WRGB: 0xWWRRGGBB
434
+ w = (color_uint32 >> 24) & 0xFF
435
+ r = (color_uint32 >> 16) & 0xFF
436
+ g = (color_uint32 >> 8) & 0xFF
437
+ b = color_uint32 & 0xFF
438
+
439
+ # Convert WRGB back to RGB by adding white channel back
440
+ r_full = r + w
441
+ g_full = g + w
442
+ b_full = b + w
443
+
444
+ return (r_full, g_full, b_full)
445
+ except (KeyError, ValueError, TypeError) as e:
446
+ logger.debug(f"Could not fetch color from position {position}: {e}")
447
+ return None
448
+
449
+ def _update_ui_from_rgb(self, rgb):
450
+ """
451
+ Update UI controls from RGB values without writing back to Crazyflie
452
+
453
+ Args:
454
+ rgb: tuple (r, g, b)
455
+ """
456
+ self._updating_from_fetch = True
457
+ try:
458
+ r, g, b = rgb
459
+ color = QColor(r, g, b)
460
+ h, s, v, _ = color.getHsvF()
461
+
462
+ self._hue = h or 0
463
+ self._saturation = s or 0
464
+ self._value = v or 0
465
+
466
+ self.hue_bar.setValue(int(self._hue * 1000))
467
+ self._update_sv_area(self.sv_area, self._hue)
468
+ self._update_preview()
469
+ finally:
470
+ self._updating_from_fetch = False
471
+
472
+ def _populate_position_dropdown(self):
473
+ """Initialize the position dropdown with items (called once during __init__)"""
474
+ self.positionDropdown.addItem("Bottom", 0)
475
+ self.positionDropdown.addItem("Top", 1)
476
+ self.positionDropdown.addItem("Both", 2)
477
+
478
+ # Initially all disabled until we connect and detect decks
479
+ self.positionDropdown.model().item(0).setEnabled(False)
480
+ self.positionDropdown.model().item(1).setEnabled(False)
481
+ self.positionDropdown.model().item(2).setEnabled(False)
482
+
483
+ def _update_position_dropdown(self):
484
+ """Update position dropdown based on detected decks"""
485
+ is_bottom_attached = self._deck_controller.is_deck_present(0)
486
+ is_top_attached = self._deck_controller.is_deck_present(1)
487
+
488
+ if is_bottom_attached and is_top_attached:
489
+ self.positionDropdown.model().item(0).setEnabled(True)
490
+ self.positionDropdown.model().item(1).setEnabled(True)
491
+ self.positionDropdown.model().item(2).setEnabled(True)
492
+ # Default to "Both" if both attached
493
+ self.positionDropdown.setCurrentIndex(2)
494
+ elif is_bottom_attached:
495
+ self.positionDropdown.model().item(0).setEnabled(True)
496
+ self.positionDropdown.model().item(1).setEnabled(False)
497
+ self.positionDropdown.model().item(2).setEnabled(False)
498
+ self.positionDropdown.setCurrentIndex(0)
499
+ elif is_top_attached:
500
+ self.positionDropdown.model().item(0).setEnabled(False)
501
+ self.positionDropdown.model().item(1).setEnabled(True)
502
+ self.positionDropdown.model().item(2).setEnabled(False)
503
+ self.positionDropdown.setCurrentIndex(1)
504
+ else:
505
+ # No decks attached
506
+ self.positionDropdown.model().item(0).setEnabled(False)
507
+ self.positionDropdown.model().item(1).setEnabled(False)
508
+ self.positionDropdown.model().item(2).setEnabled(False)
509
+
510
+ def showEvent(self, a0):
511
+ """ Show event for proper initial SV area sizing """
512
+ super().showEvent(a0)
513
+ self._update_sv_area(self.sv_area, self._hue)
514
+ # Update preview without writing to Crazyflie
515
+ self._updating_from_fetch = True
516
+ try:
517
+ self._update_preview()
518
+ finally:
519
+ self._updating_from_fetch = False
520
+
521
+ def mousePressEvent(self, a0):
522
+ self._handle_mouse_event(a0)
523
+
524
+ def mouseMoveEvent(self, a0):
525
+ self._handle_mouse_event(a0)
526
+
527
+ def _handle_mouse_event(self, event):
528
+ if not self.groupBox_color.isEnabled():
529
+ return
530
+ sv_pos = self.sv_area.mapFrom(self, event.pos())
531
+ if self.sv_area.rect().contains(sv_pos):
532
+ self._update_sv_from_pos(sv_pos)
533
+
534
+ def _update_sv_from_pos(self, pos):
535
+ x = pos.x()
536
+ y = pos.y()
537
+ w = self.sv_area.width()
538
+ h = self.sv_area.height()
539
+
540
+ self._saturation = max(0, min(1, x / w))
541
+ self._value = 1 - max(0, min(1, y / h))
542
+ self._update_preview()
543
+ self._update_sv_area(self.sv_area, self._hue)
544
+
545
+ def _update_sv_area(self, sv_area, hue):
546
+ width = sv_area.width()
547
+ height = sv_area.height()
548
+ if width <= 0 or height <= 0:
549
+ return
550
+
551
+ pixmap = QPixmap(width, height)
552
+ pixmap.fill(Qt.GlobalColor.transparent)
553
+
554
+ painter = QPainter(pixmap)
555
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
556
+
557
+ """ Rounded corners """
558
+ path = QPainterPath()
559
+ corner_radius = 8
560
+ path.addRoundedRect(0, 0, width, height, corner_radius, corner_radius)
561
+ painter.setClipPath(path)
562
+
563
+ """ Base hue layer """
564
+ base_color = QColor.fromHsvF(hue, 1, 1)
565
+ painter.fillRect(pixmap.rect(), base_color)
566
+
567
+ """ Saturation overlay (white → transparent) """
568
+ sat_gradient = QLinearGradient(0, 0, width, 0)
569
+ sat_gradient.setColorAt(0, Qt.GlobalColor.white)
570
+ sat_gradient.setColorAt(1, Qt.GlobalColor.transparent)
571
+ painter.fillRect(pixmap.rect(), sat_gradient)
572
+
573
+ """ Value overlay (transparent → black) """
574
+ val_gradient = QLinearGradient(0, 0, 0, height)
575
+ val_gradient.setColorAt(0, Qt.GlobalColor.transparent)
576
+ val_gradient.setColorAt(1, Qt.GlobalColor.black)
577
+ painter.fillRect(pixmap.rect(), val_gradient)
578
+
579
+ """ Outline """
580
+ outline_pen = QPen(Qt.GlobalColor.darkGray)
581
+ outline_pen.setWidth(1)
582
+ painter.setPen(outline_pen)
583
+ painter.setBrush(Qt.BrushStyle.NoBrush)
584
+ painter.drawPath(path)
585
+
586
+ """ Circle selector """
587
+ sel_x = int(self._saturation * width)
588
+ sel_y = int((1 - self._value) * height)
589
+ selector_radius = 6
590
+ pen = QPen(Qt.GlobalColor.white)
591
+ pen.setWidth(2)
592
+ painter.setPen(pen)
593
+ painter.setBrush(Qt.BrushStyle.NoBrush)
594
+ painter.drawEllipse(sel_x - selector_radius, sel_y - selector_radius, selector_radius * 2, selector_radius * 2)
595
+
596
+ painter.end()
597
+ sv_area.setPixmap(pixmap)
598
+
599
+ def _on_hue_slider_changed(self, value):
600
+ if not self.groupBox_color.isEnabled():
601
+ return
602
+ self._hue = value / 1000
603
+ self._update_sv_area(self.sv_area, self._hue)
604
+ self._update_preview()
605
+
606
+ def _update_preview(self):
607
+ color = QColor.fromHsvF(self._hue or 0, self._saturation or 0, self._value or 0)
608
+ self.color_preview.setStyleSheet(f"""
609
+ QFrame#color_preview {{
610
+ border: 1px solid #444;
611
+ border-radius: 4px;
612
+ }}
613
+
614
+ QFrame#color_preview:enabled {{
615
+ background-color: {color.name()};
616
+ }}
617
+
618
+ QFrame#color_preview:disabled {{
619
+ background-color: #777777; /* greyed out when disconnected */
620
+ }}
621
+ """)
622
+ self.hex_input.setText(color.name().upper())
623
+ self._colorChanged.emit(color)
624
+
625
+ def _on_hex_changed(self):
626
+ if not self.groupBox_color.isEnabled():
627
+ return
628
+ hex_value = self.hex_input.text().strip()
629
+ color = QColor(hex_value)
630
+ if color.isValid():
631
+ h, s, v, _ = color.getHsvF()
632
+ self._hue, self._saturation, self._value = h or 0, s or 0, v or 0
633
+ self.hue_bar.setValue(int(self._hue * 1000))
634
+ self._update_sv_area(self.sv_area, self._hue)
635
+ self._update_preview()
636
+ self.hex_input.setStyleSheet("")
637
+ self.hex_error_label.setText("")
638
+ self.information_text.setText("")
639
+ else:
640
+ self.hex_input.setStyleSheet("border: 2px solid red; border-radius: 4px;")
641
+ self.hex_error_label.setText("Invalid hex code.")
642
+ self.information_text.setText("")
643
+ logger.warning(f"Invalid HEX color: {hex_value}")
644
+
645
+ def _connect_color_buttons(self):
646
+ color_buttons = [
647
+ self.color_button1,
648
+ self.color_button2,
649
+ self.color_button3,
650
+ self.color_button4,
651
+ self.color_button5,
652
+ self.color_button6,
653
+ self.color_button7,
654
+ self.color_button8,
655
+ self.color_button9,
656
+ self.color_button10,
657
+ ]
658
+ for btn in color_buttons:
659
+ btn.clicked.connect(self._on_color_button_clicked)
660
+
661
+ def _on_color_button_clicked(self):
662
+ if not self.groupBox_color.isEnabled():
663
+ return
664
+ button = self.sender()
665
+ style = button.styleSheet() # type: ignore
666
+ if "background-color:" not in style:
667
+ return
668
+ hex_color = style.split("background-color:")[-1].split(";")[0].strip()
669
+ color = QColor(hex_color)
670
+ if color.isValid():
671
+ h, s, v, _ = color.getHsvF()
672
+ self._hue, self._saturation, self._value = h or 0, s or 0, v or 0
673
+ self.hue_bar.setValue(int(self._hue * 1000))
674
+ self._update_sv_area(self.sv_area, self._hue)
675
+ self._update_preview()
676
+
677
+ def _add_custom_color_button(self):
678
+ color_hex = self.hex_input.text().strip()
679
+ if not color_hex.startswith("#"):
680
+ color_hex = "#" + color_hex
681
+
682
+ color = QColor(color_hex)
683
+ if not color.isValid():
684
+ logger.warning(f"Invalid custom color: {color_hex}")
685
+ return
686
+
687
+ new_btn = QPushButton()
688
+ new_btn.setStyleSheet(f"background-color: {color_hex};")
689
+ new_btn.setFixedSize(50, 30)
690
+ new_btn.clicked.connect(self._on_color_button_clicked)
691
+ new_btn.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
692
+ new_btn.customContextMenuRequested.connect(lambda pos, btn=new_btn: self._remove_custom_color_button(btn))
693
+
694
+ self.custom_color_buttons.append(new_btn)
695
+
696
+ self._repack_custom_buttons()
697
+ logger.debug(f"Added new custom color {color_hex}")
698
+
699
+ def _remove_custom_color_button(self, button):
700
+ reply = QMessageBox.question(
701
+ self,
702
+ "Remove Custom Color",
703
+ "Do you want to remove this custom color?",
704
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
705
+ )
706
+ if reply == QMessageBox.StandardButton.Yes:
707
+ if button in self.custom_color_buttons:
708
+ self.custom_color_buttons.remove(button)
709
+ button.setParent(None)
710
+ button.deleteLater()
711
+ logger.debug("Removed custom color button.")
712
+
713
+ self._repack_custom_buttons()
714
+
715
+ def _repack_custom_buttons(self):
716
+ grid = self.gridLayout_5
717
+ plus_button = self.add_color_button
718
+
719
+ total_cols = 8
720
+ row, col = 0, 0
721
+
722
+ preset_buttons = [
723
+ self.color_button1,
724
+ self.color_button2,
725
+ self.color_button3,
726
+ self.color_button4,
727
+ self.color_button5,
728
+ self.color_button6,
729
+ self.color_button7,
730
+ self.color_button8,
731
+ self.color_button9,
732
+ self.color_button10,
733
+ ]
734
+
735
+ for btn in self.custom_color_buttons + [plus_button]:
736
+ grid.removeWidget(btn)
737
+
738
+ for btn in preset_buttons:
739
+ grid.addWidget(btn, row, col)
740
+ col += 1
741
+ if col >= total_cols:
742
+ col = 0
743
+ row += 1
744
+
745
+ for btn in self.custom_color_buttons:
746
+ grid.addWidget(btn, row, col)
747
+ col += 1
748
+ if col >= total_cols:
749
+ col = 0
750
+ row += 1
751
+
752
+ grid.addWidget(plus_button, row, col)