simo 3.0.5__py3-none-any.whl → 3.1.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/generic/gateways.py CHANGED
@@ -7,9 +7,8 @@ import traceback
7
7
  from django.conf import settings
8
8
  from django.utils import timezone
9
9
  import paho.mqtt.client as mqtt
10
- from simo.core.utils.helpers import get_self_ip
11
- from simo.core.models import Component, PublicFile
12
- from simo.core.middleware import drop_current_instance
10
+ from simo.core.models import Instance, Component
11
+ from simo.core.middleware import introduce_instance, drop_current_instance
13
12
  from simo.core.gateways import BaseObjectCommandsGatewayHandler
14
13
  from simo.core.forms import BaseGatewayForm
15
14
  from simo.core.events import GatewayObjectCommand, get_event_obj
@@ -176,15 +175,18 @@ class GenericGatewayHandler(
176
175
  ):
177
176
  name = "Generic"
178
177
  config_form = BaseGatewayForm
178
+ auto_create = True
179
179
  info = "Provides generic type components which use other components to operate like " \
180
180
  "thermostats, alarm groups, watering programs, alarm clocks," \
181
181
  "etc. "
182
182
 
183
183
  running_scripts = {}
184
184
  periodic_tasks = (
185
+ ('watch_timers', 1),
185
186
  ('watch_thermostats', 60),
186
187
  ('watch_alarm_clocks', 30),
187
188
  ('watch_watering', 60),
189
+ ('low_battery_notifications', 60 * 60),
188
190
  ('watch_main_states', 60),
189
191
  ('watch_groups', 60)
190
192
  )
