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

@@ -216,6 +216,7 @@ class GatesHandler:
216
216
  class AutomationsGatewayHandler(GatesHandler, BaseObjectCommandsGatewayHandler):
217
217
  name = "Automation"
218
218
  config_form = BaseGatewayForm
219
+ auto_create = True
219
220
  info = "Provides various types of automation capabilities"
220
221
 
221
222
  running_scripts = {}
simo/core/api.py CHANGED
@@ -140,7 +140,8 @@ class ZoneViewSet(InstanceMixin, viewsets.ModelViewSet):
140
140
  )
141
141
  for i, id in enumerate(request_data.get('zones')):
142
142
  zones[str(id)].order = i
143
- Zone.objects.bulk_update([z for id, z in zones.items()], fields=['order'])
143
+ for i, zone in zones.items():
144
+ zone.save()
144
145
  return RESTResponse({'status': 'success'})
145
146
 
146
147
 
simo/core/events.py CHANGED
@@ -52,8 +52,8 @@ class ObjectChangeEvent(ObjMqttAnnouncement):
52
52
  self.data.update(**kwargs)
53
53
 
54
54
  def get_topic(self):
55
- return f"{self.TOPIC}/{self.instance.id if self.instance else 'global'}/" \
56
- f"{type(self.obj).__name__}-{self.data['obj_pk']}"
55
+ return f"{self.TOPIC}/{self.instance.uid if self.instance else 'global'}/" \
56
+ f"{type(self.obj).__name__}/{self.data['obj_pk']}"
57
57
 
58
58
  def publish(self, retain=True):
59
59
  return super().publish(retain=retain)
@@ -91,6 +91,33 @@ def get_event_obj(payload, model_class=None, gateway=None):
91
91
  return obj
92
92
 
93
93
 
94
+ def dirty_fields_to_current_values(instance, dirty_fields):
95
+ """Return a mapping of changed field names to the instance's current values.
96
+
97
+ - Avoids extra DB hits by reading values from the in-memory instance.
98
+ - For ForeignKey fields, emits the underlying ``<field>_id`` when available
99
+ to keep the payload JSON-serializable and consistent.
100
+ """
101
+ if not dirty_fields:
102
+ return {}
103
+
104
+ current = {}
105
+ for field_name in dirty_fields.keys():
106
+ try:
107
+ model_field = instance._meta.get_field(field_name)
108
+ # Many-to-many changes are not handled here (not part of default dirty_fields)
109
+ if getattr(model_field, 'many_to_many', False):
110
+ continue
111
+ if getattr(model_field, 'is_relation', False) and hasattr(instance, f"{field_name}_id"):
112
+ current[field_name] = getattr(instance, f"{field_name}_id")
113
+ else:
114
+ current[field_name] = getattr(instance, field_name, None)
115
+ except Exception:
116
+ # Field might not be a real model field (unlikely); fallback to attribute
117
+ current[field_name] = getattr(instance, field_name, None)
118
+ return current
119
+
120
+
94
121
  class OnChangeMixin:
95
122
 
96
123
  _on_change_function = None
@@ -164,4 +191,4 @@ class OnChangeMixin:
164
191
  self._mqtt_client.disconnect()
165
192
  self._mqtt_client.loop_stop()
166
193
  self._mqtt_client = None
167
- self._on_change_function = None
194
+ self._on_change_function = None
simo/core/gateways.py CHANGED
@@ -14,6 +14,10 @@ from simo.core.loggers import get_gw_logger
14
14
 
15
15
 
16
16
  class BaseGatewayHandler(ABC):
17
+ # Whether core should auto-create a Gateway row for this handler
18
+ # at hub startup (via on_http_start). Opt-in to avoid noisy defaults
19
+ # and side effects for handlers that require credentials/hardware.
20
+ auto_create = False
17
21
 
18
22
  @property
19
23
  @abstractmethod
@@ -134,4 +138,3 @@ class BaseObjectCommandsGatewayHandler(BaseGatewayHandler):
134
138
  except Exception as e:
135
139
  self.logger.error(e, exc_info=True)
136
140
 
137
-
@@ -63,6 +63,17 @@ server {
63
63
  proxy_send_timeout 3600s;
64
64
  }
65
65
 
66
+ # MQTT over WebSockets (WSS)
67
+ location ^~ /mqtt/ {
68
+ proxy_http_version 1.1;
69
+ proxy_set_header Upgrade $http_upgrade;
70
+ proxy_set_header Connection "Upgrade";
71
+ proxy_set_header Host $host;
72
+ proxy_read_timeout 600s;
73
+ proxy_send_timeout 600s;
74
+ proxy_pass http://127.0.0.1:8083/;
75
+ }
76
+
66
77
 
