simo 2.8.15__py3-none-any.whl → 2.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of simo might be problematic. Click here for more details.

Files changed (64) hide show
  1. simo/automation/__pycache__/gateways.cpython-312.pyc +0 -0
  2. simo/automation/gateways.py +12 -10
  3. simo/core/__pycache__/admin.cpython-312.pyc +0 -0
  4. simo/core/__pycache__/auto_urls.cpython-312.pyc +0 -0
  5. simo/core/__pycache__/controllers.cpython-312.pyc +0 -0
  6. simo/core/__pycache__/models.cpython-312.pyc +0 -0
  7. simo/core/__pycache__/serializers.cpython-312.pyc +0 -0
  8. simo/core/__pycache__/tasks.cpython-312.pyc +0 -0
  9. simo/core/__pycache__/views.cpython-312.pyc +0 -0
  10. simo/core/admin.py +5 -2
  11. simo/core/auto_urls.py +4 -1
  12. simo/core/controllers.py +42 -5
  13. simo/core/models.py +32 -16
  14. simo/core/serializers.py +2 -2
  15. simo/core/tasks.py +8 -1
  16. simo/core/templates/admin/core/component_change_form.html +1 -1
  17. simo/core/templates/admin/wizard/discovery.html +3 -4
  18. simo/core/templates/admin/wizard/wizard_add.html +1 -1
  19. simo/core/views.py +26 -2
  20. simo/fleet/__pycache__/api.cpython-312.pyc +0 -0
  21. simo/fleet/__pycache__/base_types.cpython-312.pyc +0 -0
  22. simo/fleet/__pycache__/controllers.cpython-312.pyc +0 -0
  23. simo/fleet/__pycache__/forms.cpython-312.pyc +0 -0
  24. simo/fleet/__pycache__/managers.cpython-312.pyc +0 -0
  25. simo/fleet/__pycache__/models.cpython-312.pyc +0 -0
  26. simo/fleet/__pycache__/serializers.cpython-312.pyc +0 -0
  27. simo/fleet/__pycache__/socket_consumers.cpython-312.pyc +0 -0
  28. simo/fleet/api.py +26 -3
  29. simo/fleet/base_types.py +1 -0
  30. simo/fleet/controllers.py +240 -7
  31. simo/fleet/custom_dali_operations.py +275 -0
  32. simo/fleet/forms.py +132 -3
  33. simo/fleet/managers.py +3 -1
  34. simo/fleet/migrations/0045_alter_colonel_type_customdalidevice.py +29 -0
  35. simo/fleet/migrations/0046_delete_customdalidevice.py +16 -0
  36. simo/fleet/migrations/0047_customdalidevice.py +28 -0
  37. simo/fleet/migrations/0048_remove_customdalidevice_colonel_and_more.py +28 -0
  38. simo/fleet/migrations/__pycache__/0045_alter_colonel_type_customdalidevice.cpython-312.pyc +0 -0
  39. simo/fleet/migrations/__pycache__/0046_delete_customdalidevice.cpython-312.pyc +0 -0
  40. simo/fleet/migrations/__pycache__/0047_customdalidevice.cpython-312.pyc +0 -0
  41. simo/fleet/migrations/__pycache__/0048_remove_customdalidevice_colonel_and_more.cpython-312.pyc +0 -0
  42. simo/fleet/models.py +54 -9
  43. simo/fleet/serializers.py +15 -1
  44. simo/fleet/socket_consumers.py +6 -0
  45. simo/fleet/tasks.py +22 -2
  46. simo/fleet/templates/fleet/controllers_info/RoomZonePresenceSensor.md +8 -0
  47. simo/generic/__pycache__/controllers.cpython-312.pyc +0 -0
  48. simo/generic/__pycache__/forms.cpython-312.pyc +0 -0
  49. simo/generic/__pycache__/gateways.cpython-312.pyc +0 -0
  50. simo/generic/controllers.py +99 -43
  51. simo/generic/forms.py +13 -10
  52. simo/generic/gateways.py +91 -2
  53. simo/generic/migrations/0003_auto_20250409_1404.py +33 -0
  54. simo/generic/migrations/__pycache__/0003_auto_20250409_1404.cpython-312.pyc +0 -0
  55. simo/users/__pycache__/api.cpython-312.pyc +0 -0
  56. simo/users/__pycache__/dynamic_settings.cpython-312.pyc +0 -0
  57. simo/users/api.py +71 -18
  58. simo/users/dynamic_settings.py +1 -1
  59. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/METADATA +1 -1
  60. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/RECORD +64 -52
  61. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/WHEEL +0 -0
  62. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/entry_points.txt +0 -0
  63. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/licenses/LICENSE.md +0 -0
  64. {simo-2.8.15.dist-info → simo-2.10.1.dist-info}/top_level.txt +0 -0
