simo 3.0.4__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/fleet/voice_assistant.py +7 -5
- simo/generic/gateways.py +65 -14
- simo/generic/tasks.py +1 -22
- simo/mcp_server/__pycache__/dummy.cpython-312.pyc +0 -0
- 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.4.dist-info → simo-3.1.0.dist-info}/METADATA +1 -1
- {simo-3.0.4.dist-info → simo-3.1.0.dist-info}/RECORD +28 -24
- {simo-3.0.4.dist-info → simo-3.1.0.dist-info}/WHEEL +0 -0
- {simo-3.0.4.dist-info → simo-3.1.0.dist-info}/entry_points.txt +0 -0
- {simo-3.0.4.dist-info → simo-3.1.0.dist-info}/licenses/LICENSE.md +0 -0
- {simo-3.0.4.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/fleet/voice_assistant.py
CHANGED
|
@@ -780,11 +780,13 @@ class VoiceAssistantArbitrator:
|
|
|
780
780
|
return False
|
|
781
781
|
|
|
782
782
|
def start_window_if_needed(self):
|
|
783
|
-
if
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
783
|
+
# Start a new arbitration window if none is currently running.
|
|
784
|
+
# This allows a fresh window per VA session rather than only once
|
|
785
|
+
# per connection, ensuring the cloud gate can reopen after session end.
|
|
786
|
+
if self._arb_task and not self._arb_task.done():
|
|
787
|
+
return
|
|
788
|
+
self._arb_started = True
|
|
789
|
+
self._arb_task = asyncio.create_task(self._decide_after_window())
|
|
788
790
|
|
|
789
791
|
async def _decide_after_window(self):
|
|
790
792
|
try:
|