simo 2.8.15__py3-none-any.whl → 2.10.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.

Files changed (64) hide show
  1. simo/automation/__pycache__/gateways.cpython-312.pyc +0 -0
  2. simo/automation/gateways.py +12 -10
  3. simo/core/__pycache__/admin.cpython-312.pyc +0 -0
  4. simo/core/__pycache__/auto_urls.cpython-312.pyc +0 -0
  5. simo/core/__pycache__/controllers.cpython-312.pyc +0 -0
  6. simo/core/__pycache__/models.cpython-312.pyc +0 -0
  7. simo/core/__pycache__/serializers.cpython-312.pyc +0 -0
  8. simo/core/__pycache__/tasks.cpython-312.pyc +0 -0
  9. simo/core/__pycache__/views.cpython-312.pyc +0 -0
  10. simo/core/admin.py +5 -2
  11. simo/core/auto_urls.py +4 -1
  12. simo/core/controllers.py +42 -5
  13. simo/core/models.py +32 -16
  14. simo/core/serializers.py +2 -2
  15. simo/core/tasks.py +8 -1
  16. simo/core/templates/admin/core/component_change_form.html +1 -1
  17. simo/core/templates/admin/wizard/discovery.html +3 -4
  18. simo/core/templates/admin/wizard/wizard_add.html +1 -1
  19. simo/core/views.py +26 -2
  20. simo/fleet/__pycache__/api.cpython-312.pyc +0 -0
  21. simo/fleet/__pycache__/base_types.cpython-312.pyc +0 -0
  22. simo/fleet/__pycache__/controllers.cpython-312.pyc +0 -0
  23. simo/fleet/__pycache__/forms.cpython-312.pyc +0 -0
  24. simo/fleet/__pycache__/managers.cpython-312.pyc +0 -0
  25. simo/fleet/__pycache__/models.cpython-312.pyc +0 -0
  26. simo/fleet/__pycache__/serializers.cpython-312.pyc +0 -0
  27. simo/fleet/__pycache__/socket_consumers.cpython-312.pyc +0 -0
  28. simo/fleet/api.py +26 -3
  29. simo/fleet/base_types.py +1 -0
  30. simo/fleet/controllers.py +240 -7
  31. simo/fleet/custom_dali_operations.py +275 -0
  32. simo/fleet/forms.py +132 -3
  33. simo/fleet/managers.py +3 -1
  34. simo/fleet/migrations/0045_alter_colonel_type_customdalidevice.py +29 -0
  35. simo/fleet/migrations/0046_delete_customdalidevice.py +16 -0
  36. simo/fleet/migrations/0047_customdalidevice.py +28 -0
  37. simo/fleet/migrations/0048_remove_customdalidevice_colonel_and_more.py +28 -0
  38. simo/fleet/migrations/__pycache__/0045_alter_colonel_type_customdalidevice.cpython-312.pyc +0 -0
  39. simo/fleet/migrations/__pycache__/0046_delete_customdalidevice.cpython-312.pyc +0 -0
  40. simo/fleet/migrations/__pycache__/0047_customdalidevice.cpython-312.pyc +0 -0
  41. simo/fleet/migrations/__pycache__/0048_remove_customdalidevice_colonel_and_more.cpython-312.pyc +0 -0
  42. simo/fleet/models.py +54 -9
  43. simo/fleet/serializers.py +15 -1
  44. simo/fleet/socket_consumers.py +6 -0
  45. simo/fleet/tasks.py +22 -2
  46. simo/fleet/templates/fleet/controllers_info/RoomZonePresenceSensor.md +8 -0
  47. simo/generic/__pycache__/controllers.cpython-312.pyc +0 -0
  48. simo/generic/__pycache__/forms.cpython-312.pyc +0 -0
  49. simo/generic/__pycache__/gateways.cpython-312.pyc +0 -0
  50. simo/generic/controllers.py +99 -43
  51. simo/generic/forms.py +13 -10
  52. simo/generic/gateways.py +91 -2
  53. simo/generic/migrations/0003_auto_20250409_1404.py +33 -0
  54. simo/generic/migrations/__pycache__/0003_auto_20250409_1404.cpython-312.pyc +0 -0
  55. simo/users/__pycache__/api.cpython-312.pyc +0 -0
  56. simo/users/__pycache__/dynamic_settings.cpython-312.pyc +0 -0
  57. simo/users/api.py +71 -18
  58. simo/users/dynamic_settings.py +1 -1
  59. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/METADATA +1 -1
  60. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/RECORD +64 -52
  61. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/WHEEL +0 -0
  62. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/entry_points.txt +0 -0
  63. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/licenses/LICENSE.md +0 -0
  64. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/top_level.txt +0 -0