simo/generic/forms.py CHANGED
@@ -90,33 +90,38 @@ class ThermostatConfigForm(BaseComponentForm):
90
90
  )
91
91
  )
92
92
  # TODO: support for multiple heaters
93
- heater = Select2ModelChoiceField(
93
+ heaters = Select2ModelMultipleChoiceField(
94
94
  queryset=Component.objects.filter(base_type=Switch.base_type),
95
95
  required=False,
96
96
  url='autocomplete-component',
97
97
  forward=(
98
98
  forward.Const([
99
- Switch.base_type,
99
+ Switch.base_type, Dimmer.base_type
100
100
  ], 'base_type'),
101
101
  )
102
102
  )
103
103
  # TODO: support for multiple coolers
104
- cooler = Select2ModelChoiceField(
104
+ coolers = Select2ModelMultipleChoiceField(
105
105
  queryset=Component.objects.filter(base_type=Switch.base_type),
106
106
  required=False,
107
107
  url='autocomplete-component',
108
108
  forward=(
109
109
  forward.Const([
110
- Switch.base_type,
110
+ Switch.base_type, Dimmer.base_type
111
111
  ], 'base_type'),
112
112
  )
113
113
 
114
114
  )
115
- mode = forms.ChoiceField(
116
- choices=(('heater', "Heater"), ('cooler', "Cooler"), ('auto', "Auto"),),
117
- initial='heater'
115
+ engagement = forms.ChoiceField(
116
+ choices=(('dynamic', "Dynamic"), ('static', "Static")),
117
+ initial='dynamic',
118
+ help_text="Dynamic - scales engagement intensity within reaction window <br>"
119
+ "Static - engages/disengages fully within reaction window <br>"
120
+ )
121
+ reaction_difference = forms.FloatField(
122
+ initial=2, min_value=0, max_value=50,
123
+ help_text="Reaction window = target temp +- reaction difference."
118
124
  )
119
- reaction_difference = forms.FloatField(initial=0.5)
120
125
  min = forms.IntegerField(initial=3)
121
126
  max = forms.IntegerField(initial=36)
122
127
  use_real_feel = forms.BooleanField(
@@ -126,8 +131,6 @@ class ThermostatConfigForm(BaseComponentForm):
126
131
  def __init__(self, *args, **kwargs):
127
132
  super().__init__(*args, **kwargs)
128
133
  if self.instance.pk:
129
- self.fields['mode'].initial = \
130
- self.instance.config['user_config']['mode']
131
134
  temperature_sensor = Component.objects.filter(
132
135
  pk=self.instance.config.get('temperature_sensor', 0)
133
136
  ).first()
simo/generic/gateways.py CHANGED
@@ -195,7 +195,7 @@ class GenericGatewayHandler(
195
195
  self.sensors_on_watch = {}
196
196
  self.sleep_is_on = {}
197
197
  self.last_set_state = None
198
-
198
+ self.pulsing_switches = {}
199
199
 
200
200
 
201
201
  def watch_thermostats(self):
@@ -208,6 +208,7 @@ class GenericGatewayHandler(
208
208
  timezone.activate(tz)
209
209
  thermostat.evaluate()
210
210
 
211
+
211
212
  def watch_alarm_clocks(self):
212
213
  from .controllers import AlarmClock
213
214
  drop_current_instance()
@@ -218,6 +219,7 @@ class GenericGatewayHandler(
218
219
  timezone.activate(tz)
219
220
  alarm_clock.tick()
220
221
 
222
+
221
223
  def watch_watering(self):
222
224
  drop_current_instance()
223
225
  from .controllers import Watering
@@ -231,13 +233,15 @@ class GenericGatewayHandler(
231
233
  else:
232
234
  watering.controller._perform_schedule()
233
235
 
236
+
234
237
  def run(self, exit):
235
238
  drop_current_instance()
236
239
  self.exit = exit
237
240
  self.logger = get_gw_logger(self.gateway_instance.id)
238
241
  for task, period in self.periodic_tasks:
239
242
  threading.Thread(
240
- target=self._run_periodic_task, args=(exit, task, period), daemon=True
243
+ target=self._run_periodic_task, args=(exit, task, period),
244
+ daemon=True
241
245
  ).start()
242
246
 
243
247
  from simo.generic.controllers import IPCamera
@@ -254,6 +258,11 @@ class GenericGatewayHandler(
254
258
  cam_watch = CameraWatcher(cam.id, exit)
255
259
  cam_watch.start()
256
260
 
261
+ threading.Thread(
262
+ target=self.watch_switch_pulses, args=(exit,),
263
+ daemon=True
264
+ ).start()
265
+
257
266
  print("GATEWAY STARTED!")
258
267
  while not exit.is_set():
259
268
  mqtt_client.loop()
@@ -278,6 +287,8 @@ class GenericGatewayHandler(
278
287
  self.control_alarm_group(component, payload.get('set_val'))
279
288
  # elif component.controller_uid == AudioAlert.uid:
280
289
  # self.control_audio_alert(component, payload.get('set_val'))
290
+ elif payload.get('pulse'):
291
+ self.start_pulse(component, payload['pulse'])
281
292
  else:
282
293
  component.controller.set(payload.get('set_val'))
283
294
  except Exception:
@@ -416,6 +427,84 @@ class GenericGatewayHandler(
416
427
  ] = time.time()
417
428
 
418
429
 
430
+ def watch_switch_pulses(self, exit):
431
+ for comp in Component.objects.filter(
432
+ base_type='switch', meta__has_key='pulse'
433
+ ):
434
+ comp.send(True)
435
+ self.pulsing_switches[comp.id] = {
436
+ 'comp': comp, 'last_toggle': time.time(), 'value': True,
437
+ 'pulse': comp.meta['pulse']
438
+ }
439
+
440
+ step = 0
441
+ while not exit.is_set():
442
+ time.sleep(0.1)
443
+ step += 1
444
+ remove_switches = []
445
+ for id, data in self.pulsing_switches.items():
446
+ on_interval = data['pulse']['frame'] * data['pulse']['duty']
447
+ off_interval = data['pulse']['frame'] - on_interval
448
+
449
+ if (
450
+ data['value'] and
451
+ time.time() - data['last_toggle'] > on_interval
452
+ ) or (
453
+ not data['value'] and
454
+ time.time() - data['last_toggle'] > off_interval
455
+ ):
456
+ data['comp'].refresh_from_db()
457
+ if not data['comp'].meta.get('pulse'):
458
+ remove_switches.append(id)
459
+ continue
460
+ if data['pulse'] != data['comp'].meta['pulse']:
461
+ self.pulsing_switches[id]['pulse'] = data['comp'].meta['pulse']
462
+ continue
463
+
464
+ if data['value']:
465
+ if time.time() - data['last_toggle'] > on_interval:
466
+ data['comp'].send(False)
467
+ self.pulsing_switches[id]['last_toggle'] = time.time()
468
+ self.pulsing_switches[id]['value'] = False
469
+ else:
470
+ if time.time() - data['last_toggle'] > off_interval:
471
+ data['comp'].send(True)
472
+ self.pulsing_switches[id]['last_toggle'] = time.time()
473
+ self.pulsing_switches[id]['value'] = True
474
+
475
+ for id in remove_switches:
476
+ del self.pulsing_switches[id]
477
+
478
+ # Update with db every 5s just in case something is missed.
479
+ if step < 50:
480
+ continue
481
+ step = 0
482
+
483
+ remove_switches = set(self.pulsing_switches.keys())
484
+ for comp in Component.objects.filter(
485
+ base_type='switch', meta__has_key='pulse'
486
+ ):
487
+ if comp.id in remove_switches:
488
+ remove_switches.remove(comp.id)
489
+ self.pulsing_switches[comp.id]['pulse'] = comp.meta['pulse']
490
+ continue
491
+ comp.send(True)
492
+ self.pulsing_switches[comp.id] = {
493
+ 'comp': comp, 'last_toggle': time.time(), 'value': True,
494
+ 'pulse': comp.meta['pulse']
495
+ }
496
+ for id in remove_switches:
497
+ del self.pulsing_switches[id]
498
+
499
+
500
+ def start_pulse(self, comp, pulse):
501
+ comp.send(True)
502
+ self.pulsing_switches[comp.id] = {
503
+ 'comp': comp, 'last_toggle': time.time(), 'value': True,
504
+ 'pulse': pulse
505
+ }
506
+
507
+
419
508
 
420
509
  class DummyGatewayHandler(BaseObjectCommandsGatewayHandler):
421
510
  name = "Dummy"
@@ -0,0 +1,33 @@
1
+ # Generated by Django 4.2.10 on 2025-04-09 14:04
2
+
3
+ from django.db import migrations
4
+
5
+ def forwards_func(apps, schema_editor):
6
+
7
+ Component = apps.get_model("core", "Component")
8
+
9
+ for thermostat in Component.objects.filter(base_type='thermostat'):
10
+ thermostat.config['heaters'] = []
11
+ heater = thermostat.config.pop('heater')
12
+ if heater:
13
+ thermostat.config['heaters'].append(heater)
14
+ thermostat.config['coolers'] = []
15
+ cooler = thermostat.config.pop('cooler')
16
+ if cooler:
17
+ thermostat.config['coolers'].append(cooler)
18
+ thermostat.save()
19
+
20
+
21
+ def reverse_func(apps, schema_editor):
22
+ pass
23
+
24
+
25
+ class Migration(migrations.Migration):
26
+
27
+ dependencies = [
28
+ ('generic', '0002_auto_20241126_0726'),
29
+ ]
30
+
31
+ operations = [
32
+ migrations.RunPython(forwards_func, reverse_func, elidable=True),
33
+ ]
Binary file
simo/users/api.py CHANGED
@@ -194,10 +194,6 @@ class UserDeviceReport(InstanceMixin, viewsets.GenericViewSet):
194
194
  user_device.users.add(request.user)
195
195
 
196
196
  log_datetime = timezone.now()
197
- # if request.data.get('timestamp'):
198
- # log_datetime = datetime.datetime.fromtimestamp(
199
- # int(float(request.GET['timestamp'])), pytz.utc
200
- # )
201
197
 
202
198
  relay = None
203
199
  if request.META.get('HTTP_HOST', '').endswith('.simo.io'):
@@ -208,22 +204,26 @@ class UserDeviceReport(InstanceMixin, viewsets.GenericViewSet):
208
204
  except:
209
205
  speed_kmh = 0
210
206
 
207
+ if speed_kmh < 0:
208
+ speed_kmh = 0
209
+
211
210
  avg_speed_kmh = 0
212
211
 
213
212
  if relay:
214
213
  location = request.data.get('location')
215
214
  if 'null' in location:
216
215
  location = None
217
- prev_log = UserDeviceReportLog.objects.filter(
216
+ sum = 0
217
+ no_of_points = 0
218
+ for speed in UserDeviceReportLog.objects.filter(
218
219
  user_device=user_device, instance=self.instance,
219
220
  datetime__lt=log_datetime - datetime.timedelta(seconds=3),
220
- datetime__gt=log_datetime - datetime.timedelta(seconds=10),
221
+ datetime__gt=log_datetime - datetime.timedelta(seconds=30),
221
222
  at_home=False, location__isnull=False
222
- ).last()
223
- if prev_log:
224
- meters_traveled = haversine_distance(location, prev_log.location)
225
- seconds_passed = (log_datetime - prev_log.datetime).total_seconds()
226
- avg_speed_kmh = round(meters_traveled / seconds_passed * 3.6, 0)
223
+ ).values('speed_kmh',):
224
+ sum += speed[0]
225
+ sum += speed_kmh
226
+ avg_speed_kmh = round(sum / (no_of_points + 1))
227
227
  else:
228
228
  location = self.instance.location
229
229
 
@@ -248,7 +248,30 @@ class UserDeviceReport(InstanceMixin, viewsets.GenericViewSet):
248
248
  self.instance.location, location
249
249
  ) < dynamic_settings['users__at_home_radius']
250
250
 
251
+ app_open = request.data.get('app_open', False)
252
+
253
+ if self.reject_location_report(
254
+ log_datetime, user_device, location, app_open, relay,
255
+ phone_on_charge, at_home, speed_kmh
256
+ ):
257
+ # We respond with success status, so that the device dos not try to
258
+ # report this data point again.
259
+ return RESTResponse({'status': 'success'})
260
+ # return RESTResponse(
261
+ # {'status': 'error', 'msg': 'Duplicate or Bad report!'},
262
+ # status=status.HTTP_400_BAD_REQUEST
263
+ # )
264
+
265
+ UserDeviceReportLog.objects.create(
266
+ user_device=user_device, instance=self.instance,
267
+ app_open=app_open,
268
+ location=location, datetime=log_datetime,
269
+ relay=relay, speed_kmh=speed_kmh, avg_speed_kmh=avg_speed_kmh,
270
+ phone_on_charge=phone_on_charge, at_home=at_home
271
+ )
272
+
251
273
  drop_current_instance()
274
+
252
275
  for iu in request.user.instance_roles.filter(is_active=True):
253
276
  if not relay:
254
277
  iu.at_home = True
@@ -260,19 +283,49 @@ class UserDeviceReport(InstanceMixin, viewsets.GenericViewSet):
260
283
  iu.last_seen = user_device.last_seen
261
284
  if location:
262
285
  iu.last_seen_location = location
263
- iu.last_seen_speed_kmh = speed_kmh
286
+ iu.last_seen_speed_kmh = avg_speed_kmh
264
287
  iu.phone_on_charge = phone_on_charge
265
288
  iu.save()
266
289
 
267
- UserDeviceReportLog.objects.create(
290
+ return RESTResponse({'status': 'success'})
291
+
292
+
293
+ def reject_location_report(
294
+ self, log_datetime, user_device, location, app_open, relay,
295
+ phone_on_charge, at_home, speed_kmh
296
+ ):
297
+ # Phone's App location repoorting is not always as reliable as we would like to
298
+ # therefore we filter out duplicate reports as they happen quiet often
299
+ # It has been observer that sometimes an app reports locations that are
300
+ # way from past, therefore locations might jump out of the usual pattern,
301
+ # so we try to filter out these anomalies to.
302
+ q = UserDeviceReportLog.objects.filter(
268
303
  user_device=user_device, instance=self.instance,
269
- app_open=request.data.get('app_open', False),
270
- location=location, datetime=log_datetime,
271
- relay=relay, speed_kmh=speed_kmh, avg_speed_kmh=avg_speed_kmh,
272
- phone_on_charge=phone_on_charge, at_home=at_home
304
+ app_open=app_open,
305
+ datetime__gt=log_datetime - datetime.timedelta(seconds=20),
306
+ relay=relay, phone_on_charge=phone_on_charge, at_home=at_home
273
307
  )
308
+ if location:
309
+ q = q.filter(location__isnull=False)
310
+ last_similar_report = q.last()
311
+
312
+ if not last_similar_report:
313
+ return False
314
+
315
+ if location == last_similar_report.location:
316
+ # This looks like 100% duplicate
317
+ return True
318
+
319
+ from simo.automation.helpers import haversine_distance
320
+ distance = haversine_distance(location, last_similar_report.location)
321
+ seconds_passed = (
322
+ log_datetime - last_similar_report.datetime
323
+ ).total_seconds()
324
+ if speed_kmh < 100 and distance / seconds_passed * 3.6 > 300:
325
+ return True
326
+
327
+ return False
274
328
 
275
- return RESTResponse({'status': 'success'})
276
329
 
277
330
 
278
331
  class InvitationsViewSet(InstanceMixin, viewsets.ModelViewSet):
@@ -12,7 +12,7 @@ users = Section('users')
12
12
  class AtHomeRadius(IntegerPreference):
13
13
  section = users
14
14
  name = 'at_home_radius'
15
- default = 250
15
+ default = 50
16
16
  required = True
17
17
  help_text = 'Distance in meters around hub location point that is ' \
18
18
  'considered as At Home.'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simo
3
- Version: 2.8.15
3
+ Version: 2.10.1
4
4
  Summary: Smart Home Supremacy
5
5
  Author-email: Simanas Venčkauskas <simanas@simo.io>
6
6
  Project-URL: Homepage, https://simo.io