simo 2.0.11__py3-none-any.whl → 2.0.13__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.

Binary file
Binary file
simo/core/admin.py CHANGED
@@ -336,9 +336,9 @@ class ComponentAdmin(admin.ModelAdmin):
336
336
  'alarm_category', 'arm_status'
337
337
  ):
338
338
  if field_neme in form.fields:
339
- form.fields.pop(field_neme)
339
+ form.fields.pop(field_neme, None)
340
340
  if 'slaves' not in form.declared_fields:
341
- form.fields.pop('slaves')
341
+ form.fields.pop('slaves', None)
342
342
 
343
343
  ctx['is_last'] = True
344
344
  ctx['current_step'] = 3
simo/core/api_meta.py CHANGED
@@ -3,7 +3,9 @@ from django.utils.encoding import force_str
3
3
  from rest_framework import serializers
4
4
  from rest_framework.metadata import SimpleMetadata
5
5
  from rest_framework.utils.field_mapping import ClassLookupDict
6
- from .serializers import ComponentManyToManyRelatedField
6
+ from .serializers import (
7
+ HiddenSerializerField, ComponentManyToManyRelatedField
8
+ )
7
9
 
8
10
 
9
11
  class SIMOAPIMetadata(SimpleMetadata):
@@ -32,7 +34,8 @@ class SIMOAPIMetadata(SimpleMetadata):
32
34
  serializers.Serializer: 'nested object',
33
35
  serializers.RelatedField: 'related object',
34
36
  serializers.ManyRelatedField: 'many related objects',
35
- ComponentManyToManyRelatedField: 'many related objects'
37
+ ComponentManyToManyRelatedField: 'many related objects',
38
+ HiddenSerializerField: 'hidden',
36
39
  })
37
40
 
38
41
  def get_field_info(self, field):
simo/core/forms.py CHANGED
@@ -26,6 +26,15 @@ from .utils.formsets import FormsetField
26
26
  from .utils.validators import validate_slaves
27
27
 
28
28
 
29
+ class HiddenField(forms.CharField):
30
+ '''
31
+ Hidden field used in API
32
+ '''
33
+ def __init__(self, *args, **kwargs):
34
+ super().__init__(widget=forms.HiddenInput())
35
+
36
+
37
+
29
38
  class HubConfigForm(forms.Form):