simo/fleet/forms.py CHANGED
@@ -1,10 +1,11 @@
1
1
  import time
2
+ import datetime
2
3
  from django import forms
3
4
  from django.utils.translation import gettext_lazy as _
4
5
  from django.forms import formset_factory
5
6
  from django.urls.base import get_script_prefix
6
7
  from django.contrib.contenttypes.models import ContentType
7
- from dal import autocomplete
8
+ from django.utils import timezone
8
9
  from dal import forward
9
10
  from simo.core.models import Component
10
11
  from simo.core.forms import (
@@ -24,7 +25,7 @@ from simo.core.form_fields import (
24
25
  )
25
26
  from simo.core.form_fields import PlainLocationField
26
27
  from simo.users.models import PermissionsRole
27
- from .models import Colonel, ColonelPin, Interface
28
+ from .models import Colonel, ColonelPin, Interface, CustomDaliDevice
28
29
  from .utils import INTERFACES_PINS_MAP, get_all_control_input_choices
29
30
 
30
31
 
@@ -1817,4 +1818,132 @@ class DALILightSensorConfigForm(DALIDeviceConfigForm, BaseComponentForm):
1817
1818
 
1818
1819
 
1819
1820
  class DALIButtonConfigForm(DALIDeviceConfigForm, BaseComponentForm):
1820
- pass
1821
+ pass
1822
+
1823
+
1824
+ class CustomDaliDeviceForm(BaseComponentForm):
1825
+ device = forms.ChoiceField()
1826
+
1827
+ def __init__(self, *args, **kwargs):
1828
+ super().__init__(*args, **kwargs)
1829
+ choices = []
1830
+ instance = get_current_instance()
1831
+ for colonel in Colonel.objects.filter(
1832
+ type='room-sensor', instance=instance
1833
+ ):
1834
+ if not colonel.is_connected:
1835
+ continue
1836
+ choices.append((f"wifi-{colonel.id}", colonel.name))
1837
+ for device in CustomDaliDevice.objects.filter(
1838
+ instance=instance,
1839
+ last_seen__gt=timezone.now() - datetime.timedelta(minutes=10)
1840
+ ):
1841
+ choices.append((f"dali-{device.id}", device.name))
1842
+ self.fields['device'].choices = choices
1843
+
1844
+ def get_device(self, field_val):
1845
+ if field_val.startswith('wifi'):
1846
+ return Colonel.objects.filter(
1847
+ id=self.cleaned_data['device'][5:]
1848
+ ).first()
1849
+ else:
1850
+ return CustomDaliDevice.objects.filter(
1851
+ id=self.cleaned_data['device'][5:]
1852
+ )
1853
+
1854
+
1855
+ class RoomSensorDeviceConfigForm(CustomDaliDeviceForm):
1856
+
1857
+ def save(self, commit=True):
1858
+ from simo.core.models import Icon
1859
+ colonel = None
1860
+ dali_device = None
1861
+ device = self.get_device(self.cleaned_data['device'])
1862
+ if not device:
1863
+ return
1864
+ if isinstance(device, CustomDaliDevice):
1865
+ dali_device = device
1866
+ else:
1867
+ colonel = device
1868
+
1869
+ from .controllers import (
1870
+ AirQualitySensor, TempHumSensor, AmbientLightSensor,
1871
+ RoomPresenceSensor
1872
+ )
1873
+
1874
+ org_name = self.cleaned_data['name']
1875
+ org_icon = self.cleaned_data['icon']
1876
+ for CtrlClass, icon, suffix in (
1877
+ (AirQualitySensor, 'leaf', 'air quality'),
1878
+ (TempHumSensor, 'temperature-half', 'temperature'),
1879
+ (AmbientLightSensor, 'brightness-low', 'brightness'),
1880
+ (RoomPresenceSensor, 'person', 'presence')
1881
+ ):
1882
+ default_icon = Icon.objects.filter(slug=icon).first()
1883
+ if default_icon:
1884
+ self.cleaned_data['icon'] = default_icon.slug
1885
+ else:
1886
+ self.cleaned_data['icon'] = org_icon
1887
+ self.cleaned_data['name'] = f"{org_name} {suffix}"
1888
+
1889
+ if colonel:
1890
+ comp = Component.objects.filter(
1891
+ config__colonel=colonel.id,
1892
+ controller_uid=CtrlClass.uid
1893
+ ).first()
1894
+ else:
1895
+ comp = Component.objects.filter(
1896
+ config__dali_device=dali_device.id,
1897
+ controller_uid=CtrlClass.uid
1898
+ ).first()
1899
+
1900
+ form = CtrlClass.config_form(
1901
+ controller_uid=CtrlClass.uid, instance=comp,
1902
+ data=self.cleaned_data
1903
+ )
1904
+ if form.is_valid():
1905
+ comp = form.save()
1906
+ if colonel:
1907
+ comp.config['colonel'] = colonel.id
1908
+ else:
1909
+ comp.config['dali_device'] = dali_device.id
1910
+ comp.save()
1911
+ else:
1912
+ raise Exception(form.errors)
1913
+
1914
+ if colonel:
1915
+ GatewayObjectCommand(
1916
+ comp.gateway, colonel, id=comp.id,
1917
+ command='call', method='update_config', args=[
1918
+ comp.controller._get_colonel_config()
1919
+ ]
1920
+ ).publish()
1921
+
1922
+ return comp
1923
+
1924
+
1925
+ class RoomZonePresenceConfigForm(CustomDaliDeviceForm):
1926
+
1927
+ def clean_device(self):
1928
+ value = self.cleaned_data['device']
1929
+ if value.startswith('wifi'):
1930
+ return value
1931
+ from .controllers import RoomZonePresenceSensor
1932
+ dali_device = CustomDaliDevice.objects.filter(
1933
+ id=value[5:]
1934
+ ).first()
1935
+ free_slots = {0, 1, 2, 3, 4, 5, 6, 7}
1936
+ for comp in Component.objects.filter(
1937
+ controller_uid=RoomZonePresenceSensor.uid,
1938
+ config__dali_device=dali_device.id
1939
+ ):
1940
+ try:
1941
+ free_slots.remove(int(comp.config['slot']))
1942
+ except:
1943
+ continue
1944
+ if not free_slots:
1945
+ raise forms.ValidationError(
1946
+ "This device already has 8 zones defined. "
1947
+ "Please first delete some to add a new one."
1948
+ )
1949
+ return value
simo/fleet/managers.py CHANGED
@@ -8,7 +8,9 @@ class ColonelsManager(models.Manager):
8
8
  qs = super().get_queryset()
9
9
  instance = get_current_instance()
10
10
  if instance:
11
- qs = qs.filter(instance__is_active=True)
11
+ qs = qs.filter(
12
+ instance__is_active=True
13
+ )
12
14
  return qs
13
15
 
14
16
 
@@ -0,0 +1,29 @@
1
+ # Generated by Django 4.2.10 on 2025-04-03 14:35
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('core', '0049_alter_gateway_type'),
11
+ ('fleet', '0044_auto_20241210_0707'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AlterField(
16
+ model_name='colonel',
17
+ name='type',
18
+ field=models.CharField(choices=[('4-relays', '4 Relay'), ('ample-wall', 'Ample Wall'), ('game-changer', 'Game Changer'), ('game-changer-mini', 'Game Changer Mini'), ('room-sensor', 'Room Sensor')], default='ample-wall', max_length=20),
19
+ ),
20
+ migrations.CreateModel(
21
+ name='CustomDaliDevice',
22
+ fields=[
23
+ ('random_address', models.PositiveIntegerField(primary_key=True, serialize=False)),
24
+ ('name', models.CharField(help_text='User given name on initial pairing', max_length=200)),
25
+ ('colonel', models.ForeignKey(blank=True, editable=False, help_text='Colonel on which it has already appeared.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='fleet.colonel')),
26
+ ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.instance')),
27
+ ],
28
+ ),
29
+ ]
@@ -0,0 +1,16 @@
1
+ # Generated by Django 4.2.10 on 2025-04-04 05:28
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('fleet', '0045_alter_colonel_type_customdalidevice'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.DeleteModel(
14
+ name='CustomDaliDevice',
15
+ ),
16
+ ]
@@ -0,0 +1,28 @@
1
+ # Generated by Django 4.2.10 on 2025-04-04 05:32
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('core', '0049_alter_gateway_type'),
11
+ ('fleet', '0046_delete_customdalidevice'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='CustomDaliDevice',
17
+ fields=[
18
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
+ ('random_address', models.PositiveIntegerField(db_index=True, editable=False)),
20
+ ('name', models.CharField(help_text='User given name on initial pairing', max_length=200)),
21
+ ('colonel', models.ForeignKey(blank=True, editable=False, help_text='Colonel on which it has already appeared.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='fleet.colonel')),
22
+ ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.instance')),
23
+ ],
24
+ options={
25
+ 'unique_together': {('instance', 'random_address')},
26
+ },
27
+ ),
28
+ ]
@@ -0,0 +1,28 @@
1
+ # Generated by Django 4.2.10 on 2025-04-07 07:27
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('fleet', '0047_customdalidevice'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.RemoveField(
15
+ model_name='customdalidevice',
16
+ name='colonel',
17
+ ),
18
+ migrations.AddField(
19
+ model_name='customdalidevice',
20
+ name='interface',
21
+ field=models.ForeignKey(blank=True, editable=False, help_text='Colonel interface on which it operates.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='fleet.interface'),
22
+ ),
23
+ migrations.AddField(
24
+ model_name='customdalidevice',
25
+ name='last_seen',
26
+ field=models.DateTimeField(editable=False, null=True),
27
+ ),
28
+ ]
simo/fleet/models.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import requests
2
2
  import time
3
+ import random
3
4
  from actstream import action
4
5
  from django.core.exceptions import ValidationError
5
6
  from django.db import transaction
@@ -61,6 +62,7 @@ class Colonel(DirtyFieldsMixin, models.Model):
61
62
  ('ample-wall', "Ample Wall"),
62
63
  ('game-changer', "Game Changer"),
63
64
  ('game-changer-mini', "Game Changer Mini"),
65
+ ('room-sensor', "Room Sensor")
64
66
  )
65
67
  )
66
68
  firmware_version = models.CharField(
@@ -258,7 +260,7 @@ class ColonelPin(models.Model):
258
260
  @receiver(post_save, sender=Colonel)
259
261
  def after_colonel_save(sender, instance, created, *args, **kwargs):
260
262
  if created:
261
- for no, data in GPIO_PINS.get(instance.type).items():
263
+ for no, data in GPIO_PINS.get(instance.type, {}).items():
262
264
  ColonelPin.objects.get_or_create(
263
265
  colonel=instance, no=no,
264
266
  defaults={
@@ -306,11 +308,13 @@ def post_component_save(sender, instance, created, *args, **kwargs):
306
308
  colonel.components.add(instance)
307
309
  from .controllers import (
308
310
  TTLock, DALILamp, DALIGearGroup, DALIRelay, DALIOccupancySensor,
309
- DALILightSensor, DALIButton
311
+ DALILightSensor, DALIButton,
312
+ AirQualitySensor, TempHumSensor, AmbientLightSensor, RoomPresenceSensor
310
313
  )
311
314
  if instance.controller and instance.controller_cls in (
312
315
  TTLock, DALILamp, DALIGearGroup, DALIRelay, DALIOccupancySensor,
313
- DALILightSensor, DALIButton
316
+ DALILightSensor, DALIButton,
317
+ AirQualitySensor, TempHumSensor, AmbientLightSensor, RoomPresenceSensor
314
318
  ):
315
319
  return
316
320
  colonel.rebuild_occupied_pins()
@@ -466,16 +470,13 @@ def post_interface_save(sender, instance, created, *args, **kwargs):
466
470
  address=addr,
467
471
  )
468
472
  elif instance.type == 'dali':
469
- InterfaceAddress.objects.filter(
470
- interface=instance
471
- ).exclude(address_type__startswith='dali').delete()
472
473
  for addr in range(64):
473
- InterfaceAddress.objects.create(
474
+ InterfaceAddress.objects.get_or_create(
474
475
  interface=instance, address_type='dali-gear',
475
476
  address=addr,
476
477
  )
477
478
  for addr in range(16):
478
- InterfaceAddress.objects.create(
479
+ InterfaceAddress.objects.get_or_create(
479
480
  interface=instance, address_type='dali-group',
480
481
  address=addr,
481
482
  )
@@ -492,4 +493,48 @@ def post_interface_delete(sender, instance, *args, **kwargs):
492
493
  occupied_by_content_type=ct,
493
494
  occupied_by_id=instance.id
494
495
  ):
495
- pin.occupied_by_content_type = None
496
+ pin.occupied_by_content_type = None
497
+
498
+
499
+ class CustomDaliDevice(models.Model):
500
+ '''
501
+ Our own custom dali line device,
502
+ not compatible with anything else of DALI!
503
+ '''
504
+ instance = models.ForeignKey(Instance, on_delete=models.CASCADE)
505
+ random_address = models.PositiveIntegerField(db_index=True, editable=False)
506
+ name = models.CharField(
507
+ max_length=200, help_text="User given name on initial pairing"
508
+ )
509
+ interface = models.ForeignKey(
510
+ Interface, null=True, blank=True, editable=False,
511
+ on_delete=models.SET_NULL, related_name='custom_devices',
512
+ help_text="Colonel interface on which it operates."
513
+ )
514
+ last_seen = models.DateTimeField(null=True, editable=False)
515
+
516
+ class Meta:
517
+ unique_together = 'instance', 'random_address'
518
+
519
+ def save(self, *args, **kwargs):
520
+ if not self.random_address:
521
+ while True:
522
+ self.random_address = random.randint(0, 255)
523
+ if CustomDaliDevice.objects.filter(
524
+ random_address=self.random_address,
525
+ instance=self.instance
526
+ ).first():
527
+ continue
528
+ break
529
+ return super().save(*args, **kwargs)
530
+
531
+ def transmit(self, frame):
532
+ from .gateways import FleetGatewayHandler
533
+ frame[0:7] = self.random_address
534
+ gateway = Gateway.objects.filter(type=FleetGatewayHandler.uid).first()
535
+ GatewayObjectCommand(
536
+ gateway, self.interface.colonel,
537
+ command='da-tx', interface=self.interface.no,
538
+ msg=frame.pack.hex()
539
+ ).publish()
540
+
simo/fleet/serializers.py CHANGED
@@ -1,6 +1,8 @@
1
1
  from rest_framework import serializers
2
2
  from simo.core.serializers import TimestampField
3
- from .models import InstanceOptions, Colonel, ColonelPin, Interface
3
+ from .models import (
4
+ InstanceOptions, Colonel, ColonelPin, Interface, CustomDaliDevice
5
+ )
4
6
 
5
7
 
6
8
  class InstanceOptionsSerializer(serializers.ModelSerializer):
@@ -84,3 +86,15 @@ class ColonelSerializer(serializers.ModelSerializer):
84
86
  instance = super().update(instance, validated_data)
85
87
  instance.update_config()
86
88
  return instance
89
+
90
+
91
+ class CustomDaliDeviceSerializer(serializers.ModelSerializer):
92
+
93
+ class Meta:
94
+ model = CustomDaliDevice
95
+ fields = 'id', 'random_address', 'name'
96
+ read_only_fields = 'random_address',
97
+
98
+ def create(self, validated_data):
99
+ validated_data['instance'] = self.context['instance']
100
+ return super().create(validated_data)
@@ -450,6 +450,12 @@ class FleetConsumer(AsyncWebsocketConsumer):
450
450
  process_discovery_result, thread_sensitive=True
451
451
  )()
452
452
 
453
+ elif 'dali-raw' in data:
454
+ from .custom_dali_operations import process_frame
455
+ await sync_to_async(process_frame, thread_sensitive=True)(
456
+ self.colonel.id, data['dali-raw'], data['data']
457
+ )
458
+
453
459
 
454
460
  elif bytes_data:
455
461
  if not self.colonel_logger:
simo/fleet/tasks.py CHANGED
@@ -20,18 +20,38 @@ def check_colonels_connected():
20
20
  @celery_app.task
21
21
  def check_colonel_components_alive():
22
22
  from simo.core.models import Component
23
- from .models import Colonel
23
+ from .gateways import FleetGatewayHandler
24
+ from .models import Colonel, CustomDaliDevice
24
25
  drop_current_instance()
25
26
  for lost_colonel in Colonel.objects.filter(
26
27
  last_seen__lt=timezone.now() - datetime.timedelta(seconds=60)
27
28
  ).prefetch_related(Prefetch(
28
29
  'components', queryset=Component.objects.filter(alive=True),
29
30
  to_attr='alive_components'
30
- )):
31
+ ), 'interfaces'):
31
32
  for comp in lost_colonel.alive_components:
32
33
  print(f"{comp} is no longer alive!")
33
34
  comp.alive = False
34
35
  comp.save()
36
+ for interface in lost_colonel.interfaces.all():
37
+ if interface.type == 'dali':
38
+ for device in interface.custom_devices.all():
39
+ for comp in Component.objects.filter(
40
+ gateway__type=FleetGatewayHandler.uid,
41
+ config__dali_device=device.id, alive=True
42
+ ):
43
+ comp.alive = False
44
+ comp.save()
45
+
46
+ for device in CustomDaliDevice.objects.filter(
47
+ last_seen__gt=timezone.now() - datetime.timedelta(seconds=60)
48
+ ):
49
+ for comp in Component.objects.filter(
50
+ gateway__type=FleetGatewayHandler.uid,
51
+ config__dali_device=device.id, alive=True
52
+ ):
53
+ comp.alive = False
54
+ comp.save()
35
55
 
36
56
 
37
57
  @celery_app.on_after_finalize.connect
@@ -0,0 +1,8 @@
1
+ Detects human presence in particular zone of a room.
2
+
3
+ {% if component %}
4
+ {% else %}
5
+ **IMPORTANT!** Make sure the room is empty!
6
+
7
+ Take position in desired area where presence needs to be detected, before continuing.
8
+ {% endif %}
@@ -69,11 +69,16 @@ class Thermostat(ControllerBase):
69
69
 
70
70
  @property
71
71
  def default_config(self):
72
- min = 3
73
- max = 100
72
+ instance = get_current_instance()
73
+ min = 4
74
+ max = 36
75
+ if instance and instance.units_of_measure == 'imperial':
76
+ min = 40
77
+ max = 95
74
78
  return {
75
79
  'temperature_sensor': 0, 'heater': 0, 'cooler': 0,
76
- 'reaction_difference': 0, 'min': min, 'max': max,
80
+ 'engagement': 'dynamic','reaction_difference': 2,
81
+ 'min': min, 'max': max,
77
82
  'has_real_feel': False,
78
83
  'user_config': config_to_dict(self._get_default_user_config())
79
84
  }
@@ -161,12 +166,12 @@ class Thermostat(ControllerBase):
161
166
  temperature_sensor = Component.objects.filter(
162
167
  pk=self.component.config.get('temperature_sensor')
163
168
  ).first()
164
- heater = Component.objects.filter(
165
- pk=self.component.config.get('heater')
166
- ).first()
167
- cooler = Component.objects.filter(
168
- pk=self.component.config.get('cooler')
169
- ).first()
169
+ heaters = Component.objects.filter(
170
+ pk__in=self.component.config.get('heater')
171
+ )
172
+ coolers = Component.objects.filter(
173
+ pk__in=self.component.config.get('cooler')
174
+ )
170
175
 
171
176
  if not temperature_sensor or not temperature_sensor.alive:
172
177
  self.component.error_msg = "No temperature sensor"
@@ -187,50 +192,100 @@ class Thermostat(ControllerBase):
187
192
  target_temp = self.get_current_target_temperature()
188
193
  mode = self.component.config['user_config'].get('mode', 'auto')
189
194
 
195
+ low = target_temp - self.component.config['reaction_difference']
196
+ high = target_temp + self.component.config['reaction_difference']
197
+
198
+ heating = False
199
+ cooling = False
200
+
201
+ if self.component.config.get('engagement', 'static'):
202
+ if heaters:
203
+ for heater in heaters:
204
+ if current_temp < low:
205
+ if heater.base_type == 'dimmer':
206
+ heater.max_out()
207
+ else:
208
+ heater.turn_on()
209
+ heating = True
210
+ elif current_temp > high:
211
+ heater.turn_off()
212
+ heating = False
213
+ else:
214
+ if heater.value:
215
+ heating = True
216
+ break
217
+
218
+ if coolers:
219
+ for cooler in coolers:
220
+ if heating: # Do not cool if heating!
221
+ cooler.turn_off()
222
+ else:
223
+ if current_temp > high:
224
+ if heater.base_type == 'dimmer':
225
+ cooler.max_out()
226
+ else:
227
+ cooler.turn_on()
228
+ cooling = True
229
+ elif current_temp < low:
230
+ if cooler.value:
231
+ cooler.turn_off()
232
+ cooling = False
233
+ else:
234
+ if cooler.value:
235
+ cooling = True
236
+ break
237
+
238
+ else:
239
+ window = high - low
240
+ if heaters:
241
+ reach = high - current_temp
242
+ reaction_force = self._get_reaction_force(window, reach)
243
+ if reaction_force:
244
+ heating = True
245
+ self._engage_devices(heaters, reaction_force)
246
+ if coolers:
247
+ if heating: # Do not cool if heating!
248
+ reaction_force = 0
249
+ else:
250
+ reach = current_temp - low
251
+ reaction_force = self._get_reaction_force(window, reach)
252
+ self._engage_devices(coolers, reaction_force)
253
+
190
254
  self.component.set({
191
255
  'mode': mode,
192
256
  'current_temp': current_temp,
193
257
  'target_temp': target_temp,
194
- 'heating': False, 'cooling': False
258
+ 'heating': heating, 'cooling': cooling
195
259
  }, actor=get_system_user())
196
260
 
197
- low = target_temp - self.component.config['reaction_difference'] / 2
198
- high = target_temp + self.component.config['reaction_difference'] / 2
199
-
200
- if mode in ('auto', 'heater'):
201
- if (not heater or not heater.alive) and mode == 'heater':
202
- self.component.error_msg = "No heater"
203
- self.component.alive = False
204
- self.component.save()
205
- return
206
- if current_temp < low:
207
- if not heater.value:
208
- heater.turn_on()
209
- self.component.value['heating'] = True
210
- elif current_temp > high:
211
- if heater.value:
212
- heater.turn_off()
213
- self.component.value['heating'] = False
214
- if mode in ('auto', 'cooler') and cooler:
215
- if not cooler or not cooler.alive:
216
- if mode == 'cooler' or (not heater or not heater.alive):
217
- print(f"No cooler or heater on {self.component}!")
218
- self.component.alive = False
219
- self.component.save()
220
- return
221
- if current_temp > high:
222
- if not cooler.value:
223
- cooler.turn_on()
224
- self.component.value['cooling'] = True
225
- elif current_temp < low:
226
- if cooler.value:
227
- cooler.turn_off()
228
- self.component.value['cooling'] = False
229
-
230
261
  self.component.error_msg = None
231
262
  self.component.alive = True
232
263
  self.component.save()
233
264
 
265
+
266
+ def _get_reaction_force(self, window, reach):
267
+ if reach > window:
268
+ reaction_force = 100
269
+ elif reach <= 0:
270
+ reaction_force = 0
271
+ else:
272
+ reaction_force = reach / window * 100
273
+ return reaction_force
274
+
275
+
276
+ def _engage_devices(self, devices, reaction_force):
277
+ for device in devices:
278
+ if device.base_type == 'dimmer':
279
+ device.output_percent(reaction_force)
280
+ elif device.base_type == 'switch':
281
+ if reaction_force == 100:
282
+ device.turn_on()
283
+ elif reaction_force == 0:
284
+ device.turn_off()
285
+ else:
286
+ device.pulse(30, reaction_force)
287
+
288
+
234
289
  def update_user_conf(self, new_conf):
235
290
  self.component.refresh_from_db()
236
291
  self.component.config['user_config'] = validate_new_conf(
@@ -241,6 +296,7 @@ class Thermostat(ControllerBase):
241
296
  self.component.save()
242
297
  self.evaluate()
243
298
 
299
+
244
300
  def hold(self, temperature=None):
245
301
  if temperature != None:
246
302
  self.component.config['user_config']['hard'] = {