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.

Files changed (91) hide show
  1. simo/__pycache__/settings.cpython-312.pyc +0 -0
  2. simo/asgi.py +25 -6
  3. simo/automation/__pycache__/controllers.cpython-312.pyc +0 -0
  4. simo/automation/controllers.py +18 -2
  5. simo/automation/forms.py +15 -24
  6. simo/automation/gateways.py +32 -16
  7. simo/core/__pycache__/admin.cpython-312.pyc +0 -0
  8. simo/core/__pycache__/base_types.cpython-312.pyc +0 -0
  9. simo/core/__pycache__/controllers.cpython-312.pyc +0 -0
  10. simo/core/__pycache__/forms.cpython-312.pyc +0 -0
  11. simo/core/__pycache__/models.cpython-312.pyc +0 -0
  12. simo/core/__pycache__/serializers.cpython-312.pyc +0 -0
  13. simo/core/__pycache__/signal_receivers.cpython-312.pyc +0 -0
  14. simo/core/__pycache__/tasks.cpython-312.pyc +0 -0
  15. simo/core/admin.py +5 -4
  16. simo/core/base_types.py +191 -18
  17. simo/core/controllers.py +259 -26
  18. simo/core/forms.py +10 -2
  19. simo/core/management/_hub_template/hub/nginx.conf +23 -50
  20. simo/core/management/_hub_template/hub/supervisor.conf +15 -0
  21. simo/core/mcp.py +154 -0
  22. simo/core/migrations/0051_instance_ai_memory.py +18 -0
  23. simo/core/migrations/__pycache__/0051_instance_ai_memory.cpython-312.pyc +0 -0
  24. simo/core/models.py +3 -0
  25. simo/core/serializers.py +120 -0
  26. simo/core/signal_receivers.py +1 -1
  27. simo/core/tasks.py +1 -3
  28. simo/core/utils/__pycache__/type_constants.cpython-312.pyc +0 -0
  29. simo/core/utils/type_constants.py +78 -17
  30. simo/fleet/__pycache__/admin.cpython-312.pyc +0 -0
  31. simo/fleet/__pycache__/api.cpython-312.pyc +0 -0
  32. simo/fleet/__pycache__/base_types.cpython-312.pyc +0 -0
  33. simo/fleet/__pycache__/controllers.cpython-312.pyc +0 -0
  34. simo/fleet/__pycache__/forms.cpython-312.pyc +0 -0
  35. simo/fleet/__pycache__/gateways.cpython-312.pyc +0 -0
  36. simo/fleet/__pycache__/models.cpython-312.pyc +0 -0
  37. simo/fleet/__pycache__/serializers.cpython-312.pyc +0 -0
  38. simo/fleet/admin.py +5 -1
  39. simo/fleet/api.py +2 -27
  40. simo/fleet/base_types.py +35 -4
  41. simo/fleet/controllers.py +162 -156
  42. simo/fleet/forms.py +58 -88
  43. simo/fleet/gateways.py +8 -15
  44. simo/fleet/migrations/0055_colonel_is_vo_active_colonel_last_wake_and_more.py +28 -0
  45. simo/fleet/migrations/0056_delete_customdalidevice.py +16 -0
  46. simo/fleet/migrations/__pycache__/0055_colonel_is_vo_active_colonel_last_wake_and_more.cpython-312.pyc +0 -0
  47. simo/fleet/migrations/__pycache__/0056_delete_customdalidevice.cpython-312.pyc +0 -0
  48. simo/fleet/models.py +13 -72
  49. simo/fleet/serializers.py +1 -48
  50. simo/fleet/socket_consumers.py +100 -39
  51. simo/fleet/tasks.py +2 -22
  52. simo/fleet/voice_assistant.py +903 -0
  53. simo/generic/__pycache__/base_types.cpython-312.pyc +0 -0
  54. simo/generic/__pycache__/controllers.cpython-312.pyc +0 -0
  55. simo/generic/__pycache__/gateways.cpython-312.pyc +0 -0
  56. simo/generic/base_types.py +70 -10
  57. simo/generic/controllers.py +104 -17
  58. simo/generic/gateways.py +10 -10
  59. simo/mcp_server/__init__.py +0 -0
  60. simo/mcp_server/__pycache__/__init__.cpython-312.pyc +0 -0
  61. simo/mcp_server/__pycache__/admin.cpython-312.pyc +0 -0
  62. simo/mcp_server/__pycache__/models.cpython-312.pyc +0 -0
  63. simo/mcp_server/admin.py +18 -0
  64. simo/mcp_server/app.py +4 -0
  65. simo/mcp_server/auth.py +34 -0
  66. simo/mcp_server/dummy.py +22 -0
  67. simo/mcp_server/migrations/0001_initial.py +30 -0
  68. simo/mcp_server/migrations/0002_alter_instanceaccesstoken_date_expired.py +18 -0
  69. simo/mcp_server/migrations/0003_instanceaccesstoken_issuer.py +18 -0
  70. simo/mcp_server/migrations/__init__.py +0 -0
  71. simo/mcp_server/migrations/__pycache__/0001_initial.cpython-312.pyc +0 -0
  72. simo/mcp_server/migrations/__pycache__/0002_alter_instanceaccesstoken_date_expired.cpython-312.pyc +0 -0
  73. simo/mcp_server/migrations/__pycache__/0003_instanceaccesstoken_issuer.cpython-312.pyc +0 -0
  74. simo/mcp_server/migrations/__pycache__/__init__.cpython-312.pyc +0 -0
  75. simo/mcp_server/models.py +27 -0
  76. simo/mcp_server/server.py +60 -0
  77. simo/mcp_server/tasks.py +19 -0
  78. simo/multimedia/__pycache__/base_types.cpython-312.pyc +0 -0
  79. simo/multimedia/__pycache__/controllers.cpython-312.pyc +0 -0
  80. simo/multimedia/base_types.py +29 -4
  81. simo/multimedia/controllers.py +66 -19
  82. simo/settings.py +1 -0
  83. simo/users/__pycache__/utils.cpython-312.pyc +0 -0
  84. simo/users/utils.py +10 -0
  85. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/METADATA +11 -4
  86. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/RECORD +90 -64
  87. simo/fleet/custom_dali_operations.py +0 -287
  88. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/WHEEL +0 -0
  89. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/entry_points.txt +0 -0
  90. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/licenses/LICENSE.md +0 -0
  91. {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
- self.instance.base_type = self.controller.base_type
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 80 default_server;
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
- # gunicorn
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
+ ]
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
+
@@ -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 = True
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()
@@ -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
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, CustomDaliDevice
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()