simo 2.11.3__py3-none-any.whl → 3.0.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.
Potentially problematic release.
This version of simo might be problematic. Click here for more details.
- simo/__pycache__/settings.cpython-312.pyc +0 -0
- simo/asgi.py +25 -6
- simo/automation/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/automation/controllers.py +18 -2
- simo/automation/forms.py +15 -24
- simo/backups/rescue.img.xz +0 -0
- simo/core/__pycache__/admin.cpython-312.pyc +0 -0
- simo/core/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/core/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/core/__pycache__/forms.cpython-312.pyc +0 -0
- simo/core/__pycache__/models.cpython-312.pyc +0 -0
- simo/core/__pycache__/serializers.cpython-312.pyc +0 -0
- simo/core/__pycache__/signal_receivers.cpython-312.pyc +0 -0
- simo/core/__pycache__/tasks.cpython-312.pyc +0 -0
- simo/core/admin.py +5 -4
- simo/core/base_types.py +191 -18
- simo/core/controllers.py +259 -26
- simo/core/forms.py +10 -2
- simo/core/management/_hub_template/hub/nginx.conf +23 -50
- simo/core/management/_hub_template/hub/supervisor.conf +15 -0
- simo/core/mcp.py +154 -0
- simo/core/migrations/0051_instance_ai_memory.py +18 -0
- simo/core/migrations/__pycache__/0051_instance_ai_memory.cpython-312.pyc +0 -0
- simo/core/models.py +3 -0
- simo/core/serializers.py +120 -0
- simo/core/signal_receivers.py +1 -1
- simo/core/tasks.py +1 -3
- simo/core/utils/__pycache__/type_constants.cpython-312.pyc +0 -0
- simo/core/utils/type_constants.py +78 -17
- simo/fleet/__pycache__/admin.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/api.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/forms.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/gateways.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/models.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/serializers.cpython-312.pyc +0 -0
- simo/fleet/admin.py +5 -1
- simo/fleet/api.py +2 -27
- simo/fleet/base_types.py +35 -4
- simo/fleet/controllers.py +150 -156
- simo/fleet/forms.py +56 -88
- simo/fleet/gateways.py +8 -15
- simo/fleet/migrations/0055_colonel_is_vo_active_colonel_last_wake_and_more.py +28 -0
- simo/fleet/migrations/0056_delete_customdalidevice.py +16 -0
- simo/fleet/migrations/__pycache__/0055_colonel_is_vo_active_colonel_last_wake_and_more.cpython-312.pyc +0 -0
- simo/fleet/migrations/__pycache__/0056_delete_customdalidevice.cpython-312.pyc +0 -0
- simo/fleet/models.py +13 -72
- simo/fleet/serializers.py +1 -48
- simo/fleet/socket_consumers.py +100 -39
- simo/fleet/tasks.py +2 -22
- simo/fleet/voice_assistant.py +893 -0
- simo/generic/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/generic/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/generic/__pycache__/gateways.cpython-312.pyc +0 -0
- simo/generic/base_types.py +70 -10
- simo/generic/controllers.py +102 -15
- simo/generic/gateways.py +10 -10
- simo/mcp_server/__init__.py +0 -0
- simo/mcp_server/__pycache__/__init__.cpython-312.pyc +0 -0
- simo/mcp_server/__pycache__/admin.cpython-312.pyc +0 -0
- simo/mcp_server/__pycache__/models.cpython-312.pyc +0 -0
- simo/mcp_server/admin.py +18 -0
- simo/mcp_server/app.py +4 -0
- simo/mcp_server/auth.py +34 -0
- simo/mcp_server/dummy.py +22 -0
- simo/mcp_server/migrations/0001_initial.py +30 -0
- simo/mcp_server/migrations/0002_alter_instanceaccesstoken_date_expired.py +18 -0
- simo/mcp_server/migrations/0003_instanceaccesstoken_issuer.py +18 -0
- simo/mcp_server/migrations/__init__.py +0 -0
- simo/mcp_server/migrations/__pycache__/0001_initial.cpython-312.pyc +0 -0
- simo/mcp_server/migrations/__pycache__/0002_alter_instanceaccesstoken_date_expired.cpython-312.pyc +0 -0
- simo/mcp_server/migrations/__pycache__/0003_instanceaccesstoken_issuer.cpython-312.pyc +0 -0
- simo/mcp_server/migrations/__pycache__/__init__.cpython-312.pyc +0 -0
- simo/mcp_server/models.py +27 -0
- simo/mcp_server/server.py +60 -0
- simo/mcp_server/tasks.py +19 -0
- simo/multimedia/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/multimedia/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/multimedia/base_types.py +29 -4
- simo/multimedia/controllers.py +66 -19
- simo/settings.py +1 -0
- simo/users/__pycache__/utils.cpython-312.pyc +0 -0
- simo/users/utils.py +10 -0
- {simo-2.11.3.dist-info → simo-3.0.1.dist-info}/METADATA +12 -4
- {simo-2.11.3.dist-info → simo-3.0.1.dist-info}/RECORD +90 -64
- simo/fleet/custom_dali_operations.py +0 -287
- {simo-2.11.3.dist-info → simo-3.0.1.dist-info}/WHEEL +0 -0
- {simo-2.11.3.dist-info → simo-3.0.1.dist-info}/entry_points.txt +0 -0
- {simo-2.11.3.dist-info → simo-3.0.1.dist-info}/licenses/LICENSE.md +0 -0
- {simo-2.11.3.dist-info → simo-3.0.1.dist-info}/top_level.txt +0 -0
simo/core/controllers.py
CHANGED
|
@@ -14,6 +14,13 @@ from simo.users.utils import introduce_user, get_current_user, get_device_user
|
|
|
14
14
|
from .utils.helpers import is_hex_color, classproperty
|
|
15
15
|
# from django.utils.functional import classproperty
|
|
16
16
|
from .gateways import BaseGatewayHandler
|
|
17
|
+
from .base_types import (
|
|
18
|
+
BaseComponentType,
|
|
19
|
+
NumericSensorType, MultiSensorType, BinarySensorType, ButtonType,
|
|
20
|
+
DimmerType, DimmerPlusType, RGBWLightType, SwitchType,
|
|
21
|
+
DoubleSwitchType, TripleSwitchType, QuadrupleSwitchType, QuintupleSwitchType,
|
|
22
|
+
LockType, BlindsType, GateType
|
|
23
|
+
)
|
|
17
24
|
from .app_widgets import *
|
|
18
25
|
from .forms import (
|
|
19
26
|
BaseComponentForm, NumericSensorForm,
|
|
@@ -29,7 +36,24 @@ BEFORE_SEND = 'before-send'
|
|
|
29
36
|
BEFORE_SET = 'before-set'
|
|
30
37
|
|
|
31
38
|
|
|
32
|
-
class
|
|
39
|
+
class ControllerMeta(ABCMeta):
|
|
40
|
+
"""Metaclass that normalizes class-level access to `base_type`.
|
|
41
|
+
|
|
42
|
+
If a controller sets `base_type` to a BaseComponentType subclass,
|
|
43
|
+
accessing `Controller.base_type` returns the slug string to preserve
|
|
44
|
+
legacy comparisons. Assignment remains the class.
|
|
45
|
+
"""
|
|
46
|
+
def __getattribute__(cls, name):
|
|
47
|
+
val = super().__getattribute__(name)
|
|
48
|
+
if name == 'base_type':
|
|
49
|
+
if isinstance(val, str):
|
|
50
|
+
return val
|
|
51
|
+
if isinstance(val, type) and issubclass(val, BaseComponentType):
|
|
52
|
+
return val.slug
|
|
53
|
+
return val
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ControllerBase(ABC, metaclass=ControllerMeta):
|
|
33
57
|
config_form = BaseComponentForm
|
|
34
58
|
admin_widget_template = 'admin/controller_widgets/generic.html'
|
|
35
59
|
default_config = {}
|
|
@@ -40,6 +64,7 @@ class ControllerBase(ABC):
|
|
|
40
64
|
family = None
|
|
41
65
|
masters_only = False # component can be created/modified by hub masters only
|
|
42
66
|
info_template_path = None
|
|
67
|
+
accepts_value = True
|
|
43
68
|
|
|
44
69
|
@property
|
|
45
70
|
@abstractmethod
|
|
@@ -57,9 +82,9 @@ class ControllerBase(ABC):
|
|
|
57
82
|
|
|
58
83
|
@property
|
|
59
84
|
@abstractmethod
|
|
60
|
-
def base_type(self)
|
|
61
|
-
"""
|
|
62
|
-
|
|
85
|
+
def base_type(self):
|
|
86
|
+
"""Base type identifier. Accepts either a slug string (legacy)
|
|
87
|
+
or a BaseComponentType subclass (preferred).
|
|
63
88
|
"""
|
|
64
89
|
|
|
65
90
|
@property
|
|
@@ -91,8 +116,18 @@ class ControllerBase(ABC):
|
|
|
91
116
|
assert issubclass(self.gateway_class, BaseGatewayHandler)
|
|
92
117
|
assert issubclass(self.config_form, BaseComponentForm)
|
|
93
118
|
assert issubclass(self.app_widget, BaseAppWidget)
|
|
94
|
-
|
|
95
|
-
|
|
119
|
+
# Normalize to slug whether base_type is a string or a BaseComponentType subclass
|
|
120
|
+
bt = getattr(self.__class__, 'base_type', None)
|
|
121
|
+
# ControllerMeta makes class attribute access return slug when
|
|
122
|
+
# a BaseComponentType subclass is assigned to base_type.
|
|
123
|
+
# So `bt` should already be a slug string in most cases.
|
|
124
|
+
if isinstance(bt, str):
|
|
125
|
+
slug = bt
|
|
126
|
+
elif isinstance(bt, type) and issubclass(bt, BaseComponentType):
|
|
127
|
+
slug = bt.slug
|
|
128
|
+
else:
|
|
129
|
+
slug = getattr(bt, 'slug', None)
|
|
130
|
+
assert slug in ALL_BASE_TYPES, f"{slug} must be defined in BASE TYPES!"
|
|
96
131
|
|
|
97
132
|
@classproperty
|
|
98
133
|
@classmethod
|
|
@@ -230,6 +265,12 @@ class ControllerBase(ABC):
|
|
|
230
265
|
return vals
|
|
231
266
|
|
|
232
267
|
def send(self, value):
|
|
268
|
+
"""Send a value/command to the device via the gateway.
|
|
269
|
+
|
|
270
|
+
Prefer controller-specific helpers (e.g., `turn_on()`, `open()`) when
|
|
271
|
+
available. This performs validation, publishes to the gateway, and
|
|
272
|
+
updates the component value when appropriate.
|
|
273
|
+
"""
|
|
233
274
|
from .models import Component
|
|
234
275
|
try:
|
|
235
276
|
self.component.refresh_from_db()
|
|
@@ -270,7 +311,17 @@ class ControllerBase(ABC):
|
|
|
270
311
|
self.component.value_previous = self.component.value
|
|
271
312
|
self.component.value = value
|
|
272
313
|
|
|
273
|
-
def set(self, value, actor=None):
|
|
314
|
+
def set(self, value, actor=None, alive=True, error_msg=None):
|
|
315
|
+
"""Set the component value locally and record history.
|
|
316
|
+
|
|
317
|
+
This is called by `send()` after the device confirms or when device
|
|
318
|
+
reports a new value. Do not call from clients directly; prefer
|
|
319
|
+
controller action methods or `send()`.
|
|
320
|
+
|
|
321
|
+
Parameters:
|
|
322
|
+
- value: JSON-serializable value after translation/validation for this type.
|
|
323
|
+
- actor: Optional user that initiated the change.
|
|
324
|
+
"""
|
|
274
325
|
from simo.users.models import InstanceUser
|
|
275
326
|
if self.component.value_translation:
|
|
276
327
|
try:
|
|
@@ -308,6 +359,8 @@ class ControllerBase(ABC):
|
|
|
308
359
|
self.component.change_init_date = None
|
|
309
360
|
self.component.change_init_to = None
|
|
310
361
|
self.component.change_init_fingerprint = None
|
|
362
|
+
self.component.alive = alive
|
|
363
|
+
self.component.error_msg = error_msg
|
|
311
364
|
self.component.change_actor = InstanceUser.objects.filter(
|
|
312
365
|
instance=self.component.zone.instance,
|
|
313
366
|
user=actor
|
|
@@ -372,7 +425,11 @@ class ControllerBase(ABC):
|
|
|
372
425
|
]
|
|
373
426
|
|
|
374
427
|
def poke(self):
|
|
375
|
-
|
|
428
|
+
"""Best-effort wake-up: quickly toggle output to nudge the device.
|
|
429
|
+
|
|
430
|
+
Some gateways/devices can recover from a brief toggle. This flips
|
|
431
|
+
the state on subsequent calls to stimulate a response.
|
|
432
|
+
"""
|
|
376
433
|
pass
|
|
377
434
|
|
|
378
435
|
def _prepare_for_send(self, value):
|
|
@@ -392,6 +449,20 @@ class TimerMixin:
|
|
|
392
449
|
"Controller must have toggle method defined to support timer mixin."
|
|
393
450
|
|
|
394
451
|
def set_timer(self, to_timestamp, event=None):
|
|
452
|
+
"""Schedule a controller action at a specific UNIX timestamp.
|
|
453
|
+
|
|
454
|
+
Parameters:
|
|
455
|
+
- to_timestamp (float|int): Absolute UNIX epoch seconds in the future.
|
|
456
|
+
- event (str|None): Optional controller method name to invoke when the
|
|
457
|
+
timer elapses. Defaults to 'toggle' if not provided or invalid.
|
|
458
|
+
|
|
459
|
+
Behavior:
|
|
460
|
+
- Stores timer metadata in component.meta and persists it. The gateway or
|
|
461
|
+
background tasks are expected to call the method when due.
|
|
462
|
+
|
|
463
|
+
Raises:
|
|
464
|
+
- ValidationError: if `to_timestamp` is not in the future.
|
|
465
|
+
"""
|
|
395
466
|
if to_timestamp > time.time():
|
|
396
467
|
self.component.refresh_from_db()
|
|
397
468
|
self.component.meta['timer_to'] = to_timestamp
|
|
@@ -409,6 +480,13 @@ class TimerMixin:
|
|
|
409
480
|
)
|
|
410
481
|
|
|
411
482
|
def pause_timer(self):
|
|
483
|
+
"""Pause a running timer.
|
|
484
|
+
|
|
485
|
+
Stores the remaining time and clears the scheduled timestamp.
|
|
486
|
+
|
|
487
|
+
Raises:
|
|
488
|
+
- ValidationError: if no timer is currently scheduled.
|
|
489
|
+
"""
|
|
412
490
|
if self.component.meta.get('timer_to', 0) > time.time():
|
|
413
491
|
time_left = self.component.meta['timer_to'] - time.time()
|
|
414
492
|
self.component.meta['timer_left'] = time_left
|
|
@@ -420,6 +498,13 @@ class TimerMixin:
|
|
|
420
498
|
)
|
|
421
499
|
|
|
422
500
|
def resume_timer(self):
|
|
501
|
+
"""Resume a previously paused timer.
|
|
502
|
+
|
|
503
|
+
Recomputes the target timestamp from the saved remaining time.
|
|
504
|
+
|
|
505
|
+
Raises:
|
|
506
|
+
- ValidationError: if the timer is not in a paused state.
|
|
507
|
+
"""
|
|
423
508
|
if self.component.meta.get('timer_left', 0):
|
|
424
509
|
self.component.meta['timer_to'] = \
|
|
425
510
|
time.time() + self.component.meta['timer_left']
|
|
@@ -431,12 +516,14 @@ class TimerMixin:
|
|
|
431
516
|
)
|
|
432
517
|
|
|
433
518
|
def stop_timer(self):
|
|
519
|
+
"""Stop and clear any active or paused timer for this component."""
|
|
434
520
|
self.component.meta['timer_to'] = 0
|
|
435
521
|
self.component.meta['timer_left'] = 0
|
|
436
522
|
self.component.meta['timer_start'] = 0
|
|
437
523
|
self.component.save(update_fields=['meta'])
|
|
438
524
|
|
|
439
525
|
def timer_engaged(self):
|
|
526
|
+
"""Return True if there is an active or paused timer configured."""
|
|
440
527
|
return any([
|
|
441
528
|
self.component.meta.get('timer_to'),
|
|
442
529
|
self.component.meta.get('timer_left'),
|
|
@@ -449,9 +536,10 @@ class TimerMixin:
|
|
|
449
536
|
|
|
450
537
|
class NumericSensor(ControllerBase):
|
|
451
538
|
name = _("Numeric sensor")
|
|
452
|
-
base_type =
|
|
539
|
+
base_type = NumericSensorType
|
|
453
540
|
config_form = NumericSensorForm
|
|
454
541
|
default_value = 0
|
|
542
|
+
accepts_value = False
|
|
455
543
|
|
|
456
544
|
def _validate_val(self, value, occasion=None):
|
|
457
545
|
if type(value) not in (int, float, D):
|
|
@@ -473,7 +561,7 @@ class NumericSensor(ControllerBase):
|
|
|
473
561
|
|
|
474
562
|
class MultiSensor(ControllerBase):
|
|
475
563
|
name = _("Multi sensor")
|
|
476
|
-
base_type =
|
|
564
|
+
base_type = MultiSensorType
|
|
477
565
|
app_widget = MultiSensorWidget
|
|
478
566
|
config_form = MultiSensorConfigForm
|
|
479
567
|
default_value = [
|
|
@@ -481,6 +569,7 @@ class MultiSensor(ControllerBase):
|
|
|
481
569
|
["Value 2", 50, "ᴼ C"],
|
|
482
570
|
["Value 3", False, ""]
|
|
483
571
|
]
|
|
572
|
+
accepts_value = False
|
|
484
573
|
|
|
485
574
|
def _validate_val(self, value, occasion=None):
|
|
486
575
|
if len(value) != len(self.default_value):
|
|
@@ -520,6 +609,12 @@ class MultiSensor(ControllerBase):
|
|
|
520
609
|
|
|
521
610
|
|
|
522
611
|
def get_val(self, param):
|
|
612
|
+
"""Return value for a given label in a multi-sensor array.
|
|
613
|
+
|
|
614
|
+
Parameters:
|
|
615
|
+
- param (str): The label/name of the vector to read.
|
|
616
|
+
Returns: The value part from [label, value, unit] or None.
|
|
617
|
+
"""
|
|
523
618
|
for item in self.component.value:
|
|
524
619
|
if item[0] == param:
|
|
525
620
|
return item[1]
|
|
@@ -527,10 +622,11 @@ class MultiSensor(ControllerBase):
|
|
|
527
622
|
|
|
528
623
|
class BinarySensor(ControllerBase):
|
|
529
624
|
name = _("Binary sensor")
|
|
530
|
-
base_type =
|
|
625
|
+
base_type = BinarySensorType
|
|
531
626
|
app_widget = BinarySensorWidget
|
|
532
627
|
admin_widget_template = 'admin/controller_widgets/binary_sensor.html'
|
|
533
628
|
default_value = False
|
|
629
|
+
accepts_value = False
|
|
534
630
|
|
|
535
631
|
def _validate_val(self, value, occasion=None):
|
|
536
632
|
if not isinstance(value, bool):
|
|
@@ -545,7 +641,7 @@ class BinarySensor(ControllerBase):
|
|
|
545
641
|
|
|
546
642
|
class Button(ControllerBase):
|
|
547
643
|
name = _("Button")
|
|
548
|
-
base_type =
|
|
644
|
+
base_type = ButtonType
|
|
549
645
|
app_widget = ButtonWidget
|
|
550
646
|
admin_widget_template = 'admin/controller_widgets/button.html'
|
|
551
647
|
default_value = 'up'
|
|
@@ -555,13 +651,28 @@ class Button(ControllerBase):
|
|
|
555
651
|
raise ValidationError("Bad button value!")
|
|
556
652
|
return value
|
|
557
653
|
|
|
654
|
+
def send(self, value):
|
|
655
|
+
"""Simulate a button event.
|
|
656
|
+
|
|
657
|
+
Parameters:
|
|
658
|
+
- value (str): One of 'down', 'up', 'hold', 'click', 'double-click'.
|
|
659
|
+
"""
|
|
660
|
+
return super().send(value)
|
|
661
|
+
|
|
558
662
|
def is_down(self):
|
|
663
|
+
"""Return True while the button is pressed ('down' or 'hold')."""
|
|
559
664
|
return self.component.value in ('down', 'hold')
|
|
560
665
|
|
|
561
666
|
def is_held(self):
|
|
667
|
+
"""Return True if the button is currently in 'hold' state."""
|
|
562
668
|
return self.component.value == 'hold'
|
|
563
669
|
|
|
564
670
|
def get_bonded_gear(self):
|
|
671
|
+
"""Return components configured to be controlled by this button.
|
|
672
|
+
|
|
673
|
+
Scans other components' config for controls referencing this button's id.
|
|
674
|
+
Returns: list[Component]
|
|
675
|
+
"""
|
|
565
676
|
from simo.core.models import Component
|
|
566
677
|
gear = []
|
|
567
678
|
for comp in Component.objects.filter(config__has_key='controls'):
|
|
@@ -576,6 +687,7 @@ class OnOffPokerMixin:
|
|
|
576
687
|
_poke_toggle = False
|
|
577
688
|
|
|
578
689
|
def poke(self):
|
|
690
|
+
"""Best-effort wake-up: briefly toggle output to stimulate response."""
|
|
579
691
|
if self._poke_toggle:
|
|
580
692
|
self._poke_toggle = False
|
|
581
693
|
self.turn_on()
|
|
@@ -586,7 +698,7 @@ class OnOffPokerMixin:
|
|
|
586
698
|
|
|
587
699
|
class Dimmer(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
588
700
|
name = _("Dimmer")
|
|
589
|
-
base_type =
|
|
701
|
+
base_type = DimmerType
|
|
590
702
|
app_widget = KnobWidget
|
|
591
703
|
config_form = DimmerConfigForm
|
|
592
704
|
admin_widget_template = 'admin/controller_widgets/knob.html'
|
|
@@ -624,9 +736,15 @@ class Dimmer(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
|
624
736
|
return value
|
|
625
737
|
|
|
626
738
|
def turn_off(self):
|
|
739
|
+
"""Turn output to the configured minimum level (usually 0%)."""
|
|
627
740
|
self.send(self.component.config.get('min', 0.0))
|
|
628
741
|
|
|
629
742
|
def turn_on(self):
|
|
743
|
+
"""Turn output on, restoring the last level when possible.
|
|
744
|
+
|
|
745
|
+
If there is a previous level, restores it; otherwise uses configured
|
|
746
|
+
maximum level.
|
|
747
|
+
"""
|
|
630
748
|
self.component.refresh_from_db()
|
|
631
749
|
if not self.component.value:
|
|
632
750
|
if self.component.value_previous:
|
|
@@ -635,15 +753,22 @@ class Dimmer(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
|
635
753
|
self.send(self.component.config.get('max', 90))
|
|
636
754
|
|
|
637
755
|
def max_out(self):
|
|
756
|
+
"""Set output to the configured maximum level."""
|
|
638
757
|
self.send(self.component.config.get('max', 90))
|
|
639
758
|
|
|
640
759
|
def output_percent(self, value):
|
|
760
|
+
"""Set output by percentage (0–100).
|
|
761
|
+
|
|
762
|
+
Parameters:
|
|
763
|
+
- value (int|float): Percentage of configured [min, max] range.
|
|
764
|
+
"""
|
|
641
765
|
min = self.component.config.get('min', 0)
|
|
642
766
|
max = self.component.config.get('max', 100)
|
|
643
767
|
delta = max - min
|
|
644
768
|
self.send(min + delta * value / 100)
|
|
645
769
|
|
|
646
770
|
def toggle(self):
|
|
771
|
+
"""Toggle output: turn off if non-zero, otherwise turn on."""
|
|
647
772
|
self.component.refresh_from_db()
|
|
648
773
|
if self.component.value:
|
|
649
774
|
self.turn_off()
|
|
@@ -651,18 +776,30 @@ class Dimmer(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
|
651
776
|
self.turn_on()
|
|
652
777
|
|
|
653
778
|
def fade_up(self):
|
|
779
|
+
"""Start increasing brightness smoothly (if supported by gateway)."""
|
|
654
780
|
raise NotImplemented()
|
|
655
781
|
|
|
656
782
|
def fade_down(self):
|
|
783
|
+
"""Start decreasing brightness smoothly (if supported by gateway)."""
|
|
657
784
|
raise NotImplemented()
|
|
658
785
|
|
|
659
786
|
def fade_stop(self):
|
|
787
|
+
"""Stop any ongoing fade operation (if supported by gateway)."""
|
|
660
788
|
raise NotImplemented()
|
|
661
789
|
|
|
790
|
+
def send(self, value):
|
|
791
|
+
"""Set dimmer level.
|
|
792
|
+
|
|
793
|
+
Parameters:
|
|
794
|
+
- value (int|float): absolute level within configured min/max, or
|
|
795
|
+
- value (bool): True to restore previous/non-zero level, False for 0.
|
|
796
|
+
"""
|
|
797
|
+
return super().send(value)
|
|
798
|
+
|
|
662
799
|
|
|
663
800
|
class DimmerPlus(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
664
801
|
name = _("Dimmer Plus")
|
|
665
|
-
base_type =
|
|
802
|
+
base_type = DimmerPlusType
|
|
666
803
|
app_widget = KnobPlusWidget
|
|
667
804
|
config_form = DimmerPlusConfigForm
|
|
668
805
|
default_config = {
|
|
@@ -712,6 +849,7 @@ class DimmerPlus(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
|
712
849
|
return value
|
|
713
850
|
|
|
714
851
|
def turn_off(self):
|
|
852
|
+
"""Turn both channels to their configured minimums."""
|
|
715
853
|
self.send(
|
|
716
854
|
{
|
|
717
855
|
'main': self.component.config.get('main_min', 0.0),
|
|
@@ -721,6 +859,7 @@ class DimmerPlus(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
|
721
859
|
)
|
|
722
860
|
|
|
723
861
|
def turn_on(self):
|
|
862
|
+
"""Turn on: main at maximum, secondary to mid-range."""
|
|
724
863
|
self.component.refresh_from_db()
|
|
725
864
|
if not self.component.value:
|
|
726
865
|
if self.component.value_previous:
|
|
@@ -734,6 +873,7 @@ class DimmerPlus(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
|
734
873
|
})
|
|
735
874
|
|
|
736
875
|
def toggle(self):
|
|
876
|
+
"""Toggle on/off based on the 'main' channel state."""
|
|
737
877
|
if self.component.value.get('main'):
|
|
738
878
|
self.turn_off()
|
|
739
879
|
else:
|
|
@@ -741,18 +881,30 @@ class DimmerPlus(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
|
741
881
|
|
|
742
882
|
|
|
743
883
|
def fade_up(self):
|
|
884
|
+
"""Start increasing brightness smoothly (if supported by gateway)."""
|
|
744
885
|
raise NotImplemented()
|
|
745
886
|
|
|
746
887
|
def fade_down(self):
|
|
888
|
+
"""Start decreasing brightness smoothly (if supported by gateway)."""
|
|
747
889
|
raise NotImplemented()
|
|
748
890
|
|
|
749
891
|
def fade_stop(self):
|
|
892
|
+
"""Stop any ongoing fade operation (if supported by gateway)."""
|
|
750
893
|
raise NotImplemented()
|
|
751
894
|
|
|
895
|
+
def send(self, value):
|
|
896
|
+
"""Set Dimmer Plus channels.
|
|
897
|
+
|
|
898
|
+
Parameters:
|
|
899
|
+
- value (dict): {'main': number, 'secondary': number}, or
|
|
900
|
+
- value (bool): True to restore previous, False to minimums.
|
|
901
|
+
"""
|
|
902
|
+
return super().send(value)
|
|
903
|
+
|
|
752
904
|
|
|
753
905
|
class RGBWLight(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
754
906
|
name = _("RGB(W) Light")
|
|
755
|
-
base_type =
|
|
907
|
+
base_type = RGBWLightType
|
|
756
908
|
app_widget = RGBWidget
|
|
757
909
|
config_form = RGBWConfigForm
|
|
758
910
|
admin_widget_template = 'admin/controller_widgets/rgb.html'
|
|
@@ -793,16 +945,19 @@ class RGBWLight(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
|
793
945
|
return value
|
|
794
946
|
|
|
795
947
|
def turn_off(self):
|
|
948
|
+
"""Turn the light off (sets `is_on` to False and sends current value)."""
|
|
796
949
|
self.component.refresh_from_db()
|
|
797
950
|
self.component.value['is_on'] = False
|
|
798
951
|
self.send(self.component.value)
|
|
799
952
|
|
|
800
953
|
def turn_on(self):
|
|
954
|
+
"""Turn the light on (sets `is_on` to True and sends current value)."""
|
|
801
955
|
self.component.refresh_from_db()
|
|
802
956
|
self.component.value['is_on'] = True
|
|
803
957
|
self.send(self.component.value)
|
|
804
958
|
|
|
805
959
|
def toggle(self):
|
|
960
|
+
"""Toggle the light between on and off."""
|
|
806
961
|
self.component.refresh_from_db()
|
|
807
962
|
self.component.value['is_on'] = not self.component.value['is_on']
|
|
808
963
|
self.send(self.component.value)
|
|
@@ -817,6 +972,15 @@ class RGBWLight(ControllerBase, TimerMixin, OnOffPokerMixin):
|
|
|
817
972
|
def fade_stop(self):
|
|
818
973
|
raise NotImplemented()
|
|
819
974
|
|
|
975
|
+
def send(self, value):
|
|
976
|
+
"""Set RGB(W) light scenes and on/off.
|
|
977
|
+
|
|
978
|
+
Parameters:
|
|
979
|
+
- value (dict): {'scenes': ["#rrggbb[ww]"...], 'active': int 0-4,
|
|
980
|
+
'is_on': bool}
|
|
981
|
+
"""
|
|
982
|
+
return super().send(value)
|
|
983
|
+
|
|
820
984
|
|
|
821
985
|
class MultiSwitchBase(ControllerBase):
|
|
822
986
|
|
|
@@ -847,37 +1011,59 @@ class MultiSwitchBase(ControllerBase):
|
|
|
847
1011
|
))
|
|
848
1012
|
return value
|
|
849
1013
|
|
|
1014
|
+
def send(self, value):
|
|
1015
|
+
"""Set one or multiple switch channels.
|
|
1016
|
+
|
|
1017
|
+
Parameters:
|
|
1018
|
+
- value (bool) to set all channels, or
|
|
1019
|
+
- value (list[bool]) with a boolean per channel.
|
|
1020
|
+
"""
|
|
1021
|
+
return super().send(value)
|
|
1022
|
+
|
|
850
1023
|
|
|
851
1024
|
class Switch(MultiSwitchBase, TimerMixin, OnOffPokerMixin):
|
|
852
1025
|
name = _("Switch")
|
|
853
|
-
base_type =
|
|
1026
|
+
base_type = SwitchType
|
|
854
1027
|
app_widget = SingleSwitchWidget
|
|
855
1028
|
config_form = SwitchForm
|
|
856
1029
|
admin_widget_template = 'admin/controller_widgets/switch.html'
|
|
857
1030
|
default_value = False
|
|
858
1031
|
|
|
1032
|
+
def send(self, value):
|
|
1033
|
+
"""Send value to device.
|
|
1034
|
+
If non boolean value is provided it is translated to a boolean one.
|
|
1035
|
+
|
|
1036
|
+
Parameters:
|
|
1037
|
+
- value (bool): True = ON, False = OFF.
|
|
1038
|
+
"""
|
|
1039
|
+
return super().send(value)
|
|
1040
|
+
|
|
859
1041
|
def turn_on(self):
|
|
1042
|
+
"""Turn the switch on (send True)."""
|
|
860
1043
|
if self.component.meta.get('pulse'):
|
|
861
1044
|
self.component.meta.pop('pulse')
|
|
862
1045
|
self.component.save()
|
|
863
1046
|
self.send(True)
|
|
864
1047
|
|
|
865
1048
|
def turn_off(self):
|
|
1049
|
+
"""Turn the switch off (send False)."""
|
|
866
1050
|
if self.component.meta.get('pulse'):
|
|
867
1051
|
self.component.meta.pop('pulse')
|
|
868
1052
|
self.component.save()
|
|
869
1053
|
self.send(False)
|
|
870
1054
|
|
|
871
1055
|
def toggle(self):
|
|
1056
|
+
"""Toggle the switch state based on current value."""
|
|
872
1057
|
if self.component.meta.get('pulse'):
|
|
873
1058
|
self.component.meta.pop('pulse')
|
|
874
1059
|
self.component.save()
|
|
875
1060
|
self.send(not self.component.value)
|
|
876
1061
|
|
|
877
1062
|
def click(self):
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1063
|
+
"""Simulate a short press: turn on momentarily then off.
|
|
1064
|
+
|
|
1065
|
+
This sends an on command and schedules an automatic off after ~1s.
|
|
1066
|
+
"""
|
|
881
1067
|
if self.component.meta.get('pulse'):
|
|
882
1068
|
self.component.meta.pop('pulse')
|
|
883
1069
|
self.component.save()
|
|
@@ -888,6 +1074,12 @@ class Switch(MultiSwitchBase, TimerMixin, OnOffPokerMixin):
|
|
|
888
1074
|
).apply_async(countdown=1)
|
|
889
1075
|
|
|
890
1076
|
def pulse(self, frame_length_s, on_percentage):
|
|
1077
|
+
"""Generate a PWM-like pulse train (only if gateway supports it).
|
|
1078
|
+
|
|
1079
|
+
Parameters:
|
|
1080
|
+
- frame_length_s (float): Duration of a full on+off frame in seconds.
|
|
1081
|
+
- on_percentage (float|int): Duty cycle percentage (0–100) for on time.
|
|
1082
|
+
"""
|
|
891
1083
|
self.component.meta['pulse'] = {
|
|
892
1084
|
'frame': frame_length_s, 'duty': on_percentage / 100
|
|
893
1085
|
}
|
|
@@ -906,7 +1098,7 @@ class Switch(MultiSwitchBase, TimerMixin, OnOffPokerMixin):
|
|
|
906
1098
|
|
|
907
1099
|
class DoubleSwitch(MultiSwitchBase):
|
|
908
1100
|
name = _("Double Switch")
|
|
909
|
-
base_type =
|
|
1101
|
+
base_type = DoubleSwitchType
|
|
910
1102
|
app_widget = DoubleSwitchWidget
|
|
911
1103
|
config_form = DoubleSwitchConfigForm
|
|
912
1104
|
default_value = [False, False]
|
|
@@ -919,7 +1111,7 @@ class DoubleSwitch(MultiSwitchBase):
|
|
|
919
1111
|
|
|
920
1112
|
class TripleSwitch(MultiSwitchBase):
|
|
921
1113
|
name = _("Triple Switch")
|
|
922
|
-
base_type =
|
|
1114
|
+
base_type = TripleSwitchType
|
|
923
1115
|
app_widget = TripleSwitchWidget
|
|
924
1116
|
config_form = TrippleSwitchConfigForm
|
|
925
1117
|
default_value = [False, False, False]
|
|
@@ -932,7 +1124,7 @@ class TripleSwitch(MultiSwitchBase):
|
|
|
932
1124
|
|
|
933
1125
|
class QuadrupleSwitch(MultiSwitchBase):
|
|
934
1126
|
name = _("Quadruple Switch")
|
|
935
|
-
base_type =
|
|
1127
|
+
base_type = QuadrupleSwitchType
|
|
936
1128
|
app_widget = QuadrupleSwitchWidget
|
|
937
1129
|
config_form = QuadrupleSwitchConfigForm
|
|
938
1130
|
default_value = [False, False, False, False]
|
|
@@ -945,7 +1137,7 @@ class QuadrupleSwitch(MultiSwitchBase):
|
|
|
945
1137
|
|
|
946
1138
|
class QuintupleSwitch(MultiSwitchBase):
|
|
947
1139
|
name = _("Quintuple Switch")
|
|
948
|
-
base_type =
|
|
1140
|
+
base_type = QuintupleSwitchType
|
|
949
1141
|
app_widget = QuintupleSwitchWidget
|
|
950
1142
|
config_form = QuintupleSwitchConfigForm
|
|
951
1143
|
default_value = [False, False, False, False, False]
|
|
@@ -958,7 +1150,7 @@ class QuintupleSwitch(MultiSwitchBase):
|
|
|
958
1150
|
|
|
959
1151
|
class Lock(Switch):
|
|
960
1152
|
name = _("Lock")
|
|
961
|
-
base_type =
|
|
1153
|
+
base_type = LockType
|
|
962
1154
|
app_widget = LockWidget
|
|
963
1155
|
admin_widget_template = 'admin/controller_widgets/lock.html'
|
|
964
1156
|
default_value = 'unlocked'
|
|
@@ -970,11 +1162,21 @@ class Lock(Switch):
|
|
|
970
1162
|
FAULT = 4
|
|
971
1163
|
|
|
972
1164
|
def lock(self):
|
|
1165
|
+
"""Lock the device (equivalent to `turn_on()`)."""
|
|
973
1166
|
self.turn_on()
|
|
974
1167
|
|
|
975
1168
|
def unlock(self):
|
|
1169
|
+
"""Unlock the device (equivalent to `turn_off()`)."""
|
|
976
1170
|
self.turn_off()
|
|
977
1171
|
|
|
1172
|
+
def send(self, value):
|
|
1173
|
+
"""Lock/unlock.
|
|
1174
|
+
|
|
1175
|
+
Parameters:
|
|
1176
|
+
- value (bool): True to lock; False to unlock.
|
|
1177
|
+
"""
|
|
1178
|
+
return super().send(value)
|
|
1179
|
+
|
|
978
1180
|
def _receive_from_device(
|
|
979
1181
|
self, value, *args, **kwargs
|
|
980
1182
|
):
|
|
@@ -1024,7 +1226,7 @@ class Lock(Switch):
|
|
|
1024
1226
|
|
|
1025
1227
|
class Blinds(ControllerBase, TimerMixin):
|
|
1026
1228
|
name = _("Blind")
|
|
1027
|
-
base_type =
|
|
1229
|
+
base_type = BlindsType
|
|
1028
1230
|
admin_widget_template = 'admin/controller_widgets/blinds.html'
|
|
1029
1231
|
default_config = {}
|
|
1030
1232
|
|
|
@@ -1107,6 +1309,10 @@ class Blinds(ControllerBase, TimerMixin):
|
|
|
1107
1309
|
return value
|
|
1108
1310
|
|
|
1109
1311
|
def open(self):
|
|
1312
|
+
"""Open blinds fully.
|
|
1313
|
+
|
|
1314
|
+
Sends {'target': 0} and preserves current angle if present.
|
|
1315
|
+
"""
|
|
1110
1316
|
send_val = {'target': 0}
|
|
1111
1317
|
angle = self.component.value.get('angle')
|
|
1112
1318
|
if angle is not None and 0 <= angle <= 180:
|
|
@@ -1114,6 +1320,10 @@ class Blinds(ControllerBase, TimerMixin):
|
|
|
1114
1320
|
self.send(send_val)
|
|
1115
1321
|
|
|
1116
1322
|
def close(self):
|
|
1323
|
+
"""Close blinds fully.
|
|
1324
|
+
|
|
1325
|
+
Sends {'target': open_duration_ms} and preserves current angle.
|
|
1326
|
+
"""
|
|
1117
1327
|
send_val = {'target': self.component.config['open_duration'] * 1000}
|
|
1118
1328
|
angle = self.component.value.get('angle')
|
|
1119
1329
|
if angle is not None and 0 <= angle <= 180:
|
|
@@ -1121,16 +1331,29 @@ class Blinds(ControllerBase, TimerMixin):
|
|
|
1121
1331
|
self.send(send_val)
|
|
1122
1332
|
|
|
1123
1333
|
def stop(self):
|
|
1334
|
+
"""Stop blinds movement immediately.
|
|
1335
|
+
|
|
1336
|
+
Sends {'target': -1} and preserves current angle.
|
|
1337
|
+
"""
|
|
1124
1338
|
send_val = {'target': -1}
|
|
1125
1339
|
angle = self.component.value.get('angle')
|
|
1126
1340
|
if angle is not None and 0 <= angle <= 180:
|
|
1127
1341
|
send_val['angle'] = angle
|
|
1128
1342
|
self.send(send_val)
|
|
1129
1343
|
|
|
1344
|
+
def send(self, value):
|
|
1345
|
+
"""Control blinds position/angle.
|
|
1346
|
+
|
|
1347
|
+
Parameters:
|
|
1348
|
+
- value (dict): {'target': milliseconds or -1 for stop,
|
|
1349
|
+
'angle': optional 0-180}
|
|
1350
|
+
"""
|
|
1351
|
+
return super().send(value)
|
|
1352
|
+
|
|
1130
1353
|
|
|
1131
1354
|
class Gate(ControllerBase, TimerMixin):
|
|
1132
1355
|
name = _("Gate")
|
|
1133
|
-
base_type =
|
|
1356
|
+
base_type = GateType
|
|
1134
1357
|
app_widget = GateWidget
|
|
1135
1358
|
admin_widget_template = 'admin/controller_widgets/gate.html'
|
|
1136
1359
|
default_config = {
|
|
@@ -1168,11 +1391,21 @@ class Gate(ControllerBase, TimerMixin):
|
|
|
1168
1391
|
return value
|
|
1169
1392
|
|
|
1170
1393
|
def open(self):
|
|
1394
|
+
"""Command the gate to open."""
|
|
1171
1395
|
self.send('open')
|
|
1172
1396
|
|
|
1173
1397
|
def close(self):
|
|
1398
|
+
"""Command the gate to close."""
|
|
1174
1399
|
self.send('close')
|
|
1175
1400
|
|
|
1176
1401
|
def call(self):
|
|
1402
|
+
"""Trigger the 'call' action (impulse) for gates in call-mode."""
|
|
1177
1403
|
self.send('call')
|
|
1178
1404
|
|
|
1405
|
+
def send(self, value):
|
|
1406
|
+
"""Control gate.
|
|
1407
|
+
|
|
1408
|
+
Parameters:
|
|
1409
|
+
- value (str): 'open', 'close', or 'call' (depending on config).
|
|
1410
|
+
"""
|
|
1411
|
+
return super().send(value)
|