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.
- simo/automation/gateways.py +1 -0
- simo/core/api.py +2 -1
- simo/core/events.py +30 -3
- simo/core/gateways.py +4 -1
- simo/core/management/_hub_template/hub/nginx.conf +11 -0
- simo/core/management/_hub_template/hub/supervisor.conf +31 -0
- simo/core/management/commands/on_http_start.py +65 -1
- simo/core/management/commands/republish_mqtt_state.py +60 -0
- simo/core/management/commands/run_app_mqtt_control.py +129 -0
- simo/core/management/commands/run_app_mqtt_fanout.py +96 -0
- simo/core/signal_receivers.py +31 -7
- simo/core/tasks.py +0 -33
- simo/generic/gateways.py +65 -14
- simo/generic/tasks.py +1 -22
- simo/urls.py +2 -1
- simo/users/api.py +8 -2
- simo/users/auto_urls.py +2 -1
- simo/users/models.py +123 -14
- simo/users/templates/conf/mosquitto_acls.conf +7 -7
- simo/users/views.py +12 -0
- {simo-3.0.5.dist-info → simo-3.1.0.dist-info}/METADATA +1 -1
- {simo-3.0.5.dist-info → simo-3.1.0.dist-info}/RECORD +26 -23
- {simo-3.0.5.dist-info → simo-3.1.0.dist-info}/WHEEL +0 -0
- {simo-3.0.5.dist-info → simo-3.1.0.dist-info}/entry_points.txt +0 -0
- {simo-3.0.5.dist-info → simo-3.1.0.dist-info}/licenses/LICENSE.md +0 -0
- {simo-3.0.5.dist-info → simo-3.1.0.dist-info}/top_level.txt +0 -0
simo/automation/gateways.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
56
|
-
f"{type(self.obj).__name__}
|
|
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)
|
simo/core/signal_receivers.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
173
|
+
dirty_fields_prev.pop(ignore_field, None)
|
|
150
174
|
|
|
151
175
|
def post_update():
|
|
152
|
-
if not
|
|
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=
|
|
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=
|
|
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.
|
|
11
|
-
from simo.core.
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
11035
|
-
simo-3.0.
|
|
11036
|
-
simo-3.0.
|
|
11037
|
-
simo-3.0.
|
|
11038
|
-
simo-3.0.
|
|
11039
|
-
simo-3.0.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|