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
@@ -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
+ ('mcp_server', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='instanceaccesstoken',
15
+ name='date_expired',
16
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 4.2.10 on 2025-09-24 08:07
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('mcp_server', '0002_alter_instanceaccesstoken_date_expired'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='instanceaccesstoken',
15
+ name='issuer',
16
+ field=models.CharField(db_index=True, editable=False, null=True),
17
+ ),
18
+ ]
File without changes
@@ -0,0 +1,27 @@
1
+ from django.db import models
2
+ from simo.core.models import Instance
3
+ from simo.core.utils.helpers import get_random_string
4
+ from simo.core.middleware import get_current_instance
5
+
6
+
7
+ def get_new_token():
8
+ token = get_random_string(size=20)
9
+ instance = get_current_instance()
10
+ if InstanceAccessToken.objects.filter(
11
+ instance=instance, token=token
12
+ ).first():
13
+ return get_new_token()
14
+ return token
15
+
16
+
17
+ class InstanceAccessToken(models.Model):
18
+ instance = models.ForeignKey(Instance, on_delete=models.CASCADE)
19
+ date_created = models.DateTimeField(auto_now_add=True, db_index=True)
20
+ token = models.CharField(
21
+ max_length=20, unique=True, db_index=True, default=get_new_token
22
+ )
23
+ date_expired = models.DateTimeField(null=True, blank=True, db_index=True)
24
+ user = models.ForeignKey(
25
+ 'users.User', null=True, blank=True, on_delete=models.SET_NULL
26
+ )
27
+ issuer = models.CharField(db_index=True, editable=False, null=True)
@@ -0,0 +1,60 @@
1
+ import os
2
+ import logging
3
+ import django
4
+
5
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
6
+ django.setup()
7
+
8
+ from django.apps import apps
9
+ from simo.mcp_server.app import mcp
10
+ from starlette.middleware import Middleware
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+ from fastmcp.server.http import create_streamable_http_app
13
+
14
+
15
+ log = logging.getLogger("simo")
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s %(levelname).1s %(name)s: %(message)s",
19
+ )
20
+
21
+
22
+ def load_tools_from_apps() -> None:
23
+ import importlib.util
24
+
25
+ for cfg in apps.get_app_configs():
26
+ mod_name = f"{cfg.name}.mcp"
27
+
28
+ # Only attempt import if module exists
29
+ if importlib.util.find_spec(mod_name) is None:
30
+ continue
31
+
32
+ try:
33
+ importlib.import_module(mod_name)
34
+ log.info("Loaded MCP tools: %s", mod_name)
35
+ except Exception:
36
+ # Keep the server up; log full traceback and continue
37
+ log.exception("Failed to import %s", mod_name)
38
+
39
+
40
+ class LogExceptions(BaseHTTPMiddleware):
41
+ async def dispatch(self, request, call_next):
42
+ try:
43
+ return await call_next(request)
44
+ except Exception:
45
+ log.exception("Unhandled exception in %s %s", request.method, request.url.path)
46
+ raise # Let Starlette/Uvicorn still return 500
47
+
48
+
49
+ def create_app():
50
+ load_tools_from_apps()
51
+ app = create_streamable_http_app(
52
+ server=mcp,
53
+ streamable_http_path="/",
54
+ auth=mcp.auth,
55
+ json_response=True,
56
+ stateless_http=True,
57
+ debug=True,
58
+ middleware=[Middleware(LogExceptions)],
59
+ )
60
+ return app
@@ -0,0 +1,19 @@
1
+ import datetime
2
+ from django.utils import timezone
3
+ from celeryc import celery_app
4
+
5
+
6
+ @celery_app.task
7
+ def auto_expire_tokens():
8
+ """Auto expire instance access tokens that are older than one day"""
9
+ from .models import InstanceAccessToken
10
+ InstanceAccessToken.objects.filter(
11
+ date_created__lt=timezone.now() - datetime.timedelta(days=1),
12
+ date_expired=None
13
+ ).update(date_expired=timezone.now())
14
+
15
+
16
+
17
+ @celery_app.on_after_finalize.connect
18
+ def setup_periodic_tasks(sender, **kwargs):
19
+ sender.add_periodic_task(60 * 60, auto_expire_tokens.s()) # hourly cron
@@ -1,6 +1,31 @@
1
1
  from django.utils.translation import gettext_lazy as _
2
+ from simo.core.base_types import BaseComponentType
2
3
 