30
39
  name = forms.CharField(
31
40
  label=_("Hub Name"), required=True,
@@ -278,8 +287,8 @@ class ComponentAdminForm(forms.ModelForm):
278
287
  model = Component
279
288
  fields = '__all__'
280
289
  exclude = (
281
- 'gateway', 'controller_uid', 'base_type',
282
- 'alive', 'value_type', 'value', 'arm_status',
290
+ 'gateway', 'controller_uid', 'base_type', 'instance_methods',
291
+ 'alive', 'value_type', 'value', 'arm_status', 'slaves',
283
292
  )
284
293
  widgets = {
285
294
  'icon': autocomplete.ModelSelect2(
@@ -291,7 +300,7 @@ class ComponentAdminForm(forms.ModelForm):
291
300
  'category': autocomplete.ModelSelect2(
292
301
  url='autocomplete-category', attrs={'data-html': True}
293
302
  ),
294
- 'instance_methods': PythonCode
303
+ #'instance_methods': PythonCode
295
304
  }
296
305
 
297
306
  def __init__(self, *args, **kwargs):
@@ -322,7 +331,8 @@ class ComponentAdminForm(forms.ModelForm):
322
331
  main_fields = (
323
332
  'name', 'icon', 'zone', 'category',
324
333
  'show_in_app', 'battery_level',
325
- 'instance_methods', 'value_units',
334
+ #'instance_methods',
335
+ 'value_units',
326
336
  'alarm_category', 'arm_status',
327
337
  )
328
338
  base_fields = ['id', 'gateway', 'base_type', 'name']
@@ -338,7 +348,7 @@ class ComponentAdminForm(forms.ModelForm):
338
348
 
339
349
  base_fields.append('show_in_app')
340
350
  base_fields.append('control')
341
- base_fields.append('instance_methods')
351
+ #base_fields.append('instance_methods')
342
352
 
343
353
  fieldsets = [
344
354
  (_("Base settings"), {'fields': base_fields}),
simo/core/serializers.py CHANGED
@@ -2,16 +2,20 @@ import inspect
2
2
  import datetime
3
3
  import json
4
4
  from django import forms
5
+ from collections import OrderedDict
5
6
  from django.forms.utils import ErrorDict
6
7
  from collections.abc import Iterable
7
8
  from easy_thumbnails.files import get_thumbnailer
8
9
  from simo.core.middleware import get_current_request
9
10
  from rest_framework import serializers
10
- from simo.core.forms import FormsetField
11
+ from rest_framework.fields import SkipField
12
+ from rest_framework.relations import Hyperlink, PKOnlyObject
13
+ from simo.core.forms import HiddenField, FormsetField
11
14
  from rest_framework.relations import PrimaryKeyRelatedField, ManyRelatedField
12
15
  from .drf_braces.serializers.form_serializer import (
13
16
  FormSerializer, FormSerializerBase, reduce_attr_dict_from_instance,
14
- FORM_SERIALIZER_FIELD_MAPPING, set_form_partial_validation
17
+ FORM_SERIALIZER_FIELD_MAPPING, set_form_partial_validation,
18
+ find_matching_class_kwargs
15
19
  )
16
20
  from .forms import ComponentAdminForm
17
21
  from .models import Category, Zone, Icon, ComponentHistory
@@ -60,7 +64,6 @@ class CategorySerializer(serializers.ModelSerializer):
60
64
  return
61
65
 
62
66
 
63
-
64
67
  class ObjectSerializerMethodField(serializers.SerializerMethodField):
65
68
 
66
69
  def bind(self, field_name, parent):
@@ -83,6 +86,9 @@ class FormsetPrimaryKeyRelatedField(PrimaryKeyRelatedField):
83
86
 
84
87
  # TODO: if form field has initial value and is required, it is serialized as not required field, howerver when trying to submit it fails with a message, that field is required.
85
88
 
89
+ class HiddenSerializerField(serializers.CharField):
90
+ pass
91
+
86
92
 
87
93
  class ComponentFormsetField(FormSerializer):
88
94
 
@@ -91,6 +97,7 @@ class ComponentFormsetField(FormSerializer):
91
97
  # we set it to proper formset form on __init__
92
98
  form = forms.Form
93
99
  field_mapping = {
100
+ HiddenField: HiddenSerializerField,
94
101
  forms.ModelChoiceField: FormsetPrimaryKeyRelatedField,
95
102
  forms.TypedChoiceField: serializers.ChoiceField,
96
103
  forms.FloatField: serializers.FloatField,
@@ -145,10 +152,38 @@ class ComponentFormsetField(FormSerializer):
145
152
  kwargs['style'] = {'form_field': form_field}
146
153
  if serializer_field_class == FormsetPrimaryKeyRelatedField:
147
154
  kwargs['queryset'] = form_field.queryset
155
+
156
+ attrs = find_matching_class_kwargs(form_field, serializer_field_class)
157
+ if 'choices' in attrs:
158
+ kwargs['choices'] = attrs['choices']
159
+
148
160
  return kwargs
149
161
 
150
162
  def to_representation(self, instance):
151
- return super(FormSerializerBase, self).to_representation(instance)
163
+ """
164
+ Object instance -> Dict of primitive datatypes.
165
+ """
166
+ ret = OrderedDict()
167
+ fields = self._readable_fields
168
+
169
+ for field in fields:
170
+ try:
171
+ attribute = field.get_attribute(instance)
172
+ except SkipField:
173
+ continue
174
+ except:
175
+ ret[field.field_name] = None
176
+ continue
177
+
178
+ check_for_none = attribute.pk if isinstance(
179
+ attribute, PKOnlyObject
180
+ ) else attribute
181
+ if check_for_none is None:
182
+ ret[field.field_name] = None
183
+ else:
184
+ ret[field.field_name] = field.to_representation(attribute)
185
+
186
+ return ret
152
187
 
153
188
  def get_form(self, data=None, **kwargs):
154
189
  form = super().get_form(data=data, **kwargs)
@@ -178,7 +213,7 @@ class ComponentManyToManyRelatedField(serializers.Field):
178
213
  super().__init__(*args, **kwargs)
179
214
 
180
215
  def to_representation(self, value):
181
- return [obj.pk for obj in value]
216
+ return [obj.pk for obj in value.all()]
182
217
 
183
218
  def to_internal_value(self, data):
184
219
  if data == [] and self.allow_blank:
@@ -188,7 +223,6 @@ class ComponentManyToManyRelatedField(serializers.Field):
188
223
 
189
224
  class ComponentSerializer(FormSerializer):
190
225
  id = ObjectSerializerMethodField()
191
- controller_methods = serializers.SerializerMethodField()
192
226
  last_change = TimestampField(read_only=True)
193
227
  read_only = serializers.SerializerMethodField()
194
228
  app_widget = serializers.SerializerMethodField()
@@ -204,8 +238,8 @@ class ComponentSerializer(FormSerializer):
204
238
 
205
239
  class Meta:
206
240
  form = ComponentAdminForm
207
- exclude = ('instance_methods', )
208
241
  field_mapping = {
242
+ HiddenField: HiddenSerializerField,
209
243
  forms.TypedChoiceField: serializers.ChoiceField,
210
244
  forms.FloatField: serializers.FloatField,
211
245
  forms.SlugField: serializers.CharField,
@@ -217,7 +251,7 @@ class ComponentSerializer(FormSerializer):
217
251
  def get_fields(self):
218
252
  self.set_form_cls()
219
253
 
220
- ret = super(FormSerializerBase, self).get_fields()
254
+ ret = OrderedDict()
221
255
 
222
256
  field_mapping = reduce_attr_dict_from_instance(
223
257
  self,
@@ -234,11 +268,11 @@ class ComponentSerializer(FormSerializer):
234
268
  if field_name in getattr(self.Meta, 'exclude', []):
235
269
  continue
236
270
 
237
- # if field is already defined via declared fields
238
- # skip mapping it from forms which then honors
239
- # the custom validation defined on the DRF declared field
240
- if field_name in ret:
241
- continue
271
+ # # if field is already defined via declared fields
272
+ # # skip mapping it from forms which then honors
273
+ # # the custom validation defined on the DRF declared field
274
+ # if field_name in ret:
275
+ # continue
242
276
 
243
277
  form_field = form[field_name]
244
278
 
@@ -266,6 +300,10 @@ class ComponentSerializer(FormSerializer):
266
300
  ret[field_name].initial = form_field.initial
267
301
  ret[field_name].default = form_field.initial
268
302
 
303
+ for name, field in super(FormSerializerBase, self).get_fields().items():
304
+ if name in ret:
305
+ continue
306
+ ret[name] = field
269
307
  return ret
270
308
 
271
309
  def _get_field_kwargs(self, form_field, serializer_field_class):
@@ -279,6 +317,10 @@ class ComponentSerializer(FormSerializer):
279
317
  kwargs['formset_field'] = form_field
280
318
  kwargs['many'] = True
281
319
 
320
+ attrs = find_matching_class_kwargs(form_field, serializer_field_class)
321
+ if 'choices' in attrs:
322
+ kwargs['choices'] = form_field.choices
323
+
282
324
  return kwargs
283
325
 
284
326
  def set_form_cls(self):
Binary file
simo/fleet/controllers.py CHANGED
@@ -112,7 +112,6 @@ class DS18B20Sensor(FleeDeviceMixin, BasicSensorMixin, BaseNumericSensor):
112
112
 
113
113
 
114
114
  class BaseClimateSensor(FleeDeviceMixin, BasicSensorMixin, BaseMultiSensor):
115
- manual_add = False
116
115
  app_widget = NumericSensorWidget
117
116
 
118
117
  def __init__(self, *args, **kwargs):
@@ -195,6 +194,29 @@ class BasicOutputMixin:
195
194
  class Switch(FleeDeviceMixin, BasicOutputMixin, BaseSwitch):
196
195
  config_form = ColonelSwitchConfigForm
197
196
 
197
+ def signal(self, pulses):
198
+ '''
199
+ Expecting list of tuples where each item represents component value
200
+ followed by duration in miliseconds.
201
+ Maximum of 20 pulses is accepted, each pulse might not be longer than 3000ms
202
+ If you need anything longer than this, use on(), off() methods instead.
203
+ :param pulses: [(True, 200), (False, 600), (True, 200)]
204
+ :return: None
205
+ '''
206
+ assert len(pulses) > 0, "At least on pulse is expected"
207
+ assert len(pulses) <= 20, "No more than 20 pulses is accepted"
208
+ for i, pulse in enumerate(pulses):
209
+ assert isinstance(pulse[0], bool), f"{i+1}-th pulse is not boolean!"
210
+ assert pulse[1] <= 3000, "Pulses must not exceed 3000ms"
211
+
212
+ GatewayObjectCommand(
213
+ self.component.gateway,
214
+ Colonel(id=self.component.config['colonel']),
215
+ command='call', method='signal', args=[pulses],
216
+ id=self.component.id,
217
+ ).publish()
218
+
219
+
198
220
 
199
221
  class PWMOutput(FleeDeviceMixin, BasicOutputMixin, BaseDimmer):
200
222
  name = "Dimmer"
@@ -432,6 +454,30 @@ class TTLock(FleeDeviceMixin, Lock):
432
454
  command='call', method='get_fingerprints'
433
455
  ).publish()
434
456
 
457
+ def check_locked_status(self):
458
+ '''
459
+ Lock state is monitored by capturing adv data
460
+ periodically transmitted by the lock.
461
+ This data includes information about it's lock/unlock position
462
+ also if there are any new events in it that we are not yet aware of.
463
+
464
+ If anything new is observer, connection is made to the lock
465
+ and reported back to the system.
466
+ This helps to save batteries of a lock,
467
+ however it is not always as timed as we would want to.
468
+ Sometimes it can take even up to 20s for these updates to occur.
469
+
470
+ This method is here to force immediate connection to the lock
471
+ to check it's current status. After this method is called,
472
+ we might expect to receive an update within 2 seconds or less.
473
+ '''
474
+ GatewayObjectCommand(
475
+ self.component.gateway,
476
+ Colonel(id=self.component.config['colonel']),
477
+ id=self.component.id,
478
+ command='call', method='check_locked_status'
479
+ ).publish()
480
+
435
481
  def _receive_meta(self, data):
436
482
  from simo.users.models import Fingerprint
437
483
  if 'codes' in data:
simo/fleet/forms.py CHANGED
@@ -14,6 +14,7 @@ from simo.core.widgets import LogOutputWidget
14
14
  from simo.core.utils.easing import EASING_CHOICES
15
15
  from simo.core.utils.validators import validate_slaves
16
16
  from simo.core.utils.admin import AdminFormActionForm
17
+ from simo.core.events import GatewayObjectCommand
17
18
  from .models import Colonel, ColonelPin, Interface
18
19
  from .utils import INTERFACES_PINS_MAP
19
20
 
@@ -984,6 +985,18 @@ class BurglarSmokeDetectorConfigForm(ColonelComponentForm):
984
985
 
985
986
  class TTLockConfigForm(ColonelComponentForm):
986
987
 
988
+ door_sensor = forms.ModelChoiceField(
989
+ Component.objects.filter(base_type='binary-sensor'),
990
+ required=False,
991
+ help_text="Quickens up lock status reporting on open/close if provided.",
992
+ widget=autocomplete.ModelSelect2(
993
+ url='autocomplete-component', attrs={'data-html': True},
994
+ forward=(
995
+ forward.Const(['binary-sensor'], 'base_type'),
996
+ )
997
+ )
998
+ )
999
+
987
1000
  def clean(self):
988
1001
  if not self.instance or not self.instance.pk:
989
1002
  from .controllers import TTLock
@@ -1002,6 +1015,11 @@ class TTLockConfigForm(ColonelComponentForm):
1002
1015
  if commit:
1003
1016
  self.cleaned_data['colonel'].components.add(obj)
1004
1017
  self.cleaned_data['colonel'].save()
1018
+ if self.cleaned_data['door_sensor']:
1019
+ GatewayObjectCommand(
1020
+ self.instance.gateway, self.cleaned_data['door_sensor'],
1021
+ command='watch_lock_sensor'
1022
+ ).publish()
1005
1023
  return obj
1006
1024
 
1007
1025
 
simo/fleet/gateways.py CHANGED
@@ -1,13 +1,16 @@
1
1
  import datetime
2
2
  import time
3
+ import json
3
4
  from django.utils import timezone
5
+ from simo.core.models import Component
4
6
  from simo.core.gateways import BaseObjectCommandsGatewayHandler
5
7
  from simo.core.forms import BaseGatewayForm
6
8
  from simo.core.models import Gateway
7
- from simo.core.events import GatewayObjectCommand
9
+ from simo.core.events import GatewayObjectCommand, get_event_obj
8
10
  from simo.core.utils.serialization import deserialize_form_data
9
11
 
10
12
 
13
+
11
14
  class FleetGatewayHandler(BaseObjectCommandsGatewayHandler):
12
15
  name = "SIMO.io Fleet"
13
16
  config_form = BaseGatewayForm
@@ -18,8 +21,41 @@ class FleetGatewayHandler(BaseObjectCommandsGatewayHandler):
18
21
  ('push_discoveries', 6),
19
22
  )
20
23
 
24
+ def run(self, exit):
25
+ from simo.fleet.controllers import TTLock
26
+ self.door_sensors_on_watch = set()
27
+ for lock in Component.objects.filter(controller_uid=TTLock.uid):
28
+ if not lock.config.get('door_sensor'):
29
+ continue
30
+ door_sensor = Component.objects.filter(
31
+ id=lock.config['door_sensor']
32
+ ).first()
33
+ if not door_sensor:
34
+ continue
35
+ self.door_sensors_on_watch.add(door_sensor.id)
36
+ door_sensor.on_change(self.on_door_sensor)
37
+ super().run(exit)
38
+
39
+
21
40
  def _on_mqtt_message(self, client, userdata, msg):
22
- pass
41
+ from simo.core.models import Component
42
+ payload = json.loads(msg.payload)
43
+ if payload.get('command') == 'watch_lock_sensor':
44
+ door_sensor = get_event_obj(payload, Component)
45
+ if not door_sensor:
46
+ return
47
+ print("Adding door sensor to lock watch!")
48
+ if door_sensor.id in self.door_sensors_on_watch:
49
+ return
50
+ self.door_sensors_on_watch.add(door_sensor.id)
51
+ door_sensor.on_change(self.on_door_sensor)
52
+
53
+ def on_door_sensor(self, sensor):
54
+ from simo.fleet.controllers import TTLock
55
+ for lock in Component.objects.filter(
56
+ controller_uid=TTLock.uid, config__door_sensor=sensor.id
57
+ ):
58
+ lock.check_locked_status()
23
59
 
24
60
  def look_for_updates(self):
25
61
  from .models import Colonel
@@ -5,6 +5,7 @@ import datetime
5
5
  import json
6
6
  from django.core.exceptions import ValidationError
7
7
  from django.utils import timezone
8
+ from django.utils.functional import cached_property
8
9
  from django.utils.translation import gettext_lazy as _
9
10
  from django.conf import settings
10
11
  from django.urls import reverse_lazy
@@ -354,6 +355,20 @@ class AlarmGroup(ControllerBase):
354
355
  self.component.config['stats'] = stats
355
356
  self.component.save()
356
357
 
358
+ @cached_property
359
+ def events_map(self):
360
+ map = {}
361
+ for entry in self.component.config.get('breach_events', []):
362
+ if 'uid' not in entry:
363
+ continue
364
+ comp = Component.objects.filter(id=entry['component']).first()
365
+ if not comp:
366
+ continue
367
+ map[entry['uid']] = json.loads(json.dumps(entry))
368
+ map[entry['uid']].pop('uid')
369
+ map[entry['uid']]['component'] = comp
370
+ return map
371
+
357
372
 
358
373
  class WeatherForecast(ControllerBase):
359
374
  name = _("Weather Forecast")
simo/generic/forms.py CHANGED
@@ -4,7 +4,7 @@ from django.db.models import Q
4
4
  from django.utils.translation import gettext_lazy as _
5
5
  from django.urls.base import get_script_prefix
6
6
  from django.contrib.contenttypes.models import ContentType
7
- from simo.core.forms import BaseComponentForm
7
+ from simo.core.forms import HiddenField, BaseComponentForm
8
8
  from simo.core.models import Icon, Component
9
9
  from simo.core.controllers import (
10
10
  BinarySensor, NumericSensor, MultiSensor, Switch
@@ -18,6 +18,14 @@ from simo.core.utils.form_fields import ListSelect2Widget
18
18
  from simo.conf import dynamic_settings
19
19
 
20
20
 
21
+ ACTION_METHODS = (
22
+ ('turn_on', "Turn ON"), ('turn_off', "Turn OFF"),
23
+ ('play', "Play"), ('pause', "Pause"), ('stop', "Stop"),
24
+ ('open', 'Open'), ('close', 'Close'),
25
+ ('lock', "Lock"), ('unlock', "Unlock"),
26
+ )
27
+
28
+
21
29
  class ScriptConfigForm(BaseComponentForm):
22
30
  autostart = forms.BooleanField(
23
31
  initial=True, required=False,
@@ -149,6 +157,49 @@ class ThermostatConfigForm(BaseComponentForm):
149
157
  return super().save(commit)
150
158
 
151
159
 
160
+ class AlarmBreachEventForm(forms.Form):
161
+ uid = HiddenField(required=False)
162
+ component = forms.ModelChoiceField(
163
+ Component.objects.all(),
164
+ widget=autocomplete.ModelSelect2(
165
+ url='autocomplete-component', attrs={'data-html': True},
166
+ ),
167
+ )
168
+ breach_action = forms.ChoiceField(
169
+ initial='turn_on', choices=ACTION_METHODS
170
+ )
171
+ disarm_action = forms.ChoiceField(
172
+ required=False, initial='turn_off', choices=ACTION_METHODS
173
+ )
174
+ delay = forms.IntegerField(
175
+ label="Delay (s)",
176
+ min_value=0, max_value=600, initial=0,
177
+ help_text="Event will not fire if alarm group is disarmed "
178
+ "within given timeframe of seconds after the breach."
179
+ )
180
+ prefix = 'breach_events'
181
+
182
+ def clean(self):
183
+ if not self.cleaned_data.get('component'):
184
+ return self.cleaned_data
185
+ if not self.cleaned_data.get('breach_action'):
186
+ return self.cleaned_data
187
+ component = self.cleaned_data.get('component')
188
+ if not hasattr(component, self.cleaned_data['breach_action']):
189
+ self.add_error(
190
+ 'breach_action',
191
+ f"{component} has no {self.cleaned_data['breach_action']} action!"
192
+ )
193
+ if self.cleaned_data.get('disarm_action'):
194
+ if not hasattr(component, self.cleaned_data['disarm_action']):
195
+ self.add_error(
196
+ 'disarm_action',
197
+ f"{component} has no "
198
+ f"{self.cleaned_data['disarm_action']} action!"
199
+ )
200
+ return self.cleaned_data
201
+
202
+
152
203
  # TODO: create control widget for admin use.
153
204
  class AlarmGroupConfigForm(BaseComponentForm):
154
205
  components = forms.ModelMultipleChoiceField(
@@ -167,11 +218,40 @@ class AlarmGroupConfigForm(BaseComponentForm):
167
218
  required=False,
168
219
  help_text="Defines if this is your main/top global alarm group."
169
220
  )
221
+ arming_locks = forms.ModelMultipleChoiceField(
222
+ Component.objects.filter(base_type='lock'),
223
+ label="Arming locks", required=False,
224
+ widget=autocomplete.ModelSelect2Multiple(
225
+ url='autocomplete-component', attrs={'data-html': True},
226
+ forward=(
227
+ forward.Const(['lock'], 'base_type'),
228
+ )
229
+ ),
230
+ help_text="Alarm group will get armed automatically whenever "
231
+ "any of assigned locks get's locked. "
232
+ )
233
+ disarming_locks = forms.ModelMultipleChoiceField(
234
+ Component.objects.filter(base_type='lock'),
235
+ label="Disarming locks", required=False,
236
+ widget=autocomplete.ModelSelect2Multiple(
237
+ url='autocomplete-component', attrs={'data-html': True},
238
+ forward=(
239
+ forward.Const(['lock'], 'base_type'),
240
+ )
241
+ ),
242
+ help_text="Alarm group will be disarmed automatically whenever "
243
+ "any of assigned locks get's unlocked. "
244
+ )
170
245
  notify_on_breach = forms.IntegerField(
171
246
  required=False, min_value=0,
172
- help_text="Notify active users if "
173
- "not disarmed within given number of seconds "
174
- "after the breached."
247
+ help_text="Notify active users on breach if "
248
+ "not disarmed within given number of seconds. <br>"
249
+ "Leave this empty to disable breach notifications."
250
+ )
251
+ breach_events = FormsetField(
252
+ formset_factory(
253
+ AlarmBreachEventForm, can_delete=True, can_order=True, extra=0
254
+ ), label='Breach events'
175
255
  )
176
256
  has_alarm = False
177
257
 
@@ -194,6 +274,14 @@ class AlarmGroupConfigForm(BaseComponentForm):
194
274
  self.fields['is_main'].widget.attrs['disabled'] = 'disabled'
195
275
 
196
276
 
277
+ def clean_breach_events(self):
278
+ events = self.cleaned_data['breach_events']
279
+ for i, cont in enumerate(events):
280
+ if not cont.get('uid'):
281
+ cont['uid'] = get_random_string(6)
282
+ return events
283
+
284
+
197
285
  def recurse_check_alarm_groups(self, components, start_comp=None):
198
286
  for comp in components:
199
287
  check_cmp = start_comp if start_comp else comp
@@ -476,16 +564,8 @@ class StateSelectForm(BaseComponentForm):
476
564
  )
477
565
 
478
566
 
479
- ACTION_METHODS = (
480
- ('turn_on', "Turn ON"), ('turn_off', "Turn OFF"),
481
- ('play', "Play"), ('pause', "Pause"), ('stop', "Stop"),
482
- ('open', 'Open'), ('close', 'Close'),
483
- ('lock', "Lock"), ('unlock', "Unlock"),
484
- )
485
-
486
-
487
567
  class AlarmClockEventForm(forms.Form):
488
- uid = forms.CharField(widget=forms.HiddenInput(), required=False)
568
+ uid = HiddenField(required=False)
489
569
  enabled = forms.BooleanField(initial=True)
490
570
  name = forms.CharField(max_length=30)
491
571
  component = forms.ModelChoiceField(
simo/generic/gateways.py CHANGED
@@ -5,6 +5,7 @@ import json
5
5
  import time
6
6
  import multiprocessing
7
7
  import threading
8
+ import traceback
8
9
  from django.conf import settings
9
10
  from django.utils import timezone
10
11
  from django.db import connection as db_connection
@@ -165,7 +166,8 @@ class GenericGatewayHandler(BaseObjectCommandsGatewayHandler):
165
166
  ('watch_thermostats', 60),
166
167
  ('watch_alarm_clocks', 30),
167
168
  ('watch_scripts', 10),
168
- ('watch_watering', 60)
169
+ ('watch_watering', 60),
170
+ ('watch_alarm_events', 1),
169
171
  )
170
172
 
171
173
  def watch_thermostats(self):
@@ -441,6 +443,27 @@ class GenericGatewayHandler(BaseObjectCommandsGatewayHandler):
441
443
  else:
442
444
  switch.turn_off()
443
445
 
446
+ def watch_alarm_events(self):
447
+ from .controllers import AlarmGroup
448
+ for alarm in Component.objects.filter(
449
+ controller_uid=AlarmGroup.uid, value='breached',
450
+ meta__breach_start__gt=0
451
+ ):
452
+ for uid, event in alarm.controller.events_map.items():
453
+ if uid in alarm.meta.get('events_triggered', []):
454
+ continue
455
+ if time.time() - alarm.meta['breach_start'] < event['delay']:
456
+ continue
457
+ try:
458
+ getattr(event['component'], event['breach_action'])()
459
+ except Exception:
460
+ print(traceback.format_exc(), file=sys.stderr)
461
+ if not alarm.meta.get('events_triggered'):
462
+ alarm.meta['events_triggered'] = [uid]
463
+ else:
464
+ alarm.meta['events_triggered'].append(uid)
465
+ alarm.save(update_fields=['meta'])
466
+
444
467
 
445
468
  class DummyGatewayHandler(BaseObjectCommandsGatewayHandler):
446
469
  name = "Dummy"
simo/generic/models.py CHANGED
@@ -1,3 +1,6 @@
1
+ import time
2
+ import sys
3
+ import traceback
1
4
  from threading import Timer
2
5
  from django.db.models.signals import pre_save, post_save, post_delete
3
6
  from django.dispatch import receiver
@@ -19,7 +22,6 @@ def handle_alarm_groups(sender, instance, *args, **kwargs):
19
22
  for alarm_group in Component.objects.filter(
20
23
  controller_uid=AlarmGroup.uid,
21
24
  config__components__contains=instance.id,
22
- config__notify_on_breach__gt=-1
23
25
  ).exclude(value='disarmed'):
24
26
  stats = {
25
27
  'disarmed': 0, 'pending-arm': 0, 'armed': 0, 'breached': 0
@@ -57,26 +59,44 @@ def handle_alarm_groups(sender, instance, *args, **kwargs):
57
59
  'alarm', str(alarm_group_component), body,
58
60
  component=alarm_group_component
59
61
  )
60
- t = Timer(
61
- # give it one second to finish with other db processes.
62
- alarm_group.config['notify_on_breach'] + 1,
63
- notify_users_security_breach, [alarm_group.id]
64
- )
65
- t.start()
62
+ if alarm_group.config.get('notify_on_breach') is not None:
63
+ t = Timer(
64
+ # give it one second to finish with other db processes.
65
+ alarm_group.config['notify_on_breach'] + 1,
66
+ notify_users_security_breach, [alarm_group.id]
67
+ )
68
+ t.start()
66
69
  alarm_group_value = 'breached'
67
70
  else:
68
71
  alarm_group_value = 'pending-arm'
72
+
69
73
  alarm_group.controller.set(alarm_group_value)
70
74
 
71
75
 
72
- @receiver(post_save, sender=Component)
73
- def set_initial_alarm_group_stats(sender, instance, created, *args, **kwargs):
74
- if not created:
75
- return
76
+ @receiver(pre_save, sender=Component)
77
+ def manage_alarm_groups(sender, instance, *args, **kwargs):
76
78
  if instance.controller_uid != AlarmGroup.uid:
77
79
  return
78
- if instance.controller:
79
- instance.controller.refresh_status()
80
+
81
+ if 'value' not in instance.get_dirty_fields():
82
+ return
83
+
84
+ if instance.value == 'breached':
85
+ instance.meta['breach_start'] = time.time()
86
+ instance.meta['events_triggered'] = []
87
+ elif instance.get_dirty_fields()['value'] == 'breached' \
88
+ and instance.value == 'disarmed':
89
+ instance.meta['breach_start'] = None
90
+ for event_uid in instance.meta.get('events_triggered', []):
91
+ event = instance.controller.events_map.get(event_uid)
92
+ if not event:
93
+ continue
94
+ if not event.get('disarm_action'):
95
+ continue
96
+ try:
97
+ getattr(event['component'], event['disarm_action'])()
98
+ except Exception:
99
+ print(traceback.format_exc(), file=sys.stderr)
80
100
 
81
101
 
82
102
  @receiver(post_delete, sender=Component)
@@ -91,3 +111,24 @@ def clear_alarm_group_config_on_component_delete(
91
111
  id for id in ag.config.get('components', []) if id != instance.id
92
112
  ]
93
113
  ag.save(update_fields=['config'])
114
+
115
+
116
+ @receiver(post_save, sender=Component)
117
+ def bind_controlling_locks_to_alarm_groups(sender, instance, *args, **kwargs):
118
+ if instance.base_type != 'lock':
119
+ return
120
+ if 'value' not in instance.get_dirty_fields():
121
+ return
122
+ if instance.value == 'locked':
123
+ for ag in Component.objects.filter(
124
+ base_type=AlarmGroup.base_type,
125
+ config__arming_locks__contains=instance.id
126
+ ):
127
+ ag.arm()
128
+ elif instance.value == 'unlocked':
129
+ for ag in Component.objects.filter(
130
+ base_type=AlarmGroup.base_type,
131
+ config__arming_locks__contains=instance.id
132
+ ):
133
+ ag.disarm()
134
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: simo
3
- Version: 2.0.11
3
+ Version: 2.0.13
4
4
  Summary: Smart Home on Steroids!
5
5
  Author-email: Simanas Venčkauskas <simanas@simo.io>
6
6
  Project-URL: Homepage, https://simo.io
@@ -25,10 +25,10 @@ simo/_hub_template/hub/settings.py,sha256=4QhvhbtLRxHvAntwqG_qeAAtpDUqKvN4jzw9u3
25
25
  simo/_hub_template/hub/supervisor.conf,sha256=IY3fdK0fDD2eAothB0n54xhjQj8LYoXIR96-Adda5Z8,1353
26
26
  simo/_hub_template/hub/urls.py,sha256=Ydm-1BkYAzWeEF-MKSDIFf-7aE4qNLPm48-SA51XgJQ,25
27
27
  simo/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
- simo/core/admin.py,sha256=MoYz7B5YaTkF2JeacaqsR177N2IagKXc-rG0FTXjJ1w,17622
28
+ simo/core/admin.py,sha256=5QFmf7ZOqkSEba2Fo-UfJp5dVe-vhKUtOUlAnfQGhno,17634
29
29
  simo/core/api.py,sha256=OVvv3a7KYJGbuUgSOfWzPg8hoCCRhdDejQ2kVs0H2p8,23803
30
30
  simo/core/api_auth.py,sha256=_3hG4e1eLKrcRnSAOB_xTL6cwtOJ2_7JS7GZU_iqTgA,1251
31
- simo/core/api_meta.py,sha256=li4KLFl6xTYOoD8pgS8PYcjqTZlzhXI0reAkt06PZFA,3404
31
+ simo/core/api_meta.py,sha256=dZkz7z-7GaMPVAsfQxOYCvpYaMPx_v2zynbY6JM8oD8,3477
32
32
  simo/core/app_widgets.py,sha256=EEQOto3fGR0syDqpJE38tQrx8DoTTyg26nF5kYzHY38,2018
33
33
  simo/core/auto_urls.py,sha256=0gu-IL7PHobrmKW6ksffiOkAYu-aIorykWdxRNtwGYo,1194
34
34
  simo/core/autocomplete_views.py,sha256=JT5LA2_Wtr60XYSAIqaXFKFYPjrmkEf6yunXD9y2zco,4022
@@ -38,7 +38,7 @@ simo/core/controllers.py,sha256=2D7YCLktx7a-4tn80DenQP2CdY0dc2bWg6IRU69YTZ8,2724
38
38
  simo/core/dynamic_settings.py,sha256=U2WNL96JzVXdZh0EqMPWrxqO6BaRR2Eo5KTDqz7MC4o,1943
39
39
  simo/core/events.py,sha256=LvtonJGNyCb6HLozs4EG0WZItnDwNdtnGQ4vTcnKvUs,4438
40
40
  simo/core/filters.py,sha256=ghtOZcrwNAkIyF5_G9Sn73NkiI71mXv0NhwCk4IyMIM,411
41
- simo/core/forms.py,sha256=OQTPuacid9AoEvZ86G9p6ILuviqJCtSDuwk2WlLj43E,21484
41
+ simo/core/forms.py,sha256=H66FJCX_OJ8ADTfFHRLLl6xEg5AsaFH59rbd2rCE2c8,21711
42
42
  simo/core/gateways.py,sha256=s_c2W0v2_te89i6LS4Nj7F2wn9UwjZXPT7pfy6SToVo,3714
43
43
  simo/core/loggers.py,sha256=EBdq23gTQScVfQVH-xeP90-wII2DQFDjoROAW6ggUP4,1645
44
44
  simo/core/managers.py,sha256=WoQ4OX3akIvoroSYji-nLVqXBSJzCiC1u_IiWkKbKmA,2413
@@ -46,7 +46,7 @@ simo/core/middleware.py,sha256=64PYjnyRnYf4sgMvPfR0oQqf9UEtxUwnhJe3RV6z_HI,2040
46
46
  simo/core/models.py,sha256=j5AymbJFt5HOIOYsHJ8UUKhb1TvIoqgH0T1y3BeGJuM,19408
47
47
  simo/core/permissions.py,sha256=UmFjGPDWtAUbaWxJsWORb2q6BREHqndv9mkSIpnmdLk,1379
48
48
  simo/core/routing.py,sha256=X1_IHxyA-_Q7hw1udDoviVP4_FSBDl8GYETTC2zWTbY,499
49
- simo/core/serializers.py,sha256=miEh2Sd47KmB0viyNlwrEG_1kxYiTUrvQQvisEoLYuA,15759
49
+ simo/core/serializers.py,sha256=dY2R9KPvmmwMP4Up_6AHM6lf0MY3BoON8Dn8ixFubAI,17118
50
50
  simo/core/signal_receivers.py,sha256=EZ8NSYZxUgSaLS16YZdK7T__l8dl0joMRllOxx5PUt4,2809
51
51
  simo/core/socket_consumers.py,sha256=n7VE2Fvqt4iEAYLTRbTPOcI-7tszMAADu7gimBxB-Fg,9635
52
52
  simo/core/storage.py,sha256=YlxmdRs-zhShWtFKgpJ0qp2NDBuIkJGYC1OJzqkbttQ,572
@@ -56,10 +56,10 @@ simo/core/types.py,sha256=WJEq48mIbFi_5Alt4wxWMGXxNxUTXqfQU5koH7wqHHI,1108
56
56
  simo/core/views.py,sha256=hlAKpAbCbqI3a-uL5tDp532T2oLFiF0MBzKUJ_SNzo0,5833
57
57
  simo/core/widgets.py,sha256=J9e06C6I22F6xKic3VMgG7WeX07glAcl-4bF2Mg180A,2827
58
58
  simo/core/__pycache__/__init__.cpython-38.pyc,sha256=y0IW37wBUIGa3Eh_ZG28pRqHKoLiPyTgUX2OnbkEPlc,158
59
- simo/core/__pycache__/admin.cpython-38.pyc,sha256=GP3lRW8djW3w_k5jgzs_fuW7j8wnmeysZcDtm5RFnD0,13442
59
+ simo/core/__pycache__/admin.cpython-38.pyc,sha256=D9Hu5xMkclP6xFjEOLB-mBOe0ZMuCF10uilsROKkvmM,13446
60
60
  simo/core/__pycache__/api.cpython-38.pyc,sha256=W-CdACWTu_ebqH52riswadzT7z2mNRZJU8HngqbYXzw,18874
61
61
  simo/core/__pycache__/api_auth.cpython-38.pyc,sha256=5UTBr3rDMERAfc0OuOVDwGeQkt6Q7GLBtZJAMBse1sg,1712
62
- simo/core/__pycache__/api_meta.cpython-38.pyc,sha256=bdk1PG7FuRfFwcvQtF103H0kQrx6Vt0JRiepktWEPh8,2730
62
+ simo/core/__pycache__/api_meta.cpython-38.pyc,sha256=7dDi_Aay7T4eSNYmEItMlb7yU91-5_pzEVg8GXXf4Qc,2783
63
63
  simo/core/__pycache__/app_widgets.cpython-38.pyc,sha256=9Es2wZNduzUJv-jZ_HX0-L3vqwpXWBbseEwoC5K6b-w,3465
64
64
  simo/core/__pycache__/auto_urls.cpython-38.pyc,sha256=SVl4fF0-yiq7e9gt08jIM6_rL4JYcR0cNHzR9jCEi1M,931
65
65
  simo/core/__pycache__/autocomplete_views.cpython-38.pyc,sha256=hJ6JILI1LqrAtpQMvxnLvljGdW1v1gpvBsD79vFkZ58,3972
@@ -69,7 +69,7 @@ simo/core/__pycache__/controllers.cpython-38.pyc,sha256=-NjuX7iGheE_ZMqkZ6g4ZnnV
69
69
  simo/core/__pycache__/dynamic_settings.cpython-38.pyc,sha256=ELu06Hub4DOidja71ybvD3ZM4HdXiyZjNJrZfnXZXNA,2476
70
70
  simo/core/__pycache__/events.cpython-38.pyc,sha256=A1Axx-qftd1r7st7wkO3DkvTdt9-RkcJe5KJhpzJVk8,5109
71
71
  simo/core/__pycache__/filters.cpython-38.pyc,sha256=VIMADCBiYhziIyRmxAyUDJluZvuZmiC4bNYWTRsGSao,721
72
- simo/core/__pycache__/forms.cpython-38.pyc,sha256=KH1SBm1NaDv99yHONenWWJcd4XSIqQ5tqaF8LSoeDws,17843
72
+ simo/core/__pycache__/forms.cpython-38.pyc,sha256=ikU7wTC6vxzsCJJul0VD5qFJlpXHvvksSkwGun8tYVM,18187
73
73
  simo/core/__pycache__/gateways.cpython-38.pyc,sha256=XBiwMfBkjoQ2re6jvADJOwK0_0Aav-crzie9qtfqT9U,4599
74
74
  simo/core/__pycache__/loggers.cpython-38.pyc,sha256=Z-cdQnC6XlIonPV4Sl4E52tP4NMEdPAiHK0cFaIL7I8,1623
75
75
  simo/core/__pycache__/managers.cpython-38.pyc,sha256=5vstOMfm997CZBBkaSiaS7EojhLTWZlbeA_EQ8u-yfg,2554
@@ -77,7 +77,7 @@ simo/core/__pycache__/middleware.cpython-38.pyc,sha256=bGOFJNEhJeLbpsZp8LYn1VA3p
77
77
  simo/core/__pycache__/models.cpython-38.pyc,sha256=Gm36LWRxswvWiB3Wz0F7g32ZVXugh7chSSBz1lgBPZs,16995
78
78
  simo/core/__pycache__/permissions.cpython-38.pyc,sha256=uygjPbfRQiEzyo5-McCxsuMDJLbDYO_TLu55U7bJbR0,1809
79
79
  simo/core/__pycache__/routing.cpython-38.pyc,sha256=3T3FPJ8Cn99xZCGvMyg2xjl7al-Shm9CelbSpkJtNP8,599
80
- simo/core/__pycache__/serializers.cpython-38.pyc,sha256=tJf8jV9pPJXnJjUPXCc103wQxC5ZrZaC9SlIR41JomQ,15707
80
+ simo/core/__pycache__/serializers.cpython-38.pyc,sha256=07FzXZGZBEHlx1vboZThhU98WpmJDSbcQP3uYWNKMDw,16551
81
81
  simo/core/__pycache__/signal_receivers.cpython-38.pyc,sha256=sgjH_wv-1U99auH5uHb3or0qettPeHAlsz8P7B03ajY,2430
82
82
  simo/core/__pycache__/socket_consumers.cpython-38.pyc,sha256=NJUr7nRyHFvmAumxxWpsod5wzVVZM99rCEuJs1utHA4,8432
83
83
  simo/core/__pycache__/storage.cpython-38.pyc,sha256=BTkYH8QQyjqI0WOtJC8fHNtgu0YA1vjqZclXjC2vCVI,1116
@@ -116,7 +116,7 @@ simo/core/drf_braces/serializers/enforce_validation_serializer.py,sha256=CQnnx8E
116
116
  simo/core/drf_braces/serializers/form_serializer.py,sha256=CacYpFHLbBFT9Cyxuiya4ZpAH64m_D4oyUk-JgiyYDc,14818
117
117
  simo/core/drf_braces/serializers/swapping.py,sha256=8gQerjgEzFx9nzRFKfy66REA51GOlwX17NsmhWSiGgo,1790
118
118
  simo/core/drf_braces/serializers/__pycache__/__init__.cpython-38.pyc,sha256=tyqVhHyZyg_OeWz9jptQ7Ya4M-AtUq8EJDLVrRbgUow,181
119
- simo/core/drf_braces/serializers/__pycache__/form_serializer.cpython-38.pyc,sha256=ko0jgtrD9XeMBwIN13gF3bepei_LL5FZgoI5GiiT3TY,13029
119
+ simo/core/drf_braces/serializers/__pycache__/form_serializer.cpython-38.pyc,sha256=9_RxMMXVE2qQgShX4797uj_4nJPx9JQezwEn-g4c8j4,13029
120
120
  simo/core/drf_braces/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
121
121
  simo/core/drf_braces/tests/test_mixins.py,sha256=PlGkHrC8-lui2CcRu3PS3fKqEAP5lzdbzJFXAMu1CF8,4065
122
122
  simo/core/drf_braces/tests/test_parsers.py,sha256=Sv6v6TWTnup72a-gXely5g7x-n8EtOc_uhYF95YZBK8,2171
@@ -10168,9 +10168,9 @@ simo/fleet/api.py,sha256=Hxn84xI-Q77HxjINgRbjSJQOv9jii4OL20LxK0VSrS8,2499
10168
10168
  simo/fleet/auto_urls.py,sha256=X04oKJWA48wFW5iXg3PPROY2KDdHn_a99orQSE28QC4,518
10169
10169
  simo/fleet/base_types.py,sha256=wL9RVkHr0gA7HI1wZq0pruGEIgvQqpfnCL4cC3ywsvw,102
10170
10170
  simo/fleet/ble.py,sha256=eHA_9ABjbmH1vUVCv9hiPXQL2GZZSEVwfO0xyI1S0nI,1081
10171
- simo/fleet/controllers.py,sha256=8kLEGNrZ3v5H_X9t8CUi6E6g8hTVohILHxgMSVSrGgM,18515
10172
- simo/fleet/forms.py,sha256=lWJ9luZiRJD4yLQCdCMmkvezk-vY3yVb3q7Y5C4r9qc,35649
10173
- simo/fleet/gateways.py,sha256=EbSxv1DGSuv8MT7MmpZxKgV041umOteFW2Xo4LybzWI,2284
10171
+ simo/fleet/controllers.py,sha256=rTxRFf-LKWAZxzixrsLZHHm51BmMx9a1PLdgf6inlNM,20533
10172
+ simo/fleet/forms.py,sha256=rLymJ2ovNgmwdKYJtxTfjuIm5Q4p3GLkIb7-Yo2DmS4,36375
10173
+ simo/fleet/gateways.py,sha256=KV5i5fxXIrlK-k6zyEkk83x11GJt-ELQ0npb4Ac83cM,3693
10174
10174
  simo/fleet/managers.py,sha256=XOpDOA9L-f_550TNSyXnJbun2EmtGz1TenVTMlUSb8E,807
10175
10175
  simo/fleet/models.py,sha256=J-rnn7Ew-7s3646NNRVY947Sbz21mUD_nBHtuHAKXds,14160
10176
10176
  simo/fleet/routing.py,sha256=cofGsVWXMfPDwsJ6HM88xxtRxHwERhJ48Xyxc8mxg5o,149
@@ -10184,9 +10184,9 @@ simo/fleet/__pycache__/api.cpython-38.pyc,sha256=rL9fb7cCQatyFvXyKmlNOKmxVo8vHYe
10184
10184
  simo/fleet/__pycache__/auto_urls.cpython-38.pyc,sha256=SqyTuaz_kEBvx-bL46SclsZEEP5RFh6U6TGKyXDdiOE,565
10185
10185
  simo/fleet/__pycache__/base_types.cpython-38.pyc,sha256=deyPwjpT6xZiFxBGFnj5b7R-lbdOTh2krgpJhrcGVhc,274
10186
10186
  simo/fleet/__pycache__/ble.cpython-38.pyc,sha256=Nrof9w7cm4OlpFWHeVnmvvanh2_oF9oQ3TknJiV93-0,1267
10187
- simo/fleet/__pycache__/controllers.cpython-38.pyc,sha256=3zVvD2Sp26JJfU_nQm8oe3O12-xXBQC7EKlaOpidDbw,16180
10188
- simo/fleet/__pycache__/forms.cpython-38.pyc,sha256=pHgx8ulx_2v0L0bkUHaW13Tn9KcfOP6ROhgHXPVvKJE,25510
10189
- simo/fleet/__pycache__/gateways.cpython-38.pyc,sha256=xg7fW5JusYwl6epn5nEjxQJlFOyQ18SVybOaRBcpwl8,2418
10187
+ simo/fleet/__pycache__/controllers.cpython-38.pyc,sha256=l9bz18Qp33C12TJOKPSn9vIXnlBKnBusODNk7Fg64qA,18103
10188
+ simo/fleet/__pycache__/forms.cpython-38.pyc,sha256=-Sbb1kBxb246hV8A2ESlR4LVEISJNedVFiOK1IzRt6c,25954
10189
+ simo/fleet/__pycache__/gateways.cpython-38.pyc,sha256=YAcgTOqJbtjGI03lvEcU6keFfrwAHkObVmErYzfRvjk,3569
10190
10190
  simo/fleet/__pycache__/managers.cpython-38.pyc,sha256=8uz-xpUiqbGDgXIZ_XRZtFb-Tju6NGxflGg-Ee4Yo6k,1310
10191
10191
  simo/fleet/__pycache__/models.cpython-38.pyc,sha256=S9pPqRjIxASXahoIOkkjQX7cBwjkdu4d2nXMju0-Cf8,12283
10192
10192
  simo/fleet/__pycache__/routing.cpython-38.pyc,sha256=aPrCmxFKVyB8R8ZbJDwdPdFfvT7CvobovvZeq_mqRgY,314
@@ -10261,25 +10261,25 @@ simo/fleet/migrations/__pycache__/0029_alter_i2cinterface_scl_pin_and_more.cpyth
10261
10261
  simo/fleet/migrations/__pycache__/0030_colonelpin_label_alter_colonel_type.cpython-38.pyc,sha256=sfglSDxXLKJ0qE8Dl3MYjv5hbszpDtb9CDxatoEzPSw,853
10262
10262
  simo/fleet/migrations/__pycache__/0031_alter_colonel_type.cpython-38.pyc,sha256=zXX254ZgEE4uSV8xnLdH9DM3qy-ICmbrT05i0Q287bU,733
10263
10263
  simo/fleet/migrations/__pycache__/0032_auto_20240415_0736.cpython-38.pyc,sha256=QD3JNIDQhzseXKLRYysYY3Q9_vDaurIhlWBcri83FMw,1655
10264
- simo/fleet/migrations/__pycache__/0033_auto_20240415_0736.cpython-38.pyc,sha256=zN3KrYHzE40Wfnt48zp7AGCIa8Jj9-fPXxo3adZIxwo,1046
10264
+ simo/fleet/migrations/__pycache__/0033_auto_20240415_0736.cpython-38.pyc,sha256=rZK8jUEeuXM7BZ7XCl0RHXXaGakzd_WcuFxmPjp5F_s,1046
10265
10265
  simo/fleet/migrations/__pycache__/0034_auto_20240418_0735.cpython-38.pyc,sha256=FEd_tw1GVVrRYd44Fdx1Yf-rsVIebHLel7jlv434A_E,924
10266
10266
  simo/fleet/migrations/__pycache__/__init__.cpython-38.pyc,sha256=5k1KW0jeSDzw6RnVPRq4CaO13Lg7M0F-pxA_gqqZ6Mg,170
10267
10267
  simo/generic/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10268
10268
  simo/generic/app_widgets.py,sha256=E_pnpA1hxMIhenRCrHoQ5cik06jm2BAHCkl_eo-OudU,1264
10269
10269
  simo/generic/base_types.py,sha256=djymox_boXTHX1BTTCLXrCH7ED-uAsV_idhaDOc3OLI,409
10270
- simo/generic/controllers.py,sha256=Mn4jmOx-1Yb5rAZb9ZcfMcYBMf7r61NQV2p8JgxbYvQ,51675
10271
- simo/generic/forms.py,sha256=p-gzH1z7J0CV8bKMmWTQluzpYmgc7fF1aEHL7eL-fJI,20075
10272
- simo/generic/gateways.py,sha256=L3obiClEomiRbWfDu_LrMbhH9r7hUH4e0oBeLMXghXw,16027
10273
- simo/generic/models.py,sha256=d00Q-UXtt7mG9MdPpZ3mXnxKjwlTI2FgCEklZpGBk7s,3629
10270
+ simo/generic/controllers.py,sha256=WYuOUzDWvkYRaTvlbdGy_qmwp1o_ohqKDfV7OrOq2QU,52218
10271
+ simo/generic/forms.py,sha256=lrdGgOuFxizUu2l5PbW8CUiI0V_L8xHl2pN_kZB9238,23160
10272
+ simo/generic/gateways.py,sha256=b3tQ2bAkDVYXCF5iZi2yi-6nZAM8WmHE9ICwxMyR0to,17034
10273
+ simo/generic/models.py,sha256=qRwZq92_suejbXSPTqbjIfICT0Y_ABywwYPa8DJ2V8A,5053
10274
10274
  simo/generic/routing.py,sha256=elQVZmgnPiieEuti4sJ7zITk1hlRxpgbotcutJJgC60,228
10275
10275
  simo/generic/socket_consumers.py,sha256=NfTQGYtVAc864IoogZRxf_0xpDPM0eMCWn0SlKA5P7Y,1751
10276
10276
  simo/generic/__pycache__/__init__.cpython-38.pyc,sha256=mLu54WS9KIl-pHwVCBKpsDFIlOqml--JsOVzAUHg6cU,161
10277
10277
  simo/generic/__pycache__/app_widgets.cpython-38.pyc,sha256=0IoKRG9n1tkNRRkrqAeOQwWBPd_33u98JBcVtMVVCio,2374
10278
10278
  simo/generic/__pycache__/base_types.cpython-38.pyc,sha256=ptw6axyAqemZA35oa6vzr7EihzvbhW9w7Y-G6kfDedU,555
10279
- simo/generic/__pycache__/controllers.cpython-38.pyc,sha256=3U3W-EgpKdqh7MN94ZaoCEGvY4SSq2zsTptJoZbysgM,32795
10280
- simo/generic/__pycache__/forms.cpython-38.pyc,sha256=Uvh-YQxcN-0RCinRi13fyITM1Jxm6FwYQyXs3OLNruA,15699
10281
- simo/generic/__pycache__/gateways.cpython-38.pyc,sha256=syQzoWdRGcU4kKRMC8HfbQ9VQiGxMWkzYwJDqQcfDwg,11940
10282
- simo/generic/__pycache__/models.cpython-38.pyc,sha256=ItjBjJaioJttzSUJsHCKV4xiqH6QX90kRw1C_Xx7mMk,3065
10279
+ simo/generic/__pycache__/controllers.cpython-38.pyc,sha256=e0bvgyePgJbIs1omBq0TRPlVSKar2sK_JbUKqDRj7mY,33235
10280
+ simo/generic/__pycache__/forms.cpython-38.pyc,sha256=IOiN9D0NVn8e8OOieawG0QjSTZDcmFhGsf2i6oWaj5A,17430
10281
+ simo/generic/__pycache__/gateways.cpython-38.pyc,sha256=a4lLIMPyxm9tNzIqorXHIPZFVTcXlPsM1ycJMghxcHA,12673
10282
+ simo/generic/__pycache__/models.cpython-38.pyc,sha256=j4jKYVTM4XUfFzxIaRFhYx0oRSYzDRn8YVQyCteEhtc,3881
10283
10283
  simo/generic/__pycache__/routing.cpython-38.pyc,sha256=xtxTUTBTdivzFyA5Wh7k-hUj1WDO_FiRq6HYXdbr9Ks,382
10284
10284
  simo/generic/__pycache__/socket_consumers.cpython-38.pyc,sha256=piFHces0J9QuXu_CNBCQCYjoZEeoaxyVjLfJ9KaR8C8,1898
10285
10285
  simo/generic/static/weather_icons/01d@2x.png,sha256=TZfWi6Rfddb2P-oldWWcjUiuCHiU9Yrc5hyrQAhF26I,948
@@ -10444,8 +10444,8 @@ simo/users/templates/invitations/expired_msg.html,sha256=47DEQpj8HBSa-_TImW-5JCe
10444
10444
  simo/users/templates/invitations/expired_suggestion.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10445
10445
  simo/users/templates/invitations/taken_msg.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10446
10446
  simo/users/templates/invitations/taken_suggestion.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10447
- simo-2.0.11.dist-info/LICENSE.md,sha256=M7wm1EmMGDtwPRdg7kW4d00h1uAXjKOT3HFScYQMeiE,34916
10448
- simo-2.0.11.dist-info/METADATA,sha256=Jht-Iw22NzZexiiYzADTUYitoe7_ld7aSBHpOZPpylc,1700
10449
- simo-2.0.11.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
10450
- simo-2.0.11.dist-info/top_level.txt,sha256=GmS1hrAbpVqn9OWZh6UX82eIOdRLgYA82RG9fe8v4Rs,5
10451
- simo-2.0.11.dist-info/RECORD,,
10447
+ simo-2.0.13.dist-info/LICENSE.md,sha256=M7wm1EmMGDtwPRdg7kW4d00h1uAXjKOT3HFScYQMeiE,34916
10448
+ simo-2.0.13.dist-info/METADATA,sha256=rwjjG5hKGMgEGunNKlhYByM46Kepk1OFgrYTcoQ5bcg,1700
10449
+ simo-2.0.13.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
10450
+ simo-2.0.13.dist-info/top_level.txt,sha256=GmS1hrAbpVqn9OWZh6UX82eIOdRLgYA82RG9fe8v4Rs,5
10451
+ simo-2.0.13.dist-info/RECORD,,
File without changes