simo 2.11.4__py3-none-any.whl → 3.0.4__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/__pycache__/settings.cpython-312.pyc +0 -0
- simo/asgi.py +25 -6
- simo/automation/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/automation/controllers.py +18 -2
- simo/automation/forms.py +15 -24
- simo/automation/gateways.py +32 -16
- simo/core/__pycache__/admin.cpython-312.pyc +0 -0
- simo/core/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/core/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/core/__pycache__/forms.cpython-312.pyc +0 -0
- simo/core/__pycache__/models.cpython-312.pyc +0 -0
- simo/core/__pycache__/serializers.cpython-312.pyc +0 -0
- simo/core/__pycache__/signal_receivers.cpython-312.pyc +0 -0
- simo/core/__pycache__/tasks.cpython-312.pyc +0 -0
- simo/core/admin.py +5 -4
- simo/core/base_types.py +191 -18
- simo/core/controllers.py +259 -26
- simo/core/forms.py +10 -2
- simo/core/management/_hub_template/hub/nginx.conf +23 -50
- simo/core/management/_hub_template/hub/supervisor.conf +15 -0
- simo/core/mcp.py +154 -0
- simo/core/migrations/0051_instance_ai_memory.py +18 -0
- simo/core/migrations/__pycache__/0051_instance_ai_memory.cpython-312.pyc +0 -0
- simo/core/models.py +3 -0
- simo/core/serializers.py +120 -0
- simo/core/signal_receivers.py +1 -1
- simo/core/tasks.py +1 -3
- simo/core/utils/__pycache__/type_constants.cpython-312.pyc +0 -0
- simo/core/utils/type_constants.py +78 -17
- simo/fleet/__pycache__/admin.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/api.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/forms.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/gateways.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/models.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/serializers.cpython-312.pyc +0 -0
- simo/fleet/admin.py +5 -1
- simo/fleet/api.py +2 -27
- simo/fleet/base_types.py +35 -4
- simo/fleet/controllers.py +162 -156
- simo/fleet/forms.py +58 -88
- simo/fleet/gateways.py +8 -15
- simo/fleet/migrations/0055_colonel_is_vo_active_colonel_last_wake_and_more.py +28 -0
- simo/fleet/migrations/0056_delete_customdalidevice.py +16 -0
- simo/fleet/migrations/__pycache__/0055_colonel_is_vo_active_colonel_last_wake_and_more.cpython-312.pyc +0 -0
- simo/fleet/migrations/__pycache__/0056_delete_customdalidevice.cpython-312.pyc +0 -0
- simo/fleet/models.py +13 -72
- simo/fleet/serializers.py +1 -48
- simo/fleet/socket_consumers.py +100 -39
- simo/fleet/tasks.py +2 -22
- simo/fleet/voice_assistant.py +903 -0
- simo/generic/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/generic/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/generic/__pycache__/gateways.cpython-312.pyc +0 -0
- simo/generic/base_types.py +70 -10
- simo/generic/controllers.py +104 -17
- simo/generic/gateways.py +10 -10
- simo/mcp_server/__init__.py +0 -0
- simo/mcp_server/__pycache__/__init__.cpython-312.pyc +0 -0
- simo/mcp_server/__pycache__/admin.cpython-312.pyc +0 -0
- simo/mcp_server/__pycache__/models.cpython-312.pyc +0 -0
- simo/mcp_server/admin.py +18 -0
- simo/mcp_server/app.py +4 -0
- simo/mcp_server/auth.py +34 -0
- simo/mcp_server/dummy.py +22 -0
- simo/mcp_server/migrations/0001_initial.py +30 -0
- simo/mcp_server/migrations/0002_alter_instanceaccesstoken_date_expired.py +18 -0
- simo/mcp_server/migrations/0003_instanceaccesstoken_issuer.py +18 -0
- simo/mcp_server/migrations/__init__.py +0 -0
- simo/mcp_server/migrations/__pycache__/0001_initial.cpython-312.pyc +0 -0
- simo/mcp_server/migrations/__pycache__/0002_alter_instanceaccesstoken_date_expired.cpython-312.pyc +0 -0
- simo/mcp_server/migrations/__pycache__/0003_instanceaccesstoken_issuer.cpython-312.pyc +0 -0
- simo/mcp_server/migrations/__pycache__/__init__.cpython-312.pyc +0 -0
- simo/mcp_server/models.py +27 -0
- simo/mcp_server/server.py +60 -0
- simo/mcp_server/tasks.py +19 -0
- simo/multimedia/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/multimedia/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/multimedia/base_types.py +29 -4
- simo/multimedia/controllers.py +66 -19
- simo/settings.py +1 -0
- simo/users/__pycache__/utils.cpython-312.pyc +0 -0
- simo/users/utils.py +10 -0
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/METADATA +11 -4
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/RECORD +90 -64
- simo/fleet/custom_dali_operations.py +0 -287
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/WHEEL +0 -0
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/entry_points.txt +0 -0
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/licenses/LICENSE.md +0 -0
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/top_level.txt +0 -0
simo/core/forms.py
CHANGED
|
@@ -17,6 +17,7 @@ from .form_fields import Select2ModelMultipleChoiceField
|
|
|
17
17
|
from .widgets import SVGFileWidget, LogOutputWidget, PythonCode
|
|
18
18
|
from .utils.formsets import FormsetField
|
|
19
19
|
from .utils.validators import validate_slaves
|
|
20
|
+
from .base_types import BaseComponentType
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class HiddenField(forms.CharField):
|
|
@@ -265,7 +266,14 @@ class ComponentAdminForm(forms.ModelForm):
|
|
|
265
266
|
).first()
|
|
266
267
|
self.instance.gateway = self.gateway
|
|
267
268
|
self.instance.controller_uid = ControllerClass.uid
|
|
268
|
-
|
|
269
|
+
# Normalize controller base_type to slug for storage
|
|
270
|
+
bt = getattr(ControllerClass, 'base_type', None)
|
|
271
|
+
if isinstance(bt, str):
|
|
272
|
+
self.instance.base_type = bt
|
|
273
|
+
elif isinstance(bt, type) and issubclass(bt, BaseComponentType):
|
|
274
|
+
self.instance.base_type = bt.slug
|
|
275
|
+
else:
|
|
276
|
+
self.instance.base_type = getattr(bt, 'slug', None) or str(bt)
|
|
269
277
|
self.instance.value = self.controller.default_value
|
|
270
278
|
self.instance.value_units = self.controller.default_value_units
|
|
271
279
|
self.instance.value_previous = self.controller.default_value
|
|
@@ -657,4 +665,4 @@ class DimmerPlusConfigForm(BaseComponentForm):
|
|
|
657
665
|
class RGBWConfigForm(BaseComponentForm):
|
|
658
666
|
has_white = forms.BooleanField(
|
|
659
667
|
label=_("Has WHITE color channel"), required=False,
|
|
660
|
-
)
|
|
668
|
+
)
|
|
@@ -1,52 +1,8 @@
|
|
|
1
|
-
server{
|
|
1
|
+
server {
|
|
2
|
+
listen 80 default_server;
|
|
2
3
|
listen [::]:80 default_server;
|
|
3
|
-
listen
|
|
4
|
-
|
|
5
|
-
charset utf-8;
|
|
6
|
-
|
|
7
|
-
client_max_body_size 100M;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
location /protected/static {
|
|
11
|
-
internal;
|
|
12
|
-
alias {{ project_dir }}/_var/static;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
location /protected/media {
|
|
16
|
-
internal;
|
|
17
|
-
alias {{ project_dir }}/_var/media;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
location /public_media{
|
|
21
|
-
alias {{ project_dir }}/_var/public_media;
|
|
22
|
-
access_log off;
|
|
23
|
-
expires max;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
# daphne
|
|
27
|
-
location /ws {
|
|
28
|
-
include proxy_params;
|
|
29
|
-
|
|
30
|
-
proxy_set_header Upgrade $http_upgrade;
|
|
31
|
-
proxy_set_header Connection "upgrade";
|
|
32
|
-
|
|
33
|
-
proxy_pass http://unix:/tmp/http.sock;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
# gunicorn
|
|
37
|
-
location / {
|
|
38
|
-
include proxy_params;
|
|
39
|
-
proxy_connect_timeout 600;
|
|
40
|
-
proxy_send_timeout 600;
|
|
41
|
-
proxy_read_timeout 600;
|
|
42
|
-
send_timeout 600;
|
|
43
|
-
proxy_pass http://unix:/tmp/gunicorn.sock;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
server{
|
|
48
|
-
listen [::]:443 default_server ssl;
|
|
49
|
-
listen 443 default_server ssl;
|
|
4
|
+
listen 443 ssl http2 default_server;
|
|
5
|
+
listen [::]:443 ssl http2 default_server;
|
|
50
6
|
|
|
51
7
|
charset utf-8;
|
|
52
8
|
|
|
@@ -80,7 +36,6 @@ server{
|
|
|
80
36
|
expires max;
|
|
81
37
|
}
|
|
82
38
|
|
|
83
|
-
# daphne
|
|
84
39
|
location /ws {
|
|
85
40
|
include proxy_params;
|
|
86
41
|
|
|
@@ -90,7 +45,25 @@ server{
|
|
|
90
45
|
proxy_pass http://unix:/tmp/http.sock;
|
|
91
46
|
}
|
|
92
47
|
|
|
93
|
-
|
|
48
|
+
location = /mcp {
|
|
49
|
+
return 301 /mcp/;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
location ^~ /mcp/ {
|
|
53
|
+
proxy_pass http://0.0.0.0:3333/;
|
|
54
|
+
proxy_set_header Host $host;
|
|
55
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
56
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
57
|
+
proxy_set_header Authorization $http_authorization;
|
|
58
|
+
|
|
59
|
+
proxy_http_version 1.1;
|
|
60
|
+
proxy_buffering off;
|
|
61
|
+
proxy_request_buffering off;
|
|
62
|
+
proxy_read_timeout 3600s;
|
|
63
|
+
proxy_send_timeout 3600s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
94
67
|
location / {
|
|
95
68
|
include proxy_params;
|
|
96
69
|
proxy_connect_timeout 600;
|
|
@@ -47,6 +47,21 @@ killasgroup=true
|
|
|
47
47
|
environment=PYTHONUNBUFFERED=1
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
[program:simo-mcp]
|
|
51
|
+
directory={{ project_dir }}/hub/
|
|
52
|
+
command={{ venv_path }}/uvicorn simo.mcp_server.server:create_app --factory --host 0.0.0.0 --port 3333
|
|
53
|
+
process_name=%(program_name)s
|
|
54
|
+
user=root
|
|
55
|
+
stdout_logfile=/var/log/simo/mcp.log
|
|
56
|
+
stdout_logfile_maxbytes=1MB
|
|
57
|
+
stdout_logfile_backups=3
|
|
58
|
+
redirect_stderr=true
|
|
59
|
+
autostart=true
|
|
60
|
+
autorestart=true
|
|
61
|
+
stopwaitsecs=15
|
|
62
|
+
killasgroup=true
|
|
63
|
+
|
|
64
|
+
|
|
50
65
|
[program:simo-gateways]
|
|
51
66
|
command={{ venv_path }}/python {{ project_dir }}/hub/manage.py gateways_manager
|
|
52
67
|
process_name=%(program_name)s
|
simo/core/mcp.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import pytz
|
|
2
|
+
import datetime
|
|
3
|
+
import logging
|
|
4
|
+
import json
|
|
5
|
+
from asgiref.sync import sync_to_async
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
from simo.mcp_server.app import mcp
|
|
8
|
+
from fastmcp.tools.tool import ToolResult
|
|
9
|
+
from simo.users.utils import get_current_user, introduce_user, get_ai_user
|
|
10
|
+
from simo.core.middleware import get_current_instance
|
|
11
|
+
from .models import Zone, Component, ComponentHistory
|
|
12
|
+
from .serializers import MCPBasicZoneSerializer, MCPFullComponentSerializer
|
|
13
|
+
from .utils.type_constants import BASE_TYPE_CLASS_MAP
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@mcp.tool(name="core.get_state")
|
|
19
|
+
async def get_state() -> dict:
|
|
20
|
+
"""
|
|
21
|
+
PRIMARY RESOURCE – returns full current system state.
|
|
22
|
+
"""
|
|
23
|
+
def _build():
|
|
24
|
+
inst = get_current_instance()
|
|
25
|
+
data = {
|
|
26
|
+
"unix_timestamp": int(timezone.now().timestamp()),
|
|
27
|
+
"ai_memory": inst.ai_memory,
|
|
28
|
+
"zones": MCPBasicZoneSerializer(
|
|
29
|
+
Zone.objects.filter(instance=inst).prefetch_related(
|
|
30
|
+
"components", "components__category",
|
|
31
|
+
"components__gateway", "components__slaves"
|
|
32
|
+
),
|
|
33
|
+
many=True,
|
|
34
|
+
).data,
|
|
35
|
+
"component_base_types": {},
|
|
36
|
+
}
|
|
37
|
+
for slug, cls in BASE_TYPE_CLASS_MAP.items():
|
|
38
|
+
data["component_base_types"][slug] = {
|
|
39
|
+
"name": str(cls.name),
|
|
40
|
+
"description": str(cls.description),
|
|
41
|
+
"purpose": str(cls.purpose),
|
|
42
|
+
"basic_methods": str(cls.required_methods),
|
|
43
|
+
}
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
return await sync_to_async(_build, thread_sensitive=True)()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@mcp.tool(name="core.get_component")
|
|
50
|
+
async def get_component(id: str) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Returns full component state, configs, metadata, methods, values, etc.
|
|
53
|
+
"""
|
|
54
|
+
def _load(component_id: str):
|
|
55
|
+
component = (
|
|
56
|
+
Component.objects.filter(pk=component_id, zone__instance=get_current_instance())
|
|
57
|
+
.select_related("zone", "category", "gateway")
|
|
58
|
+
.first()
|
|
59
|
+
)
|
|
60
|
+
return MCPFullComponentSerializer(component).data
|
|
61
|
+
|
|
62
|
+
return await sync_to_async(_load, thread_sensitive=True)(id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@mcp.tool(name="core.get_component_value_change_history")
|
|
66
|
+
async def get_component_value_change_history(
|
|
67
|
+
start: int, end: int, component_ids: str
|
|
68
|
+
) -> list:
|
|
69
|
+
"""
|
|
70
|
+
Returns up to 100 component value change history records.
|
|
71
|
+
|
|
72
|
+
- start: unix epoch seconds (older than)
|
|
73
|
+
- end: unix epoch seconds (younger than)
|
|
74
|
+
- component_ids: ids joined by '-' OR '-' to include all
|
|
75
|
+
"""
|
|
76
|
+
def _load(_start: int, _end: int, _ids: str):
|
|
77
|
+
inst = get_current_instance()
|
|
78
|
+
tz = pytz.timezone(inst.timezone)
|
|
79
|
+
qs = (
|
|
80
|
+
ComponentHistory.objects.filter(
|
|
81
|
+
component__zone__instance=inst,
|
|
82
|
+
date__gt=datetime.datetime.fromtimestamp(int(_start), tz=timezone.utc),
|
|
83
|
+
date__lt=datetime.datetime.fromtimestamp(int(_end), tz=timezone.utc),
|
|
84
|
+
)
|
|
85
|
+
.select_related("user")
|
|
86
|
+
.order_by("-date")
|
|
87
|
+
)
|
|
88
|
+
if _ids != "-":
|
|
89
|
+
ids = [int(c_id) for c_id in _ids.split("-")]
|
|
90
|
+
qs = qs.filter(component__id__in=ids)
|
|
91
|
+
history = []
|
|
92
|
+
for item in qs[:100]:
|
|
93
|
+
history.append({
|
|
94
|
+
"component_id": item.component.id,
|
|
95
|
+
"datetime": timezone.localtime(item.date, tz).strftime("%Y-%m-%d %H:%M:%S"),
|
|
96
|
+
"type": item.type,
|
|
97
|
+
"value": item.value,
|
|
98
|
+
"alive": item.alive,
|
|
99
|
+
"user": item.user.name if item.user_id else None,
|
|
100
|
+
})
|
|
101
|
+
return history
|
|
102
|
+
|
|
103
|
+
return await sync_to_async(_load, thread_sensitive=True)(start, end, component_ids)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool(name="core.call_component_method")
|
|
107
|
+
async def call_component_method(
|
|
108
|
+
component_id: int,
|
|
109
|
+
method_name: str,
|
|
110
|
+
args: list | None = None,
|
|
111
|
+
kwargs: dict | None = None,
|
|
112
|
+
):
|
|
113
|
+
"""
|
|
114
|
+
Calls a method on a component with given args/kwargs and returns the result if any.
|
|
115
|
+
"""
|
|
116
|
+
def _execute():
|
|
117
|
+
log.debug("Call component [%s] %s(*%s, **%s)", component_id, method_name, args, kwargs)
|
|
118
|
+
current_user = get_current_user()
|
|
119
|
+
if not current_user:
|
|
120
|
+
introduce_user(get_ai_user())
|
|
121
|
+
component = Component.objects.get(
|
|
122
|
+
pk=component_id, zone__instance=get_current_instance()
|
|
123
|
+
)
|
|
124
|
+
fn = getattr(component, method_name)
|
|
125
|
+
if args and kwargs:
|
|
126
|
+
return fn(*args, **kwargs)
|
|
127
|
+
if args:
|
|
128
|
+
return fn(*args)
|
|
129
|
+
if kwargs:
|
|
130
|
+
return fn(**kwargs)
|
|
131
|
+
return fn()
|
|
132
|
+
|
|
133
|
+
return await sync_to_async(_execute, thread_sensitive=True)()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@mcp.tool(name="core.update_ai_memory")
|
|
137
|
+
async def update_ai_memory(text):
|
|
138
|
+
"""
|
|
139
|
+
Overrides ai_memory with new memory text
|
|
140
|
+
"""
|
|
141
|
+
def _execute(text):
|
|
142
|
+
inst = get_current_instance()
|
|
143
|
+
inst.ai_memory = text
|
|
144
|
+
inst.save(update_fields=['ai_memory'])
|
|
145
|
+
|
|
146
|
+
return await sync_to_async(_execute, thread_sensitive=True)(text)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@mcp.tool(name="core.get_unix_timestamp")
|
|
150
|
+
async def get_unix_timestamp() -> int:
|
|
151
|
+
"""
|
|
152
|
+
Get current unix timestamp epoch seconds
|
|
153
|
+
"""
|
|
154
|
+
return int(timezone.now().timestamp())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 4.2.10 on 2025-09-24 07:04
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('core', '0050_componenthistory_alive'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='instance',
|
|
15
|
+
name='ai_memory',
|
|
16
|
+
field=models.TextField(blank=True, default='', help_text='Used by AI assistant.'),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
Binary file
|
simo/core/models.py
CHANGED
|
@@ -109,6 +109,9 @@ class Instance(DirtyFieldsMixin, models.Model, SimoAdminMixin):
|
|
|
109
109
|
User, null=True, blank=True, on_delete=models.SET_NULL,
|
|
110
110
|
editable=False
|
|
111
111
|
)
|
|
112
|
+
ai_memory = models.TextField(
|
|
113
|
+
blank=True, default='', help_text="Used by AI assistant."
|
|
114
|
+
)
|
|
112
115
|
|
|
113
116
|
#objects = InstanceManager()
|
|
114
117
|
|
simo/core/serializers.py
CHANGED
|
@@ -3,6 +3,7 @@ import datetime
|
|
|
3
3
|
import re
|
|
4
4
|
import json
|
|
5
5
|
from django import forms
|
|
6
|
+
from django.utils import timezone
|
|
6
7
|
from collections import OrderedDict
|
|
7
8
|
from django.conf import settings
|
|
8
9
|
from django.forms.utils import pretty_name
|
|
@@ -651,3 +652,122 @@ class ActionSerializer(serializers.ModelSerializer):
|
|
|
651
652
|
def get_value(self, obj):
|
|
652
653
|
return obj.data.get('value')
|
|
653
654
|
|
|
655
|
+
|
|
656
|
+
class MCPBasicCategorySerializer(serializers.ModelSerializer):
|
|
657
|
+
|
|
658
|
+
class Meta:
|
|
659
|
+
model = Category
|
|
660
|
+
fields = 'id', 'name', 'icon'
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
class MCPBasicComponentSerializer(serializers.ModelSerializer):
|
|
664
|
+
category = MCPBasicCategorySerializer(read_only=True)
|
|
665
|
+
gateway = serializers.SerializerMethodField()
|
|
666
|
+
slave_components = serializers.SerializerMethodField()
|
|
667
|
+
last_change = TimestampField(read_only=True)
|
|
668
|
+
|
|
669
|
+
class Meta:
|
|
670
|
+
model = Component
|
|
671
|
+
fields = (
|
|
672
|
+
'id', 'name', 'icon', 'category', 'gateway', 'base_type', 'value',
|
|
673
|
+
'value_units',
|
|
674
|
+
'slave_components', 'last_change', 'alive', 'error_msg',
|
|
675
|
+
'battery_level', 'show_in_app', 'alarm_category', 'arm_status'
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def get_gateway(self, obj):
|
|
679
|
+
return obj.gateway.type
|
|
680
|
+
|
|
681
|
+
def get_slave_components(self, obj):
|
|
682
|
+
return [c['id'] for c in obj.slaves.all().values('id')]
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
class MCPBasicZoneSerializer(serializers.ModelSerializer):
|
|
686
|
+
components = serializers.SerializerMethodField()
|
|
687
|
+
|
|
688
|
+
class Meta:
|
|
689
|
+
model = Zone
|
|
690
|
+
fields = 'id', 'name', 'components'
|
|
691
|
+
|
|
692
|
+
def get_components(self, obj):
|
|
693
|
+
return MCPBasicComponentSerializer(
|
|
694
|
+
obj.components.all(), many=True, context=self.context
|
|
695
|
+
).data
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
class MCPFullComponentSerializer(serializers.ModelSerializer):
|
|
699
|
+
category = MCPBasicCategorySerializer(read_only=True)
|
|
700
|
+
gateway = serializers.SerializerMethodField()
|
|
701
|
+
slave_components = serializers.SerializerMethodField()
|
|
702
|
+
last_change = TimestampField(read_only=True)
|
|
703
|
+
info = serializers.SerializerMethodField()
|
|
704
|
+
controller_methods = serializers.SerializerMethodField()
|
|
705
|
+
unix_timestamp = serializers.SerializerMethodField()
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class Meta:
|
|
709
|
+
model = Component
|
|
710
|
+
fields = (
|
|
711
|
+
'id', 'name', 'icon', 'zone', 'category', 'gateway',
|
|
712
|
+
'base_type', 'controller_uid', 'config', 'meta', 'value',
|
|
713
|
+
'value_units', 'value_translation', 'alive', 'error_msg',
|
|
714
|
+
'battery_level', 'show_in_app', 'alarm_category', 'arm_status',
|
|
715
|
+
'info', 'controller_methods', 'slave_components', 'last_change',
|
|
716
|
+
'unix_timestamp'
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def get_gateway(self, obj):
|
|
721
|
+
return obj.gateway.type
|
|
722
|
+
|
|
723
|
+
def get_info(self, obj):
|
|
724
|
+
return obj.info()
|
|
725
|
+
|
|
726
|
+
def get_controller_methods(self, obj):
|
|
727
|
+
methods = {}
|
|
728
|
+
# Collect controller public methods with signatures and docstrings
|
|
729
|
+
for name, method in inspect.getmembers(obj.controller, predicate=inspect.ismethod):
|
|
730
|
+
if name.startswith('_'):
|
|
731
|
+
continue
|
|
732
|
+
if name in ('info', 'set'):
|
|
733
|
+
continue
|
|
734
|
+
if name == 'send' and not obj.controller.accepts_value:
|
|
735
|
+
continue
|
|
736
|
+
try:
|
|
737
|
+
sig = str(inspect.signature(method))
|
|
738
|
+
except Exception:
|
|
739
|
+
sig = '()'
|
|
740
|
+
doc = inspect.getdoc(method) or ''
|
|
741
|
+
methods[name] = {
|
|
742
|
+
'name': name,
|
|
743
|
+
'signature': sig,
|
|
744
|
+
'doc': doc
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
# If component has alarm capabilities, ensure arm/disarm are exposed
|
|
748
|
+
if obj.alarm_category:
|
|
749
|
+
for extra in ('arm', 'disarm'):
|
|
750
|
+
if extra in methods:
|
|
751
|
+
continue
|
|
752
|
+
cm = getattr(obj, extra, None)
|
|
753
|
+
if cm and callable(cm):
|
|
754
|
+
try:
|
|
755
|
+
sig = str(inspect.signature(cm))
|
|
756
|
+
except Exception:
|
|
757
|
+
sig = '()'
|
|
758
|
+
doc = inspect.getdoc(cm) or ''
|
|
759
|
+
methods[extra] = {
|
|
760
|
+
'name': extra,
|
|
761
|
+
'signature': sig,
|
|
762
|
+
'doc': doc,
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
# Return as a list sorted by method name for stability
|
|
766
|
+
return [methods[k] for k in sorted(methods.keys())]
|
|
767
|
+
|
|
768
|
+
def get_slave_components(self, obj):
|
|
769
|
+
return [c['id'] for c in obj.slaves.all().values('id')]
|
|
770
|
+
|
|
771
|
+
def get_unix_timestamp(self, obj):
|
|
772
|
+
return timezone.now().timestamp()
|
|
773
|
+
|
simo/core/signal_receivers.py
CHANGED
|
@@ -185,7 +185,7 @@ def post_save_change_events(sender, instance, created, **kwargs):
|
|
|
185
185
|
|
|
186
186
|
transaction.on_commit(post_update)
|
|
187
187
|
|
|
188
|
-
if created:
|
|
188
|
+
if created and isinstance(instance, Component):
|
|
189
189
|
def clear_api_cache():
|
|
190
190
|
cache.delete(f"main-components-{instance.zone.instance.id}")
|
|
191
191
|
from simo.users.models import User
|
simo/core/tasks.py
CHANGED
|
@@ -234,9 +234,7 @@ def sync_with_remote():
|
|
|
234
234
|
).first()
|
|
235
235
|
if weather_component:
|
|
236
236
|
if weather:
|
|
237
|
-
weather_component.alive
|
|
238
|
-
weather_component.controller.set(weather)
|
|
239
|
-
weather_component.save()
|
|
237
|
+
weather_component.controller.set(weather, alive=True)
|
|
240
238
|
else:
|
|
241
239
|
weather_component.alive = False
|
|
242
240
|
weather_component.save()
|
|
Binary file
|
|
@@ -3,6 +3,68 @@ import inspect
|
|
|
3
3
|
from django.apps import apps
|
|
4
4
|
from ..gateways import BaseGatewayHandler
|
|
5
5
|
from ..app_widgets import BaseAppWidget
|
|
6
|
+
from ..base_types import BaseComponentType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ----- Base type discovery (must be defined before controller discovery) -----
|
|
10
|
+
def get_base_type_class_map():
|
|
11
|
+
"""Discover BaseComponentType subclasses across installed apps.
|
|
12
|
+
|
|
13
|
+
Returns a dict mapping slug -> type class.
|
|
14
|
+
"""
|
|
15
|
+
base_types = {}
|
|
16
|
+
for name, app in apps.app_configs.items():
|
|
17
|
+
if name in (
|
|
18
|
+
'auth', 'admin', 'contenttypes', 'sessions', 'messages',
|
|
19
|
+
'staticfiles'
|
|
20
|
+
):
|
|
21
|
+
continue
|
|
22
|
+
try:
|
|
23
|
+
module = importlib.import_module(f'{app.name}.base_types')
|
|
24
|
+
except ModuleNotFoundError:
|
|
25
|
+
continue
|
|
26
|
+
for cls_name, cls in module.__dict__.items():
|
|
27
|
+
if not inspect.isclass(cls):
|
|
28
|
+
continue
|
|
29
|
+
if not issubclass(cls, BaseComponentType) or cls is BaseComponentType:
|
|
30
|
+
continue
|
|
31
|
+
if not getattr(cls, 'slug', None):
|
|
32
|
+
continue
|
|
33
|
+
if cls.slug in base_types:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
f"Duplicate base type slug '{cls.slug}' defined by "
|
|
36
|
+
f"{base_types[cls.slug].__module__}.{base_types[cls.slug].__name__} "
|
|
37
|
+
f"and {cls.__module__}.{cls.__name__}"
|
|
38
|
+
)
|
|
39
|
+
base_types[cls.slug] = cls
|
|
40
|
+
return base_types
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
BASE_TYPE_CLASS_MAP = get_base_type_class_map()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_all_base_types():
|
|
47
|
+
"""Build a combined map of slug -> name from classes and legacy dicts."""
|
|
48
|
+
all_types = {slug: cls.name for slug, cls in BASE_TYPE_CLASS_MAP.items()}
|
|
49
|
+
# Backward-compatible: merge any legacy dict entries not covered by classes
|
|
50
|
+
for name, app in apps.app_configs.items():
|
|
51
|
+
if name in (
|
|
52
|
+
'auth', 'admin', 'contenttypes', 'sessions', 'messages',
|
|
53
|
+
'staticfiles'
|
|
54
|
+
):
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
configs = importlib.import_module('%s.base_types' % app.name)
|
|
58
|
+
except ModuleNotFoundError:
|
|
59
|
+
continue
|
|
60
|
+
for slug, display in configs.__dict__.get('BASE_TYPES', {}).items():
|
|
61
|
+
if slug not in all_types:
|
|
62
|
+
all_types[slug] = display
|
|
63
|
+
return all_types
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
ALL_BASE_TYPES = get_all_base_types()
|
|
67
|
+
BASE_TYPE_CHOICES = sorted(list(ALL_BASE_TYPES.items()), key=lambda e: e[0])
|
|
6
68
|
|
|
7
69
|
|
|
8
70
|
def get_controller_types_map(gateway=None, user=None):
|
|
@@ -41,6 +103,22 @@ def get_controller_types_map(gateway=None, user=None):
|
|
|
41
103
|
if user and not user.is_master and cls.masters_only:
|
|
42
104
|
continue
|
|
43
105
|
|
|
106
|
+
# Validate controller against its base type contract if available.
|
|
107
|
+
# Support either `base_type_cls` or legacy `base_type` (slug or class).
|
|
108
|
+
declared = getattr(cls, 'base_type_cls', None)
|
|
109
|
+
slug = None
|
|
110
|
+
if declared and inspect.isclass(declared) and issubclass(declared, BaseComponentType):
|
|
111
|
+
slug = declared.slug
|
|
112
|
+
else:
|
|
113
|
+
bt = getattr(cls, 'base_type', None)
|
|
114
|
+
if isinstance(bt, str):
|
|
115
|
+
slug = bt
|
|
116
|
+
elif inspect.isclass(bt) and issubclass(bt, BaseComponentType):
|
|
117
|
+
slug = bt.slug
|
|
118
|
+
bt_cls = BASE_TYPE_CLASS_MAP.get(slug) if slug else None
|
|
119
|
+
if bt_cls:
|
|
120
|
+
bt_cls.validate_controller(cls)
|
|
121
|
+
|
|
44
122
|
controllers_map[cls.uid] = cls
|
|
45
123
|
return controllers_map
|
|
46
124
|
|
|
@@ -97,21 +175,6 @@ for gateway_slug, gateway_cls in GATEWAYS_MAP.items():
|
|
|
97
175
|
CONTROLLERS_BY_GATEWAY[gateway_slug][ctrl_uid] = ctrl_cls
|
|
98
176
|
|
|
99
177
|
|
|
100
|
-
ALL_BASE_TYPES = {}
|
|
101
|
-
for name, app in apps.app_configs.items():
|
|
102
|
-
if name in (
|
|
103
|
-
'auth', 'admin', 'contenttypes', 'sessions', 'messages',
|
|
104
|
-
'staticfiles'
|
|
105
|
-
):
|
|
106
|
-
continue
|
|
107
|
-
try:
|
|
108
|
-
configs = importlib.import_module('%s.base_types' % app.name)
|
|
109
|
-
except ModuleNotFoundError:
|
|
110
|
-
continue
|
|
111
|
-
ALL_BASE_TYPES.update(configs.__dict__.get('BASE_TYPES', {}))
|
|
112
|
-
|
|
113
|
-
BASE_TYPE_CHOICES = list(ALL_BASE_TYPES.items())
|
|
114
|
-
BASE_TYPE_CHOICES.sort(key=lambda e: e[0])
|
|
115
178
|
|
|
116
179
|
|
|
117
180
|
APP_WIDGETS = {}
|
|
@@ -134,5 +197,3 @@ for name, app in apps.app_configs.items():
|
|
|
134
197
|
|
|
135
198
|
APP_WIDGET_CHOICES = [(slug, cls.name) for slug, cls in APP_WIDGETS.items()]
|
|
136
199
|
APP_WIDGET_CHOICES.sort(key=lambda e: e[1])
|
|
137
|
-
|
|
138
|
-
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
simo/fleet/admin.py
CHANGED
|
@@ -61,7 +61,7 @@ class ColonelAdmin(admin.ModelAdmin):
|
|
|
61
61
|
readonly_fields = (
|
|
62
62
|
'type', 'uid', 'connected', 'last_seen',
|
|
63
63
|
'firmware_version', 'newer_firmware_available',
|
|
64
|
-
'history',
|
|
64
|
+
'history', 'wake_stats', 'last_wake', 'is_vo_active'
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
actions = (
|
|
@@ -83,6 +83,10 @@ class ColonelAdmin(admin.ModelAdmin):
|
|
|
83
83
|
'fields': ('history',),
|
|
84
84
|
'classes': ('collapse',),
|
|
85
85
|
}),
|
|
86
|
+
("AI Voice Assistant", {
|
|
87
|
+
'fields': ('wake_stats', 'last_wake', 'is_vo_active'),
|
|
88
|
+
'classes': ('collapse',),
|
|
89
|
+
})
|
|
86
90
|
)
|
|
87
91
|
|
|
88
92
|
def get_queryset(self, request):
|
simo/fleet/api.py
CHANGED
|
@@ -5,13 +5,11 @@ from rest_framework import viewsets
|
|
|
5
5
|
from rest_framework.response import Response as RESTResponse
|
|
6
6
|
from rest_framework.decorators import action
|
|
7
7
|
from rest_framework.exceptions import ValidationError as APIValidationError
|
|
8
|
-
from django_filters.rest_framework import DjangoFilterBackend
|
|
9
8
|
from simo.core.api import InstanceMixin
|
|
10
9
|
from simo.core.permissions import IsInstanceSuperuser
|
|
11
|
-
from .models import InstanceOptions, Colonel, Interface
|
|
10
|
+
from .models import InstanceOptions, Colonel, Interface
|
|
12
11
|
from .serializers import (
|
|
13
|
-
InstanceOptionsSerializer, ColonelSerializer, ColonelInterfaceSerializer
|
|
14
|
-
CustomDaliDeviceSerializer
|
|
12
|
+
InstanceOptionsSerializer, ColonelSerializer, ColonelInterfaceSerializer
|
|
15
13
|
)
|
|
16
14
|
|
|
17
15
|
|
|
@@ -106,26 +104,3 @@ class InterfaceViewSet(
|
|
|
106
104
|
def get_queryset(self):
|
|
107
105
|
return Interface.objects.filter(colonel__instance=self.instance)
|
|
108
106
|
|
|
109
|
-
|
|
110
|
-
class CustomDaliDeviceViewSet(InstanceMixin, viewsets.ModelViewSet):
|
|
111
|
-
url = 'fleet/custom-dali-devices'
|
|
112
|
-
basename = 'custom-dali-devices'
|
|
113
|
-
serializer_class = CustomDaliDeviceSerializer
|
|
114
|
-
filter_backends = [DjangoFilterBackend]
|
|
115
|
-
filterset_fields = ['uid', 'random_address', 'name']
|
|
116
|
-
|
|
117
|
-
def get_permissions(self):
|
|
118
|
-
permissions = super().get_permissions()
|
|
119
|
-
permissions.append(IsInstanceSuperuser())
|
|
120
|
-
return permissions
|
|
121
|
-
|
|
122
|
-
def get_queryset(self):
|
|
123
|
-
return CustomDaliDevice.objects.filter(instance=self.instance)
|
|
124
|
-
|
|
125
|
-
def perform_destroy(self, instance):
|
|
126
|
-
if instance.components.all().count():
|
|
127
|
-
raise APIValidationError(
|
|
128
|
-
_('Deleting colonel which has components is not allowed!'),
|
|
129
|
-
code=400
|
|
130
|
-
)
|
|
131
|
-
instance.delete()
|