3
- BASE_TYPES = {
4
- 'audio-player': _("Audio Player"),
5
- 'video-player': _("Video Player"),
6
- }
4
+
5
+ class AudioPlayerType(BaseComponentType):
6
+ slug = 'audio-player'
7
+ name = _("Audio Player")
8
+ description = _("Playback control for audio sources.")
9
+ purpose = _("Use to play/pause/stop audio and adjust playback.")
10
+ required_methods = ('play', 'pause', 'stop')
11
+
12
+
13
+ class VideoPlayerType(BaseComponentType):
14
+ slug = 'video-player'
15
+ name = _("Video Player")
16
+ description = _("Playback control for video sources.")
17
+ purpose = _("Use to control video playback in the app.")
18
+ required_methods = ('play', 'pause', 'stop')
19
+
20
+
21
+ def _export_base_types_dict():
22
+ import inspect as _inspect
23
+ mapping = {}
24
+ for _name, _obj in globals().items():
25
+ if _inspect.isclass(_obj) and issubclass(_obj, BaseComponentType) \
26
+ and _obj is not BaseComponentType and getattr(_obj, 'slug', None):
27
+ mapping[_obj.slug] = _obj.name
28
+ return mapping
29
+
30
+
31
+ BASE_TYPES = _export_base_types_dict()
@@ -1,6 +1,7 @@
1
1
  from django.utils.translation import gettext_lazy as _
2
2
  from simo.core.controllers import Switch, TimerMixin
3
3
  from .app_widgets import AudioPlayerWidget, VideoPlayerWidget
4
+ from .base_types import AudioPlayerType, VideoPlayerType
4
5
 
5
6
 
6
7
  class BasePlayer(Switch):
@@ -32,60 +33,103 @@ class BasePlayer(Switch):
32
33
  def _validate_val(self, value, occasion=None):
33
34
  return value
34
35
 
36
+ def send(self, value):
37
+ """Control playback.
38
+
39
+ Parameters:
40
+ - value (str): one of 'play', 'pause', 'stop', 'next', 'previous', or
41
+ - value (dict): one of {'seek': seconds}, {'set_volume': 0-100},
42
+ {'shuffle': bool}, {'loop': bool}, {'alert': id|None},
43
+ {'play_from_library': id, 'volume': int|None, 'fade_in': seconds|None},
44
+ {'play_uri': uri, 'volume': int|None}.
45
+ Prefer using the convenience methods (`play()`, `pause()`, `seek()`, ...).
46
+ """
47
+ return super().send(value)
48
+
35
49
  def play(self):
50
+ """Start or resume playback."""
36
51
  self.send('play')
37
52
 
38
53
  def pause(self):
54
+ """Pause playback (keeps current position)."""
39
55
  self.send('pause')
40
56
 
41
57
  def stop(self):
58
+ """Stop playback and reset position to start (if supported)."""
42
59
  self.send('stop')
43
60
 
44
61
  def seek(self, second):
62
+ """Seek to the specified position in seconds.
63
+
64
+ Parameters:
65
+ - second (int|float): Absolute position from start.
66
+ """
45
67
  self.send({'seek': second})
46
68
 
47
69
  def next(self):
70
+ """Skip to the next item in the queue/playlist."""
48
71
  self.send('next')
49
72
 
50
73
  def previous(self):
74
+ """Return to the previous item in the queue/playlist."""
51
75
  self.send('previous')
52
76
 
53
77
  def set_volume(self, val):
78
+ """Set output volume.
79
+
80
+ Parameters:
81
+ - val (int): Volume percent 0–100.
82
+ """
54
83
  assert 0 <= val <= 100
55
84
  self.component.meta['volume'] = val
56
85
  self.component.save()
57
86
  self.send({'set_volume': val})
58
87
 
59
88
  def get_volume(self):
60
- '''override of possible with something more reliable'''
89
+ """Get the last known volume (0–100).
90
+
91
+ Note: This reads cached meta; device may differ if gateway does not
92
+ report volume changes.
93
+ """
61
94
  return self.component.meta['volume']
62
95
 
63
96
  def set_shuffle_play(self, val):
97
+ """Enable or disable shuffle mode.
98
+
99
+ Parameters:
100
+ - val (bool|int): Truthy to enable, falsy to disable.
101
+ """
64
102
  self.component.meta['shuffle'] = bool(val)
65
103
  self.component.save()
66
104
  self.send({'shuffle': bool(val)})
67
105
 
68
106
  def set_loop_play(self, val):
107
+ """Enable or disable loop/repeat mode.
108
+
109
+ Parameters:
110
+ - val (bool|int): Truthy to enable, falsy to disable.
111
+ """
69
112
  self.component.meta['loop'] = bool(val)
70
113
  self.component.save()
71
114
  self.send({'loop': bool(val)})
72
115
 
73
116
  def play_library_item(self, id, volume=None, fade_in=None):
74
- '''
75
- :param id: Library item ID
76
- :param volume: Volume to play at. Current volume will be used if not provided
77
- :param fade_in: number of seconds to fade in
78
- :return:
79
- '''
117
+ """Play an item from the controller's library.
118
+
119
+ Parameters:
120
+ - id: Library item identifier as provided by the gateway.
121
+ - volume (int|None): Optional volume 0–100; keep current if None.
122
+ - fade_in (int|float|None): Optional seconds to fade in.
123
+ """
80
124
  self.send({'play_from_library': id, 'volume': volume, 'fade_in': fade_in})