67
78
  location / {
68
79
  include proxy_params;
@@ -78,6 +78,37 @@ killasgroup=true
78
78
  stopsignal=INT
79
79
 
80
80
 
81
+ [program:simo-app-mqtt]
82
+ directory={{ project_dir }}/hub/
83
+ command={{ venv_path }}/python manage.py run_app_mqtt_control
84
+ process_name=%(program_name)s
85
+ user=root
86
+ stdout_logfile=/var/log/simo/app_mqtt.log
87
+ stdout_logfile_maxbytes=1MB
88
+ stdout_logfile_backups=3
89
+ redirect_stderr=true
90
+ autostart=true
91
+ autorestart=true
92
+ stopwaitsecs=15
93
+ killasgroup=true
94
+ stopsignal=INT
95
+
96
+ [program:simo-app-mqtt-fanout]
97
+ directory={{ project_dir }}/hub/
98
+ command={{ venv_path }}/python manage.py run_app_mqtt_fanout
99
+ process_name=%(program_name)s
100
+ user=root
101
+ stdout_logfile=/var/log/simo/app_mqtt_fanout.log
102
+ stdout_logfile_maxbytes=1MB
103
+ stdout_logfile_backups=3
104
+ redirect_stderr=true
105
+ autostart=true
106
+ autorestart=true
107
+ stopwaitsecs=15
108
+ killasgroup=true
109
+ stopsignal=INT
110
+
111
+
81
112
  [program:simo-celery-beat]
82
113
  directory={{ project_dir }}/hub/
83
114
  command={{ venv_path }}/celery -A celeryc.celery_app beat -l info --pidfile="/var/run/celerybeat.pid"
@@ -10,6 +10,7 @@ from django.apps import apps
10
10
  from crontab import CronTab
11
11
  from django.conf import settings
12
12
  from django.template.loader import render_to_string
13
+ from django.db import transaction
13
14
 
14
15
 
15
16
  def prepare_mosquitto():
@@ -84,6 +85,70 @@ class Command(BaseCommand):
84
85
  from simo.core.tasks import maybe_update_to_latest
85
86
  maybe_update_to_latest.delay()
86
87
  update_auto_update()
88
+ # Auto-create Gateway rows for handlers that opted in
89
+ try:
90
+ from simo.core.models import Gateway
91
+ from simo.core.utils.type_constants import GATEWAYS_MAP
92
+ created_any = False
93
+ for uid, handler_cls in GATEWAYS_MAP.items():
94
+ if getattr(handler_cls, 'auto_create', False):
95
+ # Build default config from handler's config_form
96
+ config_defaults = {}
97
+ try:
98
+ form_cls = getattr(handler_cls, 'config_form', None)
99
+ if form_cls is not None:
100
+ tmp_instance = Gateway(type=uid)
101
+ form = form_cls(instance=tmp_instance)
102
+ for fname, field in getattr(form, 'fields', {}).items():
103
+ if fname in ('log',):
104
+ continue
105
+ init = getattr(field, 'initial', None)
106
+ if init is None:
107
+ continue
108
+ # Normalize potential Model/QuerySet values to pks
109
+ try:
110
+ import json as _json
111
+ def _norm(v):
112
+ try:
113
+ return _json.loads(_json.dumps(v))
114
+ except Exception:
115
+ pass
116
+ # Model instance
117
+ if hasattr(v, 'pk'):
118
+ return v.pk
119
+ # QuerySet or iterable of models
120
+ try:
121
+ from django.db.models.query import QuerySet as _QS
122
+ if isinstance(v, _QS):
123
+ return [obj.pk for obj in v]
124
+ except Exception:
125
+ pass
126
+ if isinstance(v, (list, tuple, set)):
127
+ return [_norm(x) for x in v]
128
+ return v
129
+ config_defaults[fname] = _norm(init)
130
+ except Exception:
131
+ # Best-effort; skip if cannot serialize
132
+ continue
133
+ except Exception:
134
+ # If we cannot introspect defaults, fall back to empty config
135
+ pass
136
+
137
+ obj, created = Gateway.objects.get_or_create(
138
+ type=uid, defaults={'config': config_defaults}
139
+ )
140
+ if created:
141
+ created_any = True
142
+ print(f"Auto-created gateway: {handler_cls.name} ({uid})")
143
+ try:
144
+ obj.start()
145
+ except Exception:
146
+ print(traceback.format_exc(), file=sys.stderr)
147
+ if created_any:
148
+ pass
149
+ except Exception:
150
+ # Do not fail startup on gateway auto-create issues
151
+ print(traceback.format_exc(), file=sys.stderr)
87
152
  for name, app in apps.app_configs.items():
88
153
  if name in (
89
154
  'auth', 'admin', 'contenttypes', 'sessions', 'messages',
@@ -96,4 +161,3 @@ class Command(BaseCommand):
96
161
  continue
97
162
  except:
98
163
  print(traceback.format_exc(), file=sys.stderr)
99
-
@@ -0,0 +1,60 @@
1
+ from django.core.management.base import BaseCommand
2
+ from django.utils import timezone
3
+
4
+ from simo.core.events import ObjectChangeEvent
5
+ from simo.core.models import Instance, Zone, Category, Component
6
+ from simo.users.models import InstanceUser
7
+
8
+
9
+ class Command(BaseCommand):
10
+ help = "Republish retained MQTT state for zones, categories, components, and instance users."
11
+
12
+ def add_arguments(self, parser):
13
+ parser.add_argument('--instance', type=int, help='Instance ID to republish (default: all)')
14
+
15
+ def handle(self, *args, **options):
16
+ instance_id = options.get('instance')
17
+ instances = Instance.objects.filter(is_active=True)
18
+ if instance_id:
19
+ instances = instances.filter(id=instance_id)
20
+
21
+ count = 0
22
+ for inst in instances:
23
+ # Zones
24
+ for zone in Zone.objects.filter(instance=inst):
25
+ ObjectChangeEvent(inst, zone, name=zone.name).publish()
26
+ count += 1
27
+
28
+ # Categories
29
+ for cat in Category.objects.filter(instance=inst):
30
+ ObjectChangeEvent(
31
+ inst, cat, name=cat.name, last_modified=cat.last_modified
32
+ ).publish()
33
+ count += 1
34
+
35
+ # Components
36
+ for comp in Component.objects.filter(zone__instance=inst):
37
+ data = {
38
+ 'value': comp.value,
39
+ 'last_change': comp.last_change,
40
+ 'arm_status': comp.arm_status,
41
+ 'battery_level': comp.battery_level,
42
+ 'alive': comp.alive,
43
+ 'meta': comp.meta,
44
+ }
45
+ ObjectChangeEvent(inst, comp, **data).publish()
46
+ count += 1
47
+
48
+ # Instance users (presence and phone charging)
49
+ for iu in InstanceUser.objects.filter(instance=inst, is_active=True):
50
+ ObjectChangeEvent(
51
+ inst,
52
+ iu,
53
+ at_home=iu.at_home,
54
+ last_seen=iu.last_seen,
55
+ phone_on_charge=iu.phone_on_charge,
56
+ ).publish()
57
+ count += 1
58
+
59
+ self.stdout.write(self.style.SUCCESS(f"Republished {count} retained messages."))
60
+
@@ -0,0 +1,129 @@
1
+ import json
2
+ import sys
3
+ import traceback
4
+ import paho.mqtt.client as mqtt
5
+ from django.core.management.base import BaseCommand
6
+ from django.conf import settings
7
+
8
+ from simo.users.models import User, InstanceUser, ComponentPermission
9
+ from simo.users.utils import introduce_user
10
+ from simo.core.models import Component
11
+
12
+
13
+ CONTROL_PREFIX = 'SIMO/user'
14
+
15
+
16
+ class Command(BaseCommand):
17
+ help = 'Run MQTT control bridge to execute component controller methods from app MQTT requests.'
18
+
19
+ def handle(self, *args, **options):
20
+ client = mqtt.Client()
21
+ client.username_pw_set('root', settings.SECRET_KEY)
22
+ client.on_connect = self.on_connect
23
+ client.on_message = self.on_message
24
+ client.connect(host=settings.MQTT_HOST, port=settings.MQTT_PORT)
25
+ try:
26
+ while True:
27
+ client.loop()
28
+ finally:
29
+ try:
30
+ client.disconnect()
31
+ except Exception:
32
+ pass
33
+
34
+ def on_connect(self, client, userdata, flags, rc):
35
+ # SIMO/user/+/control/#
36
+ client.subscribe(f'{CONTROL_PREFIX}/+/control/#')
37
+
38
+ def on_message(self, client, userdata, msg):
39
+ try:
40
+ parts = msg.topic.split('/')
41
+ # SIMO/user/<user-id>/control/<instance-uid>/Component/<component-id>
42
+ if len(parts) < 7 or parts[0] != 'SIMO' or parts[1] != 'user' or parts[3] != 'control':
43
+ return
44
+ user_id = int(parts[2])
45
+ instance_uid = parts[4]
46
+ if parts[5] != 'Component':
47
+ return
48
+ try:
49
+ component_id = int(parts[6])
50
+ except Exception:
51
+ return
52
+
53
+ # Resolve user and permission
54
+ user = User.objects.filter(id=user_id).first()
55
+ if not user or not user.is_active:
56
+ return
57
+ if not user.is_master:
58
+ # Must be active on instance
59
+ if not InstanceUser.objects.filter(
60
+ user=user, instance__uid=instance_uid, is_active=True
61
+ ).exists():
62
+ return
63
+ # Must have write permission on the component
64
+ has_write = ComponentPermission.objects.filter(
65
+ role__in=user.roles.all(),
66
+ component_id=component_id,
67
+ component__zone__instance__uid=instance_uid,
68
+ write=True,
69
+ ).exists()
70
+ if not has_write:
71
+ return
72
+
73
+ # Execute controller method
74
+ component = Component.objects.filter(
75
+ id=component_id, zone__instance__uid=instance_uid
76
+ ).first()
77
+ if not component:
78
+ return
79
+
80
+ introduce_user(user)
81
+ payload = json.loads(msg.payload or '{}')
82
+ request_id = payload.get('request_id')
83
+ sub_id = payload.get('subcomponent_id')
84
+ method = payload.get('method')
85
+ args = payload.get('args', [])
86
+ kwargs = payload.get('kwargs', {})
87
+ if method in (None, 'id', 'secret') or str(method).startswith('_'):
88
+ return
89
+
90
+ # Choose target component (main or subcomponent)
91
+ target = component
92
+ if sub_id:
93
+ try:
94
+ target = component.slaves.get(pk=sub_id)
95
+ except Exception:
96
+ return
97
+
98
+ # Prepare controller and call
99
+ target.prepare_controller()
100
+ if not hasattr(target, method):
101
+ self.respond(client, user_id, request_id, ok=False, error=f'Method {method} not found')
102
+ return
103
+ call = getattr(target, method)
104
+ try:
105
+ if isinstance(args, list) and isinstance(kwargs, dict):
106
+ result = call(*args, **kwargs)
107
+ elif isinstance(args, list):
108
+ result = call(*args)
109
+ elif isinstance(kwargs, dict):
110
+ result = call(**kwargs)
111
+ else:
112
+ result = call()
113
+ self.respond(client, user_id, request_id, ok=True, result=result)
114
+ except Exception:
115
+ self.respond(client, user_id, request_id, ok=False, error=''.join(traceback.format_exception(*sys.exc_info())))
116
+ except Exception:
117
+ # Never crash the consumer
118
+ pass
119
+
120
+ def respond(self, client, user_id, request_id, ok=True, result=None, error=None):
121
+ if not request_id:
122
+ return
123
+ topic = f'{CONTROL_PREFIX}/{user_id}/control-resp/{request_id}'
124
+ payload = {'ok': ok}
125
+ if ok:
126
+ payload['result'] = result
127
+ else:
128
+ payload['error'] = error
129
+ client.publish(topic, json.dumps(payload), qos=0, retain=False)
@@ -0,0 +1,96 @@
1
+ import json
2
+ import sys
3
+ import traceback
4
+ import paho.mqtt.client as mqtt
5
+ from django.core.management.base import BaseCommand
6
+ from django.conf import settings
7
+ from simo.core.events import get_event_obj
8
+ from simo.core.models import Component, Zone, Category
9
+ from simo.users.models import User, InstanceUser, ComponentPermission
10
+
11
+
12
+ OBJ_STATE_PREFIX = 'SIMO/obj-state'
13
+ FEED_PREFIX = 'SIMO/user'
14
+
15
+
16
+ class Command(BaseCommand):
17
+ help = 'Authorizing fanout for app feeds: replicate internal obj-state to per-user feed topics.'
18
+
19
+ def handle(self, *args, **options):
20
+ self.client = mqtt.Client()
21
+ self.client.username_pw_set('root', settings.SECRET_KEY)
22
+ self.client.on_connect = self.on_connect
23
+ self.client.on_message = self.on_message
24
+ self.client.connect(host=settings.MQTT_HOST, port=settings.MQTT_PORT)
25
+ try:
26
+ while True:
27
+ self.client.loop()
28
+ finally:
29
+ try:
30
+ self.client.disconnect()
31
+ except Exception:
32
+ pass
33
+
34
+ def on_connect(self, client, userdata, flags, rc):
35
+ client.subscribe(f'{OBJ_STATE_PREFIX}/#')
36
+
37
+ def on_message(self, client, userdata, msg):
38
+ try:
39
+ topic_parts = msg.topic.split('/')
40
+ # SIMO/obj-state/<instance-uid>/<Model>/<id>
41
+ if len(topic_parts) < 5 or topic_parts[0] != 'SIMO' or topic_parts[1] != 'obj-state':
42
+ return
43
+ instance_uid = topic_parts[2]
44
+ model_name = topic_parts[3]
45
+ obj_id = topic_parts[4]
46
+
47
+ # Only forward instance-scoped objects that the app cares about
48
+ payload = json.loads(msg.payload or '{}')
49
+
50
+ # Resolve object if needed (mainly for Components to do permission checks)
51
+ target_obj = None
52
+ if model_name == 'Component':
53
+ target_obj = get_event_obj(payload, model_class=Component)
54
+ if not target_obj:
55
+ return
56
+ elif model_name == 'Zone':
57
+ target_obj = get_event_obj(payload, model_class=Zone)
58
+ elif model_name == 'Category':
59
+ target_obj = get_event_obj(payload, model_class=Category)
60
+ elif model_name == 'InstanceUser':
61
+ # presence updates; no need to resolve explicitly
62
+ pass
63
+ else:
64
+ # Ignore other objects for feed
65
+ return
66
+
67
+ publish_to_users = set([
68
+ m.id for m in User.objects.filter(is_master=True) if m.is_active
69
+ ])
70
+ for iu in InstanceUser.objects.filter(
71
+ instance__uid=instance_uid, is_active=True
72
+ ).select_related('user', 'role'):
73
+ if iu.user.is_master:
74
+ publish_to_users.add(iu.user.id)
75
+ continue
76
+ if iu.role.is_superuser:
77
+ publish_to_users.add(iu.user.id)
78
+ continue
79
+ if model_name != 'Component':
80
+ publish_to_users.add(iu.user.id)
81
+ continue
82
+ if ComponentPermission.objects.filter(
83
+ role=iu.role,
84
+ component_id=target_obj.id,
85
+ component__zone__instance__uid=instance_uid,
86
+ read=True,
87
+ ).exists():
88
+ publish_to_users.add(iu.user.id)
89
+ continue
90
+
91
+ for user_id in publish_to_users:
92
+ feed_topic = f'{FEED_PREFIX}/{user_id}/feed/{instance_uid}/{model_name}/{obj_id}'
93
+ client.publish(feed_topic, msg.payload, qos=0, retain=True)
94
+ except Exception:
95
+ # Never crash the consumer
96
+ print('Fanout error:', ''.join(traceback.format_exception(*sys.exc_info())), file=sys.stderr)
@@ -136,26 +136,50 @@ def post_save_actions_dispatcher(sender, instance, created, **kwargs):
136
136
  action_type='management_event'
137
137
  )
138
138
 
139
+ # Announce Zone/Category changes over MQTT for mobile live updates
140
+ from .events import ObjectChangeEvent, dirty_fields_to_current_values
141
+ dirty_fields_prev = instance.get_dirty_fields()
142
+
143
+ def post_update():
144
+ if not dirty_fields_prev:
145
+ return
146
+
147
+ data = {}
148
+ # Provide minimal fields clients can use without re-fetching
149
+ if isinstance(instance, Zone):
150
+ data['name'] = instance.name
151
+ elif isinstance(instance, Category):
152
+ data['name'] = instance.name
153
+ data['last_modified'] = instance.last_modified
154
+
155
+ ObjectChangeEvent(
156
+ instance.instance, instance,
157
+ dirty_fields=dirty_fields_to_current_values(instance, dirty_fields_prev),
158
+ **data
159
+ ).publish()
160
+
161
+ transaction.on_commit(post_update)
162
+
139
163
 
140
164
  @receiver(post_save, sender=Component)
141
165
  @receiver(post_save, sender=Gateway)
142
166
  def post_save_change_events(sender, instance, created, **kwargs):
143
167
  target = instance
144
- from .events import ObjectChangeEvent
145
- dirty_fields = target.get_dirty_fields()
168
+ from .events import ObjectChangeEvent, dirty_fields_to_current_values
169
+ dirty_fields_prev = target.get_dirty_fields()
146
170
  for ignore_field in (
147
171
  'change_init_by', 'change_init_date', 'change_init_to', 'last_update'
148
172
  ):
149
- dirty_fields.pop(ignore_field, None)
173
+ dirty_fields_prev.pop(ignore_field, None)
150
174
 
151
175
  def post_update():
152
- if not dirty_fields:
176
+ if not dirty_fields_prev:
153
177
  return
154
178
 
155
179
  if type(target) == Gateway:
156
180
  ObjectChangeEvent(
157
181
  None, target,
158
- dirty_fields=dirty_fields,
182
+ dirty_fields=dirty_fields_to_current_values(target, dirty_fields_prev),
159
183
  ).publish()
160
184
  elif type(target) == Component:
161
185
  data = {}
@@ -166,7 +190,7 @@ def post_save_change_events(sender, instance, created, **kwargs):
166
190
  data[field_name] = getattr(target, field_name, None)
167
191
  ObjectChangeEvent(
168
192
  target.zone.instance, target,
169
- dirty_fields=dirty_fields,
193
+ dirty_fields=dirty_fields_to_current_values(target, dirty_fields_prev),
170
194
  actor=getattr(target, 'change_actor', None),
171
195
  **data
172
196
  ).publish()
@@ -217,4 +241,4 @@ def delete_file_itself(sender, instance, *args, **kwargs):
217
241
  try:
218
242
  os.remove(instance.file.path)
219
243
  except:
220
- pass
244
+ pass
simo/core/tasks.py CHANGED
@@ -441,39 +441,7 @@ def restart_postgresql():
441
441
  proc.communicate()
442
442
 
443
443
 
444
- @celery_app.task
445
- def low_battery_notifications():
446
- from simo.notifications.utils import notify_users
447
- from simo.automation.helpers import be_or_not_to_be
448
- for instance in Instance.objects.filter(is_active=True):
449
- timezone.activate(instance.timezone)
450
- hour = timezone.localtime().hour
451
- if hour < 7:
452
- continue
453
- if hour > 21:
454
- continue
455
-
456
- introduce_instance(instance)
457
- for comp in Component.objects.filter(
458
- zone__instance=instance,
459
- battery_level__isnull=False, battery_level__lt=20
460
- ):
461
- last_warning = comp.meta.get('last_battery_warning', 0)
462
- notify = be_or_not_to_be(12 * 60 * 60, 72 * 60 * 60, last_warning)
463
- if not notify:
464
- continue
465
444
 
466
- iusers = comp.zone.instance.instance_users.filter(
467
- is_active=True, role__is_owner=True
468
- )
469
- if iusers:
470
- notify_users(
471
- 'warning',
472
- f"Low battery ({comp.battery_level}%) on {comp}",
473
- component=comp, instance_users=iusers
474
- )
475
- comp.meta['last_battery_warning'] = time.time()
476
- comp.save()
477
445
 
478
446
 
479
447
  @celery_app.task
@@ -516,4 +484,3 @@ def setup_periodic_tasks(sender, **kwargs):
516
484
  sender.add_periodic_task(60 * 60, maybe_update_to_latest.s())
517
485
  sender.add_periodic_task(60, drop_fingerprints_learn.s())
518
486
  sender.add_periodic_task(60 * 60 * 24, restart_postgresql.s())
519
- sender.add_periodic_task(60 * 60, low_battery_notifications.s())
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/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
-
@@ -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.0
4
4
  Summary: Smart Home Supremacy
5
5
  Author-email: "Simon V." <simon@simo.io>
6
6
  Project-URL: Homepage, https://simo.io
@@ -3,7 +3,7 @@ simo/asgi.py,sha256=J0Xmxbi26_EWRTOn36ada6YMX7Pjp_ngMRrcdPUEFes,1839
3
3
  simo/celeryc.py,sha256=eab7_e9rw0c__DCeoUFUh_tjAGVlulxVrk75BaJf57Q,1512
4
4
  simo/conf.py,sha256=H2BhXAV8MEDVXF8AbkaLSfR4ULd-9_bS4bnhE5sE5fg,112
5
5
  simo/settings.py,sha256=WuW5CCVB66nkRZI-rPV_8b3o6BOfIs8sO-xsP7DjCDU,7090
6
- simo/urls.py,sha256=d8g-wN0Xr2PVIV8RZl_h_PMN9KGZNIE9to2hQj1p1TU,2497
6
+ simo/urls.py,sha256=AwTvBi1NE1j-MIUIsrHtlDXo34HRU4__ASwIedzBjPU,2535
7
7
  simo/__pycache__/__init__.cpython-312.pyc,sha256=EAVr4EWzl4QAeIboNWdHmEaFudefMrqjEq6IRDYtkGs,117
8
8
  simo/__pycache__/__init__.cpython-38.pyc,sha256=j81de0BqHMr6bs0C7cuYrXl7HwtK_vv8hDEtAdSwDJc,153
9
9
  simo/__pycache__/asgi.cpython-312.pyc,sha256=rP901zORYEHQnumQ25VCDkVC5dXqi7rK0xn_MfUjJI4,2416
@@ -21,7 +21,7 @@ simo/automation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  simo/automation/app_widgets.py,sha256=gaqImMZjuMHm7nIb9a4D-Y3qipz_WhSPAHXcwGx4Uzs,199
22
22
  simo/automation/controllers.py,sha256=jtoG91nYlUPW-Pkl025bBoGELWFm8SQD3duTPsjRhfk,12230
23
23
  simo/automation/forms.py,sha256=NAQUS9qrXp2d4GuvdpVyWj0Yh7vqMyX6pzs5oX4ze5Y,9800
24
- simo/automation/gateways.py,sha256=nqqiNFTYmHnwyYCM2tI-AS4gCnEFPVoJojoMo2mytVM,17383
24
+ simo/automation/gateways.py,sha256=SGq91nr2K1i8aSFh0XTZDHBb7TzFU_X24-wkt8IQkeM,17406
25
25
  simo/automation/helpers.py,sha256=iP-fxxB8HsFQy3k2CjFubu86aMqvWgmh-p24DiyOrek,4330
26
26
  simo/automation/models.py,sha256=zt-jkzyq5ddqGT864OkJzCsvov2vZ0nO4ez3hAeZkXg,934
27
27
  simo/automation/serializers.py,sha256=Pg-hMaASQPB5_BTAMkfqM6z4jdHWH8xMYWOvDxIvmx8,2126
@@ -93,7 +93,7 @@ simo/backups/migrations/__pycache__/__init__.cpython-312.pyc,sha256=gxhvSSUK92Z7
93
93
  simo/backups/migrations/__pycache__/__init__.cpython-38.pyc,sha256=Lz1fs6V05h2AoxTOLNye0do9bEMnyuaXB_hHOjG5-HU,172
94
94
  simo/core/__init__.py,sha256=_s2TjJfQImsMrTIxqLAx9AZie1Ojmm6sCHASdl3WLGU,50
95
95
  simo/core/admin.py,sha256=blhsDujAQZ5UAbwhmatKvYiAns9fOzRPL2LyOTHaUBw,18880
96
- simo/core/api.py,sha256=yqGxO0dQC3aUfIfZw2XpVeeVuGEjKgtO5hLhOhqAD6g,29924
96
+ simo/core/api.py,sha256=CCjo8DN7uzAhScW5BEJMnxq8GprAvOtnpLEwNmNN-rA,29903
97
97
  simo/core/api_auth.py,sha256=I9bALko_L7M2dodUkVLRHPctdwR-KPbyZ-r1HygDyEU,1229
98
98
  simo/core/api_meta.py,sha256=Y-kq5lLwjymARnKOx0jHCS4OOH9PhyWNwIUxLUJMny8,5350
99
99
  simo/core/app_widgets.py,sha256=VxZzapuc-a29wBH7JzpvNF2SK1ECrgNUySId5ke1ffc,2509
@@ -104,11 +104,11 @@ simo/core/base_types.py,sha256=FNIS9Y7wmdbVl-dISLdSBYvMEiV4zSLpBOBDYOVyam0,6580
104
104
  simo/core/context.py,sha256=LKw1I4iIRnlnzoTCuSLLqDX7crHdBnMo3hjqYvVmzFc,1557
105
105
  simo/core/controllers.py,sha256=jGn9bDvUuAbFL_uCAV8aqXgDJz9akYDroJuyU0ONsC8,48563
106
106
  simo/core/dynamic_settings.py,sha256=bUs58XEZOCIEhg1TigR3LmYggli13KMryBZ9pC7ugAQ,1872
107
- simo/core/events.py,sha256=hnv17g37kqwpfrkMUStfk_Rk_-G28MU1HEjolwWNNFg,4978
107
+ simo/core/events.py,sha256=GQwolnqO4ZoxgyVi9C4eKXpMULD3gMstGJsxWhc1TWU,6172
108
108
  simo/core/filters.py,sha256=6wbn8C2WvKTTjtfMwwLBp2Fib1V0-DMpS4iqJd6jJQo,2540
109
109
  simo/core/form_fields.py,sha256=b4wZ4n7OO0m0_BPPS9ILVrwBvhhjUB079YrroveFUWA,5222
110
110
  simo/core/forms.py,sha256=IwuljCxwqPcSn3eXJRF2pWjktvlQ4DH2LCsncHzCHw0,22720
111
- simo/core/gateways.py,sha256=Y2BME6zSyeUq_e-hzEUF6gErCUCP6nFxedkLZKiLVOo,4141
111
+ simo/core/gateways.py,sha256=ik9C9L0Z_pTuWXMDXCW09MuTgb6IGdMPn8uVQAJVw5I,4377
112
112
  simo/core/loggers.py,sha256=EBdq23gTQScVfQVH-xeP90-wII2DQFDjoROAW6ggUP4,1645
113
113
  simo/core/managers.py,sha256=Ampwe5K7gfE6IJULNCV35V8ysmMOdS_wz7mRzfaLZUw,3014
114
114
  simo/core/mcp.py,sha256=MDx_m6BmkYDxCrfFegchz6NMCDCB0Mrbjx4gb2iJxHU,5188
@@ -117,10 +117,10 @@ simo/core/models.py,sha256=wmwL-pUYYTmhuYrTZJWeumnnojhPbRSQsgiIVDN1Scc,23880
117
117
  simo/core/permissions.py,sha256=Ef4NO7QwwDd3Z-v61R0BeCBXxTOJz9qBvzRTIB5tHwI,2943
118
118
  simo/core/routing.py,sha256=X1_IHxyA-_Q7hw1udDoviVP4_FSBDl8GYETTC2zWTbY,499
119
119
  simo/core/serializers.py,sha256=BwkqDmV2vEumyw7EDYlD2Wm0FAYs88G_4JuTVbcEBqU,27235
120
- simo/core/signal_receivers.py,sha256=jTAjsrm5uPBmrwWvG--OuVGbSIKicynpTis_veNWuM0,7150
120
+ simo/core/signal_receivers.py,sha256=5HG7bTUcX99PUpm2aoGTsHSueoPfziZtqjtbEB56_N8,8110
121
121
  simo/core/socket_consumers.py,sha256=UlxV9OvTUUXaoKKYT3-qf1TyGxyOPxIpFH5cPFepH1o,9584
122
122
  simo/core/storage.py,sha256=_5igjaoWZAiExGWFEJMElxUw55DzJG1jqFty33xe8BE,342
123
- simo/core/tasks.py,sha256=gGbKn7iGFakhI-73pCdI_d3VcxC8mRGzQuu3YDyZLKA,17324
123
+ simo/core/tasks.py,sha256=zhHK-4tRAIEkfxRUiTRhtUqRgX56J30ggPblEtO0PE4,16027
124
124
  simo/core/todos.py,sha256=eYVXfLGiapkxKK57XuviSNe3WsUYyIWZ0hgQJk7ThKo,665
125
125
  simo/core/types.py,sha256=WJEq48mIbFi_5Alt4wxWMGXxNxUTXqfQU5koH7wqHHI,1108
126
126
  simo/core/views.py,sha256=BBh8U2P3rS_WccKN9hN6dhtiyXIfCX9I80zNwejqfe4,3666
@@ -284,9 +284,9 @@ simo/core/management/__pycache__/update.cpython-312.pyc,sha256=wB8M-RzaPgoOkeH92
284
284
  simo/core/management/_hub_template/hub/asgi.py,sha256=ElN_fdeSkf0Ysa7pS9rJVmZ1HmLhFxb8jFaMLqe1220,126
285
285
  simo/core/management/_hub_template/hub/celeryc.py,sha256=3ksDXftIZKJ4Cq9WNKJERdZdQlDEnjTQXycweRFmsSQ,27
286
286
  simo/core/management/_hub_template/hub/manage.py,sha256=PNNlw3EVeIJDgkG0l-klqoxsKWfTYWG9jzRG0upmAaI,620
287
- simo/core/management/_hub_template/hub/nginx.conf,sha256=u-e6ExdkxQX6yyh34ZIpyS8o3GtwzGvMj6yMy0-XPjg,2001
287
+ simo/core/management/_hub_template/hub/nginx.conf,sha256=_-ch60oQYXMWmcvYUEbxMvXq9S46exwKmiBaVrxkQ3c,2339
288
288
  simo/core/management/_hub_template/hub/settings.py,sha256=4QhvhbtLRxHvAntwqG_qeAAtpDUqKvN4jzw9u3vqff8,361
289
- simo/core/management/_hub_template/hub/supervisor.conf,sha256=55MHRuq0964q1hgqyqLgV-jCMxOY456bwB3g3JKAr5U,2766
289
+ simo/core/management/_hub_template/hub/supervisor.conf,sha256=eHdvz98YtxizyPyiJ57-uiTrHGthXdFEN4lybxGBJZk,3492
290
290
  simo/core/management/_hub_template/hub/urls.py,sha256=Ydm-1BkYAzWeEF-MKSDIFf-7aE4qNLPm48-SA51XgJQ,25
291
291
  simo/core/management/_hub_template/hub/wsgi.py,sha256=Lo-huLHnMDTxSmMBOodVFMWBls9poddrV2KRzXU0xGo,280
292
292
  simo/core/management/_hub_template/hub/__pycache__/asgi.cpython-312.pyc,sha256=FVjb5whNF2mVopSz71s0Zms8d_b90_4smf84OWWkzDI,396
@@ -297,7 +297,10 @@ simo/core/management/_hub_template/hub/__pycache__/urls.cpython-312.pyc,sha256=d
297
297
  simo/core/management/_hub_template/hub/__pycache__/wsgi.cpython-312.pyc,sha256=oA9duba-bsc_OOQnUaz_qiaB-0OcsS5dfjSVHTscrUM,529
298
298
  simo/core/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
299
299
  simo/core/management/commands/gateways_manager.py,sha256=oHzgC-eV4w_KHkiz6eCAlt3XMIwtS8_7mHDQ4UsU5y0,6967
300
- simo/core/management/commands/on_http_start.py,sha256=C6Xt5yTZPQQd_EdzEz-1U6-2HTIIPZzRsYKw8nnPsQM,2766
300
+ simo/core/management/commands/on_http_start.py,sha256=SYhE1d2GJ_tOFLvT9VFLPcXA3Lzz-dw5x3zMWPkdHYI,6278
301
+ simo/core/management/commands/republish_mqtt_state.py,sha256=mc8e7qnhDyC9_fiYr6d0g2s_3wGUXCrLo95-HBIrkOA,2248
302
+ simo/core/management/commands/run_app_mqtt_control.py,sha256=Vopy9BPWRCmHaWwqYkJQE5a_OHCrUfC6LVI2VEB9w2o,4820
303
+ simo/core/management/commands/run_app_mqtt_fanout.py,sha256=NLLE4A6KijvmId8pWD2qYr4VGbM_65Q8UKlbgXhNfIs,3820
301
304
  simo/core/management/commands/run_gateway.py,sha256=bp0FQQoBeOSoxjHCCMicDL1fxPZZGyLgnq2QKht3bJo,645
302
305
  simo/core/management/commands/__pycache__/__init__.cpython-312.pyc,sha256=2Rw8IQ8vlr-wLXgjtc9zACkDD_SWZCxDlbrXw5vfI6o,142
303
306
  simo/core/management/commands/__pycache__/__init__.cpython-38.pyc,sha256=WKpfZZpAB9D7U4X6oWQIrU_H-6rUmq8Gl9fj9XaY2fw,178
@@ -10672,11 +10675,11 @@ simo/generic/app_widgets.py,sha256=y8W3jR76Hh26O9pPQyg2SophMbYIOtAWD33MPKbB8Mg,8
10672
10675
  simo/generic/base_types.py,sha256=gJUJYpd_gE-f1ogzagAPA1u2TYljhyU0_SMlgGUvCVk,2318
10673
10676
  simo/generic/controllers.py,sha256=oQ7mLYU-MxO4lpCQEGJA0ClIw253Ayzx3pOAFbkqC_4,53273
10674
10677
  simo/generic/forms.py,sha256=0RIDtLLzCkiSb9OxlioicOQW9yp1OjLKekpjbxzGVfM,26272
10675
- simo/generic/gateways.py,sha256=9K4KkTn0rHRsRba1o8hIQFtRpRtpoL2E33Vozcy-FzM,19458
10678
+ simo/generic/gateways.py,sha256=mnr4nAwLLESI50cwz1-LeeaLt0ABOt8s0D2_U-NgRsc,21586
10676
10679
  simo/generic/models.py,sha256=59fkYowOX0imviIhA6uwupvuharrpBykmBm674rJNoI,7279
10677
10680
  simo/generic/routing.py,sha256=elQVZmgnPiieEuti4sJ7zITk1hlRxpgbotcutJJgC60,228
10678
10681
  simo/generic/socket_consumers.py,sha256=qesKZVhI56Kh7vdIUDD3hzDUi0FcXwIfcmE_a3YS6JQ,1772
10679
- simo/generic/tasks.py,sha256=5jhi7Jv7lfaM3T8GArWKaDqQfuvdBsKqz5obN6NVUqk,2570
10682
+ simo/generic/tasks.py,sha256=BRS2-jwxT4vQIWxzRXmRiEAchImu9vksyxXCfirsQEM,1946
10680
10683
  simo/generic/__pycache__/__init__.cpython-312.pyc,sha256=ve2HqQWbGZuKo28_jvzAITLRnqC_Hx3482zCu82uW3Y,125
10681
10684
  simo/generic/__pycache__/__init__.cpython-38.pyc,sha256=mLu54WS9KIl-pHwVCBKpsDFIlOqml--JsOVzAUHg6cU,161
10682
10685
  simo/generic/__pycache__/app_widgets.cpython-312.pyc,sha256=sfJl7ZyJHbNtwfU3Y7DMRLt1hOznFArMQPOWk8tLPoo,1895
@@ -10838,21 +10841,21 @@ simo/notifications/migrations/__pycache__/__init__.cpython-312.pyc,sha256=AA5lsh
10838
10841
  simo/notifications/migrations/__pycache__/__init__.cpython-38.pyc,sha256=YMBRHVon2nWDtIUbghckjnC12sIg_ykPWhV5aM0tto4,178
10839
10842
  simo/users/__init__.py,sha256=6a7uBpCWB_DR7p54rbHusc0xvi1qfT1ZCCQGb6TiBh8,52
10840
10843
  simo/users/admin.py,sha256=DVXDfq8ssauNA7-H6sj89UlsfbOvJldpveNSAaYIVPY,7515
10841
- simo/users/api.py,sha256=7LXVAsCuheS4k8JtkORNXFGh6bb1jR4QPZknuS12nzw,15136
10844
+ simo/users/api.py,sha256=Dt9L6Tx4gf3_4TNGFl6YHJB8aGAF2-xVdp_g-ADwkK4,15394
10842
10845
  simo/users/apps.py,sha256=cq0A8-U1HALEwev0TicgFhr4CAu7Icz8rwq0HfOaL4E,207
10843
10846
  simo/users/auth_backends.py,sha256=HxRp9iFvU1KqUhE7pA9YKEjqtBCJDbDqk_UMCD2Dwww,4361
10844
- simo/users/auto_urls.py,sha256=RSUW3ai5LbMTknS8M7M5aOnG_YlFOVQrnNVNH-fkwlg,357
10847
+ simo/users/auto_urls.py,sha256=OUUaTSpd6aPG6IuRc9FPxYDNL8Ix7todFpH7457BOGc,449
10845
10848
  simo/users/dynamic_settings.py,sha256=yDtjpcEKA5uS2fcma6e-Zznh2iyMT3x8N7aRqNCtzSM,569
10846
10849
  simo/users/managers.py,sha256=OHgEP85MBtdkdYxdstBd8RavTBT8F_2WyDxUJ9aCqqM,246
10847
10850
  simo/users/middleware.py,sha256=tNPmnzo0eTrJ25SLHP7NotqYKI2cKnmv8hf6v5DLOWo,427
10848
- simo/users/models.py,sha256=2bnUEFM1UqwWntBOTnoYwzWRMr0KsQEv6HTHGo9VdxE,20679
10851
+ simo/users/models.py,sha256=hEspAbqpbIYfQ4IiXnYtl96waILuuqlfTg4p44rBUTY,25355
10849
10852
  simo/users/permissions.py,sha256=IwtYS8yQdupWbYKR9VimSRDV3qCJ2jXP57Lyjpb2EQM,242
10850
10853
  simo/users/serializers.py,sha256=Uy5JsZp6nEGNMuK9HgLbA0KJdsbjG8GDIyF7z7Ue_so,2610
10851
10854
  simo/users/sso_urls.py,sha256=gQOaPvGMYFD0NCVSwyoWO-mTEHe5j9sbzV_RK7kdvp0,251
10852
10855
  simo/users/sso_views.py,sha256=qHjiw3Ru-hWZRJAcLJa46DrQuDORl5ZKUHgRwmN3XlE,4335
10853
10856
  simo/users/tasks.py,sha256=FhbczWFHRFI6To4xqkx4gUX4p0vCwwTT297GWBPAoIg,1162
10854
10857
  simo/users/utils.py,sha256=KjLdDFoUYt7oEA-xUVMhJA9cHH1PLMYQ3VOCVIz2Pds,2499
10855
- simo/users/views.py,sha256=iIfv8DqNztzBB3fiZcnHOyzp4eFtjrC-gCv5rBX3XsQ,4136
10858
+ simo/users/views.py,sha256=fS7amCXDOGeaz2wmUb4QPmvKSdMYjKtDo3uZrT_Z_vk,4493
10856
10859
  simo/users/__pycache__/__init__.cpython-312.pyc,sha256=UiRwMF_vg4qhVZwHICgp47rZ96O7CMtn5d5tkK9NlDE,181
10857
10860
  simo/users/__pycache__/__init__.cpython-38.pyc,sha256=VFoDJE_SKKaPqqYaaBYd1Ndb1hjakkTo_u0EG_XJ1GM,211
10858
10861
  simo/users/__pycache__/admin.cpython-312.pyc,sha256=ZN9E9ldfrK-dG-ZyvNajdDOjVwNhyJHksy5OSFFEehk,11446
@@ -11024,16 +11027,16 @@ simo/users/migrations/__pycache__/0044_permissionsrole_is_person.cpython-312.pyc
11024
11027
  simo/users/migrations/__pycache__/__init__.cpython-312.pyc,sha256=i3P6nr4OxZF7ZodnxhMfQ4jecJ7O0gsEAnUPiAEtNZ0,134
11025
11028
  simo/users/migrations/__pycache__/__init__.cpython-38.pyc,sha256=NKq7WLgktK8WV1oOqCPbAbdkrPV5GRGhYx4VxxI4dcs,170
11026
11029
  simo/users/templates/conf/mosquitto.conf,sha256=1eIGNuRu4Y3hfAU6qiWix648eCRrw0oOT24PnyFI4ys,189
11027
- simo/users/templates/conf/mosquitto_acls.conf,sha256=ga44caTDNQE0CBKw55iM2jOuna6-9fKGwAhjyERZdRE,500
11030
+ simo/users/templates/conf/mosquitto_acls.conf,sha256=e_2GN-5stuOO2QgFmELUCdHzZEF4pd2M7YGTTGpVQYo,369
11028
11031
  simo/users/templates/invitations/authenticated_msg.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11029
11032
  simo/users/templates/invitations/authenticated_suggestion.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11030
11033
  simo/users/templates/invitations/expired_msg.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11031
11034
  simo/users/templates/invitations/expired_suggestion.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11032
11035
  simo/users/templates/invitations/taken_msg.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11033
11036
  simo/users/templates/invitations/taken_suggestion.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11034
- simo-3.0.5.dist-info/licenses/LICENSE.md,sha256=M7wm1EmMGDtwPRdg7kW4d00h1uAXjKOT3HFScYQMeiE,34916
11035
- simo-3.0.5.dist-info/METADATA,sha256=9t_mI0DDVS1T9_wexKOfhYcg8O02-AgArAxvrVnseuY,2224
11036
- simo-3.0.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11037
- simo-3.0.5.dist-info/entry_points.txt,sha256=S9PwnUYmTSW7681GKDCxUbL0leRJIaRk6fDQIKgbZBA,135
11038
- simo-3.0.5.dist-info/top_level.txt,sha256=GmS1hrAbpVqn9OWZh6UX82eIOdRLgYA82RG9fe8v4Rs,5
11039
- simo-3.0.5.dist-info/RECORD,,
11037
+ simo-3.1.0.dist-info/licenses/LICENSE.md,sha256=M7wm1EmMGDtwPRdg7kW4d00h1uAXjKOT3HFScYQMeiE,34916
11038
+ simo-3.1.0.dist-info/METADATA,sha256=GRNmAs7CWJByJjZAyx8DZC15smFmWgQ2vHveK1HrEjI,2224
11039
+ simo-3.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11040
+ simo-3.1.0.dist-info/entry_points.txt,sha256=S9PwnUYmTSW7681GKDCxUbL0leRJIaRk6fDQIKgbZBA,135
11041
+ simo-3.1.0.dist-info/top_level.txt,sha256=GmS1hrAbpVqn9OWZh6UX82eIOdRLgYA82RG9fe8v4Rs,5
11042
+ simo-3.1.0.dist-info/RECORD,,
File without changes