@@ -198,6 +200,21 @@ class GenericGatewayHandler(
198
200
  self.pulsing_switches = {}
199
201
 
200
202
 
203
+ def watch_timers(self):
204
+ from simo.core.models import Component
205
+ drop_current_instance()
206
+ for component in Component.objects.filter(
207
+ meta__timer_to__gt=0
208
+ ).filter(meta__timer_to__lt=time.time()):
209
+ component.meta['timer_to'] = 0
210
+ component.meta['timer_start'] = 0
211
+ component.save()
212
+ try:
213
+ component.controller._on_timer_end()
214
+ except Exception as e:
215
+ print(traceback.format_exc(), file=sys.stderr)
216
+
217
+
201
218
  def watch_thermostats(self):
202
219
  from .controllers import Thermostat
203
220
  drop_current_instance()
@@ -206,7 +223,7 @@ class GenericGatewayHandler(
206
223
  ):
207
224
  tz = pytz.timezone(thermostat.zone.instance.timezone)
208
225
  timezone.activate(tz)
209
- thermostat._evaluate()
226
+ thermostat.controller._evaluate()
210
227
 
211
228
 
212
229
  def watch_alarm_clocks(self):
@@ -217,7 +234,7 @@ class GenericGatewayHandler(
217
234
  ):
218
235
  tz = pytz.timezone(alarm_clock.zone.instance.timezone)
219
236
  timezone.activate(tz)
220
- alarm_clock._tick()
237
+ alarm_clock.controller._tick()
221
238
 
222
239
 
223
240
  def watch_watering(self):
@@ -227,12 +244,46 @@ class GenericGatewayHandler(
227
244
  tz = pytz.timezone(watering.zone.instance.timezone)
228
245
  timezone.activate(tz)
229
246
  if watering.value['status'] == 'running_program':
230
- watering._set_program_progress(
247
+ watering.controller._set_program_progress(
231
248
  watering.value['program_progress'] + 1
232
249
  )
233
250
  else:
234
251
  watering.controller._perform_schedule()
235
252
 
253
+ def low_battery_notifications(self):
254
+ from simo.notifications.utils import notify_users
255
+ from simo.automation.helpers import be_or_not_to_be
256
+ for instance in Instance.objects.filter(is_active=True):
257
+ timezone.activate(instance.timezone)
258
+ hour = timezone.localtime().hour
259
+ if hour < 7:
260
+ continue
261
+ if hour > 21:
262
+ continue
263
+
264
+ introduce_instance(instance)
265
+ for comp in Component.objects.filter(
266
+ zone__instance=instance,
267
+ battery_level__isnull=False, battery_level__lt=20
268
+ ):
269
+ last_warning = comp.meta.get('last_battery_warning', 0)
270
+ notify = be_or_not_to_be(12 * 60 * 60, 72 * 60 * 60,
271
+ last_warning)
272
+ if not notify:
273
+ continue
274
+
275
+ iusers = comp.zone.instance.instance_users.filter(
276
+ is_active=True, role__is_owner=True
277
+ )
278
+ if iusers:
279
+ notify_users(
280
+ 'warning',
281
+ f"Low battery ({comp.battery_level}%) on {comp}",
282
+ component=comp, instance_users=iusers
283
+ )
284
+ comp.meta['last_battery_warning'] = time.time()
285
+ comp.save()
286
+
236
287
 
237
288
  def run(self, exit):
238
289
  drop_current_instance()
@@ -340,7 +391,7 @@ class GenericGatewayHandler(
340
391
  def set_get_day_evening_night_morning(self, state):
341
392
  if state.value not in ('day', 'night', 'evening', 'morning'):
342
393
  return
343
- new_state = state._get_day_evening_night_morning()
394
+ new_state = state.controller._get_day_evening_night_morning()
344
395
  if new_state == state.value:
345
396
  self.last_set_state = state.value
346
397
  return
@@ -377,7 +428,7 @@ class GenericGatewayHandler(
377
428
  self.sensors_on_watch[state.id][sensor.id] = i_id
378
429
  sensor.on_change(self.security_sensor_change)
379
430
 
380
- if state._check_is_away(self.last_sensor_actions.get(i_id, 0)):
431
+ if state.controller._check_is_away(self.last_sensor_actions.get(i_id, 0)):
381
432
  if state.value != 'away':
382
433
  print(f"New main state of "
383
434
  f"{state.zone.instance} - away")
@@ -385,7 +436,7 @@ class GenericGatewayHandler(
385
436
  else:
386
437
  if state.value == 'away':
387
438
  try:
388
- new_state = state._get_day_evening_night_morning()
439
+ new_state = state.controller._get_day_evening_night_morning()
389
440
  except:
390
441
  new_state = 'day'
391
442
  print(f"New main state of "
@@ -394,14 +445,14 @@ class GenericGatewayHandler(
394
445
 
395
446
  if state.config.get('sleeping_phones_hour') is not None:
396
447
  if state.value != 'sleep':
397
- if state._is_sleep_time() and state._owner_phones_on_charge(True):
448
+ if state.controller._is_sleep_time() and state.controller._owner_phones_on_charge(True):
398
449
  print(f"New main state of {state.zone.instance} - sleep")
399
450
  state.send('sleep')
400
451
  else:
401
- if not state._owner_phones_on_charge(True) \
402
- and not state._is_sleep_time():
452
+ if not state.controller._owner_phones_on_charge(True) \
453
+ and not state.controller._is_sleep_time():
403
454
  try:
404
- new_state = state._get_day_evening_night_morning()
455
+ new_state = state.controller._get_day_evening_night_morning()
405
456
  except:
406
457
  new_state = 'day'
407
458
  print(f"New main state of "
@@ -509,9 +560,9 @@ class GenericGatewayHandler(
509
560
  class DummyGatewayHandler(BaseObjectCommandsGatewayHandler):
510
561
  name = "Dummy"
511
562
  config_form = BaseGatewayForm
563
+ auto_create = True
512
564
  info = "Provides dummy components that do absolutely anything, " \
513
565
  "but comes in super handy when configuring custom automations."
514
566
 
515
567
  def perform_value_send(self, component, value):
516
568
  component.controller.set(value)
517
-
simo/generic/tasks.py CHANGED
@@ -56,25 +56,4 @@ def fire_breach_events(ag_id):
56
56
  ag.meta['events_triggered'] = [uid]
57
57
  else:
58
58
  ag.meta['events_triggered'].append(uid)
59
- ag.save(update_fields=['meta'])
60
-
61
-
62
- @celery_app.task
63
- def watch_timers():
64
- from simo.core.models import Component
65
- drop_current_instance()
66
- for component in Component.objects.filter(
67
- meta__timer_to__gt=0
68
- ).filter(meta__timer_to__lt=time.time()):
69
- component.meta['timer_to'] = 0
70
- component.meta['timer_start'] = 0
71
- component.save()
72
- try:
73
- component.controller._on_timer_end()
74
- except Exception as e:
75
- print(traceback.format_exc(), file=sys.stderr)
76
-
77
-
78
- @celery_app.on_after_finalize.connect
79
- def setup_periodic_tasks(sender, **kwargs):
80
- sender.add_periodic_task(1, watch_timers.s())
59
+ ag.save(update_fields=['meta'])
simo/settings.py CHANGED
@@ -133,6 +133,14 @@ DATABASES = {
133
133
  'ENGINE': 'simo.core.db_backend',
134
134
  'NAME': 'SIMO',
135
135
  'ATOMIC_REQUESTS': False,
136
+ 'CONN_HEALTH_CHECKS': True,
137
+ 'CONN_MAX_AGE': 300,
138
+ 'OPTIONS': {
139
+ "keepalives": 1,
140
+ "keepalives_idle": 30,
141
+ "keepalives_interval": 10,
142
+ "keepalives_count": 3,
143
+ }
136
144
  }
137
145
  }
138
146
 
simo/urls.py CHANGED
@@ -45,7 +45,8 @@ urlpatterns = [
45
45
  path('logout/', LogoutView.as_view(), name='logout'),
46
46
  path('admin/', admin_site.urls),
47
47
  path('api-hub-info/', hub_info),
48
- path('api/<instance_slug>/', include(rest_router.urls)),
48
+ # REST API (instance-scoped)
49
+ path('api/<slug:instance_slug>/', include(rest_router.urls)),
49
50
 
50
51
  ]
51
52
 
simo/users/api.py CHANGED
@@ -127,9 +127,16 @@ class UsersViewSet(mixins.RetrieveModelMixin,
127
127
  raise ValidationError(
128
128
  'You do not have permission for this!', code=403
129
129
  )
130
- self.perform_destroy(user)
130
+ if InstanceUser.objects.filter(user=user, is_active=True).count() > 1:
131
+ InstanceUser.objects.filter(
132
+ user=user, instance=self.instance
133
+ ).delete()
134
+ else:
135
+ user.delete()
131
136
  return RESTResponse(status=status.HTTP_204_NO_CONTENT)
132
137
 
138
+ # (moved to dedicated view at /users/mqtt-credentials/)
139
+
133
140
 
134
141
  class RolesViewsets(InstanceMixin, viewsets.ReadOnlyModelViewSet):
135
142
  url = 'users/roles'
@@ -406,4 +413,3 @@ class FingerprintViewSet(
406
413
  def destroy(self, request, *args, **kwargs):
407
414
  self.check_can_manage_user(request)
408
415
  return super().destroy(request, *args, **kwargs)
409
-
simo/users/auto_urls.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from django.urls import re_path, path
2
- from .views import accept_invitation, RolesAutocomplete
2
+ from .views import accept_invitation, RolesAutocomplete, mqtt_credentials
3
3
 
4
4
  urlpatterns = [
5
5
  re_path(
@@ -10,4 +10,5 @@ urlpatterns = [
10
10
  'autocomplete-roles',
11
11
  RolesAutocomplete.as_view(), name='autocomplete-user-roles'
12
12
  ),
13
+ path('mqtt-credentials/', mqtt_credentials, name='mqtt-credentials'),
13
14
  ]
simo/users/models.py CHANGED
@@ -1,4 +1,6 @@
1
1
  import datetime
2
+ import time
3
+ import json
2
4
  import requests
3
5
  import subprocess
4
6
  from django.urls import reverse
@@ -16,6 +18,7 @@ from actstream import action
16
18
  from django.contrib.auth.models import (
17
19
  AbstractBaseUser, PermissionsMixin, UserManager as DefaultUserManager
18
20
  )
21
+ from django.conf import settings
19
22
  from django.utils import timezone
20
23
  from easy_thumbnails.fields import ThumbnailerImageField
21
24
  from location_field.models.plain import PlainLocationField
@@ -115,7 +118,9 @@ class InstanceUser(DirtyFieldsMixin, models.Model, OnChangeMixin):
115
118
  objects = ActiveInstanceManager()
116
119
 
117
120
  on_change_fields = (
118
- 'is_active', 'last_seen', 'last_seen_location', 'phone_on_charge'
121
+ 'is_active', 'role', 'at_home',
122
+ 'last_seen', 'last_seen_location', 'last_seen_speed_kmh',
123
+ 'phone_on_charge'
119
124
  )
120
125
 
121
126
  class Meta:
@@ -158,13 +163,11 @@ class InstanceUser(DirtyFieldsMixin, models.Model, OnChangeMixin):
158
163
 
159
164
  @receiver(post_save, sender=InstanceUser)
160
165
  def post_instance_user_save(sender, instance, created, **kwargs):
161
- if created:
162
- return
163
- from simo.core.events import ObjectChangeEvent
164
- dirty_fields = instance.get_dirty_fields()
165
- if any([f in dirty_fields.keys() for f in InstanceUser.on_change_fields]):
166
+ from simo.core.events import ObjectChangeEvent, dirty_fields_to_current_values
167
+ dirty_fields_prev = instance.get_dirty_fields()
168
+ if not created and any([f in dirty_fields_prev.keys() for f in InstanceUser.on_change_fields]):
166
169
  def post_update():
167
- if 'at_home' in dirty_fields:
170
+ if 'at_home' in dirty_fields_prev:
168
171
  if instance.at_home:
169
172
  verb = 'came home'
170
173
  else:
@@ -174,12 +177,57 @@ def post_instance_user_save(sender, instance, created, **kwargs):
174
177
  instance_id=instance.instance.id,
175
178
  action_type='user_presence', value=instance.at_home
176
179
  )
180
+ # Include key fields so clients can update UI without a refetch
177
181
  ObjectChangeEvent(
178
- instance.instance, instance, dirty_fields=dirty_fields
182
+ instance.instance,
183
+ instance,
184
+ dirty_fields=dirty_fields_to_current_values(instance, dirty_fields_prev),
185
+ at_home=instance.at_home,
186
+ last_seen=instance.last_seen,
187
+ last_seen_location=instance.last_seen_location,
188
+ last_seen_speed_kmh=instance.last_seen_speed_kmh,
189
+ phone_on_charge=instance.phone_on_charge,
190
+ is_active=instance.is_active,
191
+ role=instance.role_id,
179
192
  ).publish()
193
+ # If role changed, notify the affected user to re-fetch states
194
+ if 'role' in dirty_fields_prev:
195
+ from paho.mqtt import publish as mqtt_publish
196
+ topic = f"SIMO/user/{instance.user.id}/perms-changed"
197
+ payload = json.dumps({
198
+ 'instance_id': instance.instance.id,
199
+ 'timestamp': int(time.time())
200
+ })
201
+ try:
202
+ mqtt_publish.single(
203
+ topic, payload,
204
+ hostname=settings.MQTT_HOST, port=settings.MQTT_PORT,
205
+ auth={'username': 'root', 'password': settings.SECRET_KEY},
206
+ retain=False
207
+ )
208
+ except Exception:
209
+ pass
210
+ # Invalidate cached role lookups
211
+ try:
212
+ cache.delete(f'user-{instance.user.id}_instance-{instance.instance.id}-role-id')
213
+ cache.delete(f'user-{instance.user.id}_instance-{instance.instance.id}_role')
214
+ except Exception:
215
+ pass
180
216
  transaction.on_commit(post_update)
181
- if 'role' or 'is_active' in instance.dirty_fields:
217
+ # Rebuild ACLs if user became active/inactive due to this role change
218
+ try:
219
+ if created or ('is_active' in dirty_fields_prev):
220
+ dynamic_settings['core__needs_mqtt_acls_rebuild'] = True
221
+ except Exception:
222
+ pass
223
+
224
+ @receiver(post_delete, sender=InstanceUser)
225
+ def post_instance_user_delete(sender, instance, **kwargs):
226
+ # Deleting role entry may change user's overall is_active; rebuild ACLs
227
+ try:
182
228
  dynamic_settings['core__needs_mqtt_acls_rebuild'] = True
229
+ except Exception:
230
+ pass
183
231
 
184
232
 
185
233
  # DirtyFieldsMixin does not work with AbstractBaseUser model!!!
@@ -256,6 +304,14 @@ class User(AbstractBaseUser, SimoAdminMixin):
256
304
  if not org or (org.secret_key != self.secret_key):
257
305
  self.update_mqtt_secret()
258
306
 
307
+ # Rebuild ACLs when a user is created or properties affecting ACLs change
308
+ # (username/email used by Mosquitto + master flag)
309
+ try:
310
+ if (not org) or (org.email != self.email) or (org.is_master != self.is_master):
311
+ dynamic_settings['core__needs_mqtt_acls_rebuild'] = True
312
+ except Exception:
313
+ pass
314
+
259
315
  return obj
260
316
 
261
317
  def update_mqtt_secret(self, reload=True):
@@ -408,6 +464,12 @@ class User(AbstractBaseUser, SimoAdminMixin):
408
464
 
409
465
  rebuild_authorized_keys()
410
466
 
467
+ # Reflect access changes in Mosquitto ACLs
468
+ try:
469
+ dynamic_settings['core__needs_mqtt_acls_rebuild'] = True
470
+ except Exception:
471
+ pass
472
+
411
473
  self._is_active = None
412
474
 
413
475
 
@@ -507,8 +569,28 @@ class ComponentPermission(models.Model):
507
569
 
508
570
  @receiver(post_save, sender=ComponentPermission)
509
571
  def rebuild_mqtt_acls_on_create(sender, instance, created, **kwargs):
510
- if not created:
511
- dynamic_settings['core__needs_mqtt_acls_rebuild'] = True
572
+ # ACLs are per-user prefix; permission changes don't require ACL rebuilds.
573
+
574
+ # Notify affected users to re-sync their subscriptions
575
+ def _notify():
576
+ from paho.mqtt import publish as mqtt_publish
577
+ role = instance.role
578
+ for iu in role.instance.instance_users.filter(role=role, is_active=True).select_related('user'):
579
+ topic = f"SIMO/user/{iu.user.id}/perms-changed"
580
+ payload = json.dumps({
581
+ 'instance_id': role.instance.id,
582
+ 'timestamp': int(time.time())
583
+ })
584
+ try:
585
+ mqtt_publish.single(
586
+ topic, payload,
587
+ hostname=settings.MQTT_HOST, port=settings.MQTT_PORT,
588
+ auth={'username': 'root', 'password': settings.SECRET_KEY},
589
+ retain=False
590
+ )
591
+ except Exception:
592
+ pass
593
+ transaction.on_commit(_notify)
512
594
 
513
595
 
514
596
 
@@ -524,7 +606,7 @@ def create_component_permissions_comp(sender, instance, created, **kwargs):
524
606
  'write': role.is_superuser or role.is_owner
525
607
  }
526
608
  )
527
- dynamic_settings['core__needs_mqtt_acls_rebuild'] = True
609
+ # ACLs are per-user prefix; component additions don't require ACL rebuilds.
528
610
 
529
611
 
530
612
  @receiver(post_save, sender=PermissionsRole)
@@ -541,6 +623,35 @@ def create_component_permissions_role(sender, instance, created, **kwargs):
541
623
  }
542
624
  )
543
625
 
626
+ # Permissions topology changed; notify users on this role
627
+ def _notify():
628
+ from paho.mqtt import publish as mqtt_publish
629
+ for iu in instance.instance.instance_users.filter(role=instance, is_active=True).select_related('user'):
630
+ topic = f"SIMO/user/{iu.user.id}/perms-changed"
631
+ payload = json.dumps({
632
+ 'instance_id': instance.instance.id,
633
+ 'timestamp': int(time.time())
634
+ })
635
+ try:
636
+ mqtt_publish.single(
637
+ topic, payload,
638
+ hostname=settings.MQTT_HOST, port=settings.MQTT_PORT,
639
+ auth={'username': 'root', 'password': settings.SECRET_KEY},
640
+ retain=False
641
+ )
642
+ except Exception:
643
+ pass
644
+ transaction.on_commit(_notify)
645
+
646
+
647
+ @receiver(post_delete, sender=User)
648
+ def rebuild_mqtt_acls_on_user_delete(sender, instance, **kwargs):
649
+ # Remove ACL stanza for deleted user
650
+ try:
651
+ dynamic_settings['core__needs_mqtt_acls_rebuild'] = True
652
+ except Exception:
653
+ pass
654
+
544
655
 
545
656
  def get_default_inviation_expire_date():
546
657
  return timezone.now() + datetime.timedelta(days=14)
@@ -607,5 +718,3 @@ class InstanceInvitation(models.Model):
607
718
 
608
719
  def get_absolute_url(self):
609
720
  return reverse('accept_invitation', kwargs={'token': self.token})
610
-
611
-
simo/users/serializers.py CHANGED
@@ -32,7 +32,10 @@ class UserSerializer(serializers.ModelSerializer):
32
32
  iu = InstanceUser.objects.filter(
33
33
  user=obj, instance=get_current_instance()
34
34
  ).first()
35
- return iu.is_active
35
+ try:
36
+ return iu.is_active
37
+ except:
38
+ return False
36
39
 
37
40
  def get_avatar(self, obj):
38
41
  if not obj.avatar:
@@ -2,18 +2,18 @@ user root
2
2
  topic readwrite #
3
3
 
4
4
  {% for user in users %}
5
+ {% if user.email == 'system@simo.io' %}
5
6
  user {{ user.email }}
6
- {% if user.email == 'system@simo.io' or user.is_master %}
7
7
  topic readwrite #
8
- {% elif user.email == 'device@simo.io' %}
8
+ {% elif user.is_active %}
9
+ user {{ user.email }}
10
+ {% if user.is_master %}
9
11
  topic readwrite #
10
- topic deny SIMO/#
11
12
  {% else %}
12
- {% for perm in user.get_component_permissions %}
13
- topic read SIMO/obj-state/{{ perm.component.zone.instance.id }}/Component-{{ perm.component.id }}
14
- {% endfor %}
13
+ topic readwrite SIMO/user/{{ user.id }}/#
15
14
  {% endif %}
15
+ {% endif %}
16
16
  {% endfor %}
17
17
 
18
18
  # This affects all clients.
19
- pattern deny #
19
+ pattern deny #
simo/users/views.py CHANGED
@@ -125,3 +125,15 @@ class RolesAutocomplete(autocomplete.Select2QuerySetView):
125
125
  return qs.distinct()
126
126
 
127
127
 
128
+ @login_required
129
+ def mqtt_credentials(request):
130
+ """Return MQTT credentials for the authenticated user.
131
+ Response payload:
132
+ - username: user's email
133
+ - password: user's MQTT secret
134
+ """
135
+ return JsonResponse({
136
+ 'username': request.user.email,
137
+ 'password': request.user.secret_key,
138
+ 'user_id': request.user.id
139
+ })
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simo
3
- Version: 3.0.5
3
+ Version: 3.1.1
4
4
  Summary: Smart Home Supremacy
5
5
  Author-email: "Simon V." <simon@simo.io>
6
6
  Project-URL: Homepage, https://simo.io