81
125
 
82
126
  def play_uri(self, uri, volume=None):
83
- '''
84
- Replace que with this single uri and play it immediately
85
- :param uri: playable uri or url
86
- :param volume: volume at which to play
87
- :return:
88
- '''
127
+ """Replace the queue with a single URI and play immediately.
128
+
129
+ Parameters:
130
+ - uri (str): Playable URI/URL (e.g. file://, http://, spotify:...).
131
+ - volume (int|None): Optional volume 0–100; keep current if None.
132
+ """
89
133
  if volume:
90
134
  assert 0 <= volume <= 100
91
135
  self.send({"play_uri": uri, 'volume': volume})
@@ -105,13 +149,19 @@ class BasePlayer(Switch):
105
149
 
106
150
 
107
151
  def play_alert(self, id):
152
+ """Play a one-shot alert sound by id, then resume previous playback.
153
+
154
+ Parameters:
155
+ - id: Alert sound identifier.
156
+ """
108
157
  self.send({"alert": id})
109
158
 
110
159
  def cancel_alert(self):
111
- '''Cancel alert if it's currently playing'''
160
+ """Cancel an in-progress alert and resume previous playback."""
112
161
  self.send({"alert": None})
113
162
 
114
163
  def toggle(self):
164
+ """Toggle between play and pause based on current state."""
115
165
  if self.component.value == 'playing':
116
166
  self.pause()
117
167
  else:
@@ -121,15 +171,12 @@ class BasePlayer(Switch):
121
171
  class BaseAudioPlayer(BasePlayer):
122
172
  """Base class for audio players"""
123
173
  name = _("Audio Player")
124
- base_type = 'audio-player'
174
+ base_type = AudioPlayerType
125
175
  app_widget = AudioPlayerWidget
126
176
 
127
177
 
128
178
  class BaseVideoPlayer(BasePlayer):
129
179
  """Base class for video players"""
130
180
  name = _("Video Player")
131
- base_type = 'video-player'
181
+ base_type = VideoPlayerType
132
182
  app_widget = VideoPlayerWidget
133
-
134
-
135
-
simo/settings.py CHANGED
@@ -76,6 +76,7 @@ INSTALLED_APPS = [
76
76
  'simo.multimedia',
77
77
  'simo.fleet',
78
78
  'simo.backups',
79
+ 'simo.mcp_server',
79
80
 
80
81
  'django.contrib.admin',
81
82
  'adminsortable2',
simo/users/utils.py CHANGED
@@ -30,6 +30,16 @@ def get_device_user():
30
30
  return device
31
31
 
32
32
 
33
+ def get_ai_user():
34
+ from .models import User
35
+ device, new = User.objects.get_or_create(
36
+ email='ai@simo.io', defaults={
37
+ 'name': "AI"
38
+ }
39
+ )
40
+ return device
41
+
42
+
33
43
  def rebuild_authorized_keys():
34
44
  from .models import User
35
45
  try:
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simo
3
- Version: 2.11.4
3
+ Version: 3.0.4
4
4
  Summary: Smart Home Supremacy
5
- Author-email: Simanas Venčkauskas <simanas@simo.io>
5
+ Author-email: "Simon V." <simon@simo.io>
6
6
  Project-URL: Homepage, https://simo.io
7
- Project-URL: Issues, https://github.com/pypa/sampleproject/issues
7
+ Project-URL: Issues, https://github.com/SIMO-io/simo/issues
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.8
11
+ Requires-Python: >=3.12
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE.md
14
14
  Requires-Dist: Django==4.2.10
@@ -44,14 +44,21 @@ Requires-Dist: django-location-field==2.1.0
44
44
  Requires-Dist: slugify==0.0.1
45
45
  Requires-Dist: django-countries==7.5.1
46
46
  Requires-Dist: librosa==0.10.1
47
+ Requires-Dist: lameenc==1.8.1
47
48
  Requires-Dist: daphne==4.1.2
48
49
  Requires-Dist: Pillow==9.5.0
49
50
  Requires-Dist: django-markdownify==0.9.5
50
51
  Requires-Dist: django-activity-stream==2.0.0
51
52
  Requires-Dist: gunicorn==23.0.0
52
53
  Requires-Dist: python-crontab==3.2.0
54
+ Requires-Dist: pydub==0.25.1
53
55
  Requires-Dist: django-object-actions==4.3.0
54
56
  Requires-Dist: tqdm==4.67.0
55
57
  Requires-Dist: bs4==0.0.2
56
58
  Requires-Dist: lxml==5.3.0
59
+ Requires-Dist: websockets==15.0.1
60
+ Requires-Dist: lameenc==1.8.1
61
+ Requires-Dist: pydub==0.25.1
62
+ Requires-Dist: fastmcp==2.12.3
63
+ Requires-Dist: uvicorn==0.35.0
57
64
  Dynamic: license-file