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.
- simo/__pycache__/settings.cpython-312.pyc +0 -0
- simo/automation/__pycache__/helpers.cpython-312.pyc +0 -0
- simo/core/__pycache__/admin.cpython-312.pyc +0 -0
- simo/core/__pycache__/auto_urls.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__/views.cpython-312.pyc +0 -0
- simo/core/admin.py +5 -2
- simo/core/auto_urls.py +4 -1
- simo/core/controllers.py +1 -3
- simo/core/forms.py +1 -1
- simo/core/models.py +32 -16
- simo/core/serializers.py +2 -5
- simo/core/templates/admin/core/component_change_form.html +1 -1
- simo/core/templates/admin/msg_page.html +1 -0
- simo/core/templates/admin/wizard/discovery.html +3 -4
- simo/core/templates/admin/wizard/wizard_add.html +1 -1
- simo/core/views.py +26 -2
- 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__/managers.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/__pycache__/socket_consumers.cpython-312.pyc +0 -0
- simo/fleet/api.py +26 -3
- simo/fleet/base_types.py +1 -0
- simo/fleet/controllers.py +240 -7
- simo/fleet/custom_dali_operations.py +275 -0
- simo/fleet/forms.py +132 -3
- simo/fleet/managers.py +3 -1
- simo/fleet/migrations/0045_alter_colonel_type_customdalidevice.py +29 -0
- simo/fleet/migrations/0046_delete_customdalidevice.py +16 -0
- simo/fleet/migrations/0047_customdalidevice.py +28 -0
- simo/fleet/migrations/0048_remove_customdalidevice_colonel_and_more.py +28 -0
- simo/fleet/migrations/__pycache__/0045_alter_colonel_type_customdalidevice.cpython-312.pyc +0 -0
- simo/fleet/migrations/__pycache__/0046_delete_customdalidevice.cpython-312.pyc +0 -0
- simo/fleet/migrations/__pycache__/0047_customdalidevice.cpython-312.pyc +0 -0
- simo/fleet/migrations/__pycache__/0048_remove_customdalidevice_colonel_and_more.cpython-312.pyc +0 -0
- simo/fleet/models.py +54 -9
- simo/fleet/serializers.py +15 -1
- simo/fleet/socket_consumers.py +6 -0
- simo/fleet/tasks.py +22 -2
- simo/fleet/templates/fleet/controllers_info/RoomZonePresenceSensor.md +8 -0
- simo/generic/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/generic/__pycache__/forms.cpython-312.pyc +0 -0
- simo/settings.py +1 -1
- {simo-2.8.14.dist-info → simo-2.9.1.dist-info}/METADATA +3 -2
- {simo-2.8.14.dist-info → simo-2.9.1.dist-info}/RECORD +56 -46
- {simo-2.8.14.dist-info → simo-2.9.1.dist-info}/WHEEL +1 -1
- {simo-2.8.14.dist-info → simo-2.9.1.dist-info}/entry_points.txt +0 -0
- {simo-2.8.14.dist-info → simo-2.9.1.dist-info/licenses}/LICENSE.md +0 -0
- {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
|
|
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
|
@@ -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
|
+
]
|
|
Binary file
|
|
Binary file
|
simo/fleet/migrations/__pycache__/0048_remove_customdalidevice_colonel_and_more.cpython-312.pyc
ADDED
|
Binary file
|
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.
|
|
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.
|
|
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
|
|
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)
|