simo 2.8.14__py3-none-any.whl → 2.9.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 (56) hide show
  1. simo/__pycache__/settings.cpython-312.pyc +0 -0
  2. simo/automation/__pycache__/helpers.cpython-312.pyc +0 -0
  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__/forms.cpython-312.pyc +0 -0
  7. simo/core/__pycache__/models.cpython-312.pyc +0 -0
  8. simo/core/__pycache__/serializers.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 +1 -3
  13. simo/core/forms.py +1 -1
  14. simo/core/models.py +32 -16
  15. simo/core/serializers.py +2 -5
  16. simo/core/templates/admin/core/component_change_form.html +1 -1
  17. simo/core/templates/admin/msg_page.html +1 -0
  18. simo/core/templates/admin/wizard/discovery.html +3 -4
  19. simo/core/templates/admin/wizard/wizard_add.html +1 -1
  20. simo/core/views.py +26 -2
  21. simo/fleet/__pycache__/api.cpython-312.pyc +0 -0
  22. simo/fleet/__pycache__/base_types.cpython-312.pyc +0 -0
  23. simo/fleet/__pycache__/controllers.cpython-312.pyc +0 -0
  24. simo/fleet/__pycache__/forms.cpython-312.pyc +0 -0
  25. simo/fleet/__pycache__/managers.cpython-312.pyc +0 -0
  26. simo/fleet/__pycache__/models.cpython-312.pyc +0 -0
  27. simo/fleet/__pycache__/serializers.cpython-312.pyc +0 -0
  28. simo/fleet/__pycache__/socket_consumers.cpython-312.pyc +0 -0
  29. simo/fleet/api.py +26 -3
  30. simo/fleet/base_types.py +1 -0
  31. simo/fleet/controllers.py +240 -7
  32. simo/fleet/custom_dali_operations.py +275 -0
  33. simo/fleet/forms.py +132 -3
  34. simo/fleet/managers.py +3 -1
  35. simo/fleet/migrations/0045_alter_colonel_type_customdalidevice.py +29 -0
  36. simo/fleet/migrations/0046_delete_customdalidevice.py +16 -0
  37. simo/fleet/migrations/0047_customdalidevice.py +28 -0
  38. simo/fleet/migrations/0048_remove_customdalidevice_colonel_and_more.py +28 -0
  39. simo/fleet/migrations/__pycache__/0045_alter_colonel_type_customdalidevice.cpython-312.pyc +0 -0
  40. simo/fleet/migrations/__pycache__/0046_delete_customdalidevice.cpython-312.pyc +0 -0
  41. simo/fleet/migrations/__pycache__/0047_customdalidevice.cpython-312.pyc +0 -0
  42. simo/fleet/migrations/__pycache__/0048_remove_customdalidevice_colonel_and_more.cpython-312.pyc +0 -0
  43. simo/fleet/models.py +54 -9
  44. simo/fleet/serializers.py +15 -1
  45. simo/fleet/socket_consumers.py +6 -0
  46. simo/fleet/tasks.py +22 -2
  47. simo/fleet/templates/fleet/controllers_info/RoomZonePresenceSensor.md +8 -0
  48. simo/generic/__pycache__/controllers.cpython-312.pyc +0 -0
  49. simo/generic/__pycache__/forms.cpython-312.pyc +0 -0
  50. simo/settings.py +1 -1
  51. {simo-2.8.14.dist-info → simo-2.9.1.dist-info}/METADATA +3 -2
  52. {simo-2.8.14.dist-info → simo-2.9.1.dist-info}/RECORD +56 -46
  53. {simo-2.8.14.dist-info → simo-2.9.1.dist-info}/WHEEL +1 -1
  54. {simo-2.8.14.dist-info → simo-2.9.1.dist-info}/entry_points.txt +0 -0
  55. {simo-2.8.14.dist-info → simo-2.9.1.dist-info/licenses}/LICENSE.md +0 -0
  56. {simo-2.8.14.dist-info → simo-2.9.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,275 @@
1
+ from django.utils import timezone
2
+ from simo.core.models import Component
3
+ from .models import Interface, CustomDaliDevice
4
+ from .controllers import (
5
+ TempHumSensor, AirQualitySensor, AmbientLightSensor,
6
+ RoomPresenceSensor, RoomZonePresenceSensor
7
+ )
8
+
9
+
10
+ class Frame:
11
+ """A DALI frame.
12
+
13
+ A Frame consists of one start bit, n data bits, and one stop
14
+ condition. The most significant bit is always transmitted first.
15
+
16
+ Instances of this object are mutable.
17
+ """
18
+
19
+ def __init__(self, bits, data=0):
20
+ """Initialise a Frame with the supplied number of data bits.
21
+
22
+ :parameter bits: the number of data bits in the Frame
23
+ :parameter data: initial data for the Frame as an integer or
24
+ an iterable sequence of integers
25
+ """
26
+ if not isinstance(bits, int):
27
+ raise TypeError(
28
+ "Number of bits must be an integer")
29
+ if bits < 1:
30
+ raise ValueError(
31
+ "Frames must contain at least 1 data bit")
32
+ self._bits = bits
33
+ if isinstance(data, int):
34
+ self._data = data
35
+ else:
36
+ self._data = int.from_bytes(data, 'big')
37
+ if self._data < 0:
38
+ raise ValueError("Initial data must not be negative")
39
+ if len(self.pack) > bits:
40
+ raise ValueError(
41
+ "Initial data will not fit in {} bits".format(bits))
42
+ self._error = False
43
+
44
+ @property
45
+ def error(self):
46
+ """Frame was received with a framing error."""
47
+ return self._error
48
+
49
+ def __len__(self):
50
+ return self._bits
51
+
52
+ def __eq__(self, other):
53
+ try:
54
+ return self._bits == other._bits and self._data == other._data
55
+ except Exception:
56
+ return False
57
+
58
+ def __ne__(self, other):
59
+ try:
60
+ return self._bits != other._bits or self._data != other._data
61
+ except Exception:
62
+ return True
63
+
64
+ def _readslice(self, key):
65
+ """Check that a slice is valid, return indices
66
+
67
+ The slice must have indices that are integers. The indices
68
+ must be in the range 0..(len(self)-1).
69
+ """
70
+ if not isinstance(key.start, int) or not isinstance(key.stop, int):
71
+ raise TypeError("slice indices must be integers")
72
+ if key.step not in (None, 1):
73
+ raise TypeError("slice with step not supported")
74
+ hi = max(key.start, key.stop)
75
+ lo = min(key.start, key.stop)
76
+ if hi < 0 or lo < 0:
77
+ raise IndexError("slice indices must be >= 0")
78
+ if hi >= self._bits or lo >= self._bits:
79
+ raise IndexError("slice index out of range")
80
+ return hi, lo
81
+
82
+ def __getitem__(self, key):
83
+ """Read a bit or group of bits from the frame
84
+
85
+ If the key is an integer, return that bit as True or False or
86
+ raise IndexError if the key is out of bounds.
87
+
88
+ If the key is a slice, return that slice as an integer or
89
+ raise IndexError if out of bounds. We abuse the slice
90
+ mechanism slightly such that slice(5,7) and slice(7,5) are
91
+ treated the same. Slices with a step or a negative index are
92
+ not supported.
93
+ """
94
+ if isinstance(key, slice):
95
+ hi, lo = self._readslice(key)
96
+ d = self._data >> lo
97
+ return d & ((1 << (hi + 1 - lo)) - 1)
98
+ elif isinstance(key, int):
99
+ if key < 0 or key >= self._bits:
100
+ raise IndexError("index out of range")
101
+ return (self._data & (1 << key)) != 0
102
+ raise TypeError
103
+
104
+ def __setitem__(self, key, value):
105
+ """Write a bit or a group of bits to the frame
106
+
107
+ If the key is an integer, set that bit to the truth value of
108
+ value or raise IndexError if the key is out of bounds.
109
+
110
+ If the key is a slice, value must be an integer that fits
111
+ within the slice; set that slice to value or raise IndexError
112
+ if out of bounds. We abuse the slice mechanism slightly such
113
+ that slice(5,7) and slice(7,5) are treated the same. Slices
114
+ with a step or a negative index are not supported.
115
+ """
116
+ if isinstance(key, slice):
117
+ hi, lo = self._readslice(key)
118
+ if not isinstance(value, int):
119
+ raise TypeError("value must be an integer")
120
+ if len(bin(value)) - 2 > (hi + 1 - lo):
121
+ raise ValueError("value will not fit in supplied slice")
122
+ if value < 0:
123
+ raise ValueError("value must not be negative")
124
+ template = ((1 << hi + 1 - lo) - 1) << lo
125
+ mask = ((1 << self._bits) - 1) ^ template
126
+ self._data = self._data & mask | (value << lo)
127
+ elif isinstance(key, int):
128
+ if key < 0 or key >= self._bits:
129
+ raise IndexError("index out of range")
130
+ if value:
131
+ self._data = self._data | (1 << key)
132
+ else:
133
+ self._data = self._data \
134
+ & (((1 << self._bits) - 1) ^ (1 << key))
135
+ else:
136
+ raise TypeError
137
+
138
+ def __contains__(self, item):
139
+ if item is True:
140
+ return self._data != 0
141
+ if item is False:
142
+ return self._data != (1 << self._bits) - 1
143
+ return False
144
+
145
+ def __add__(self, other):
146
+ try:
147
+ return Frame(self._bits + other._bits,
148
+ self._data << other._bits | other._data)
149
+ except Exception:
150
+ raise TypeError("Frame can only be added to another Frame")
151
+
152
+ @property
153
+ def as_integer(self):
154
+ """The contents of the frame represented as an integer."""
155
+ return self._data
156
+
157
+ @property
158
+ def as_byte_sequence(self):
159
+ """The contents of the frame represented as a sequence.
160
+
161
+ Returns a sequence of integers each in the range 0..255
162
+ representing the data in the frame, with the most-significant
163
+ bits first. If the frame is not an exact multiple of 8 bits
164
+ long, the first element in the sequence contains fewer than 8
165
+ bits.
166
+ """
167
+ return list(self.pack)
168
+
169
+ @property
170
+ def pack(self):
171
+ """The contents of the frame represented as a byte string.
172
+
173
+ If the frame is not an exact multiple of 8 bits long, the
174
+ first byte in the string will contain fewer than 8 bits.
175
+ """
176
+ return self._data.to_bytes(
177
+ (len(self) // 8) + (1 if len(self) % 8 else 0),
178
+ 'big')
179
+
180
+ def pack_len(self, l):
181
+ """The contents of the frame represented as a fixed length byte string.
182
+
183
+ The least significant bit of the frame is aligned to the end
184
+ of the byte string. The start of the byte string is padded
185
+ with zeroes.
186
+
187
+ If the frame will not fit in the byte string, raises
188
+ OverflowError.
189
+ """
190
+ return self._data.to_bytes(l, 'big')
191
+
192
+ def __str__(self):
193
+ return "{}({},{})".format(self.__class__.__name__, len(self),
194
+ self.as_byte_sequence)
195
+
196
+
197
+ def process_frame(colonel_id, interface_no, data):
198
+ interface = Interface.objects.filter(
199
+ colonel_id=colonel_id, no=interface_no
200
+ ).first()
201
+ if not interface:
202
+ return
203
+
204
+ data = bytes.fromhex(data)
205
+ frame = Frame(len(data) * 8, data)
206
+
207
+ device_address = frame[0:7]
208
+ device = CustomDaliDevice.objects.filter(
209
+ random_address=device_address, instance=interface.colonel.instance
210
+ ).first()
211
+ if not device:
212
+ return
213
+
214
+ device.interface = interface
215
+ device.last_seen = timezone.now()
216
+ device.save()
217
+
218
+ print("Frame received: ", frame.pack)
219
+
220
+ if frame[8:11] == 0:
221
+ # climate and air quality data
222
+ temp = (frame[12:21] - 512) / 10
223
+ humidity = round(frame[22:27] / 64 * 100)
224
+ comp = Component.objects.filter(
225
+ controller_uid=TempHumSensor.uid, config__dali_device=device.id
226
+ ).first()
227
+ if comp:
228
+ comp.controller._receive_from_device({'temp': temp, 'humidity': humidity})
229
+ voc = frame[28:38]
230
+ comp = Component.objects.filter(
231
+ controller_uid=AirQualitySensor.uid, config__dali_device=device.id
232
+ ).first()
233
+ if comp:
234
+ comp.controller._receive_from_device(voc)
235
+
236
+ elif frame[8:11] == 1:
237
+ comp = Component.objects.filter(
238
+ controller_uid=AmbientLightSensor.uid, config__dali_device=device.id
239
+ ).first()
240
+ if comp:
241
+ comp.controller._receive_from_device(frame[12:22] * 2)
242
+ comp = Component.objects.filter(
243
+ controller_uid=RoomPresenceSensor.uid, config__dali_device=device.id
244
+ ).first()
245
+ if comp:
246
+ comp.controller._receive_from_device(frame[23])
247
+
248
+ zone_sensors = {}
249
+ for slot in range(8):
250
+ comp = Component.objects.filter(
251
+ controller_uid=RoomZonePresenceSensor.uid,
252
+ config__dali_device=device.id, config__slot=slot
253
+ ).first()
254
+ if comp:
255
+ zone_sensors[slot] = comp
256
+
257
+ for slot in range(8):
258
+ if frame[24 + slot * 2]:
259
+ comp = zone_sensors.get(slot)
260
+ if comp:
261
+ comp.controller._receive_from_device(frame[25 + slot * 2])
262
+ else:
263
+ # component no longer exists, probably deleted by user
264
+ # need to inform device about that!
265
+ f = Frame(40, bytes(bytearray(5)))
266
+ f[8:11] = 15 # command to custom dali device
267
+ f[12:15] = 2 # action to perform: delete zone sensor
268
+ f[16:18] = slot
269
+ device.transmit(f)
270
+ elif zone_sensors.get(slot):
271
+ # not yet picked up by the device itself or
272
+ # was never successfully created
273
+ if zone_sensors[slot].alive:
274
+ zone_sensors[slot].alive = False
275
+ zone_sensors[slot].save()
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)