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
@@ -1,12 +1,72 @@
1
1
  from django.utils.translation import gettext_lazy as _
2
+ from simo.core.base_types import BaseComponentType
2
3
 
3
- BASE_TYPES = {
4
- 'script': _("Script"),
5
- 'thermostat': _("Thermostat"),
6
- 'alarm-group': _("Alarm Group"),
7
- 'ip-camera': _("IP Camera"),
8
- 'weather': _("Weather"),
9
- 'watering': _("Watering"),
10
- 'state-select': _("State Select"),
11
- 'alarm-clock': _("Alarm Clock"),
12
- }
4
+
5
+ class ScriptType(BaseComponentType):
6
+ slug = 'script'
7
+ name = _("Script")
8
+ description = _("Runs user-defined logic or actions.")
9
+ purpose = _("Use for programmable behaviors or routines.")
10
+
11
+
12
+ class ThermostatType(BaseComponentType):
13
+ slug = 'thermostat'
14
+ name = _("Thermostat")
15
+ description = _("Thermal control orchestrating heaters/coolers.")
16
+ purpose = _("Use to maintain target temperature based on sensors.")
17
+
18
+
19
+ class AlarmGroupType(BaseComponentType):
20
+ slug = 'alarm-group'
21
+ name = _("Alarm Group")
22
+ description = _("Aggregates security components into a single state.")
23
+ purpose = _("Use to arm/disarm and observe overall security status.")
24
+ required_methods = ('arm', 'disarm')
25
+
26
+
27
+ class IPCameraType(BaseComponentType):
28
+ slug = 'ip-camera'
29
+ name = _("IP Camera")
30
+ description = _("Network camera streaming and snapshots.")
31
+ purpose = _("Use to integrate RTSP-capable cameras.")
32
+
33
+
34
+ class WeatherType(BaseComponentType):
35
+ slug = 'weather'
36
+ name = _("Weather")
37
+ description = _("Aggregated weather information for the instance.")
38
+ purpose = _("Use to display external weather data.")
39
+
40
+
41
+ class WateringType(BaseComponentType):
42
+ slug = 'watering'
43
+ name = _("Watering")
44
+ description = _("Irrigation control with programs and manual runs.")
45
+ purpose = _("Use to drive valves/pumps for garden irrigation.")
46
+
47
+
48
+ class StateSelectType(BaseComponentType):
49
+ slug = 'state-select'
50
+ name = _("State Select")
51
+ description = _("Pick one of predefined states.")
52
+ purpose = _("Use to represent and switch between modes/states.")
53
+
54
+
55
+ class AlarmClockType(BaseComponentType):
56
+ slug = 'alarm-clock'
57
+ name = _("Alarm Clock")
58
+ description = _("User-configurable alarm schedule and events.")
59
+ purpose = _("Use to trigger actions at specific times.")
60
+
61
+
62
+ def _export_base_types_dict():
63
+ import inspect as _inspect
64
+ mapping = {}
65
+ for _name, _obj in globals().items():
66
+ if _inspect.isclass(_obj) and issubclass(_obj, BaseComponentType) \
67
+ and _obj is not BaseComponentType and getattr(_obj, 'slug', None):
68
+ mapping[_obj.slug] = _obj.name
69
+ return mapping
70
+
71
+
72
+ BASE_TYPES = _export_base_types_dict()
@@ -18,6 +18,10 @@ from simo.core.controllers import (
18
18
  RGBWLight,
19
19
  DoubleSwitch, TripleSwitch, QuadrupleSwitch, QuintupleSwitch
20
20
  )
21
+ from .base_types import (
22
+ ThermostatType, AlarmGroupType, WeatherType, IPCameraType,
23
+ WateringType, StateSelectType, AlarmClockType
24
+ )
21
25
  from simo.core.utils.config_values import (
22
26
  BooleanConfigValue, FloatConfigValue,
23
27
  TimeTempConfigValue, ThermostatModeConfigValue,
@@ -57,7 +61,7 @@ class SwitchGroup(Switch):
57
61
 
58
62
  class Thermostat(ControllerBase):
59
63
  name = _("Thermostat")
60
- base_type = 'thermostat'
64
+ base_type = ThermostatType
61
65
  gateway_class = GenericGatewayHandler
62
66
  app_widget = ThermostatWidget
63
67
  config_form = ThermostatConfigForm
@@ -66,6 +70,7 @@ class Thermostat(ControllerBase):
66
70
  'current_temp': 21, 'target_temp': 22,
67
71
  'heating': False, 'cooling': False
68
72
  }
73
+ accepts_value = False
69
74
 
70
75
  @property
71
76
  def default_config(self):
@@ -142,12 +147,18 @@ class Thermostat(ControllerBase):
142
147
  target_temp = sorted_options[-1][1]
143
148
  for timestr, target in sorted_options:
144
149
  start_second = int(timestr.split(':')[0]) * 3600 \
145
- + int(timestr.split(':')[1] * 60)
150
+ + int(timestr.split(':')[1]) * 60
146
151
  if start_second < current_second:
147
152
  target_temp = target
148
153
  return target_temp
149
154
 
150
155
  def get_current_target_temperature(self):
156
+ """Return active target temperature from user config.
157
+
158
+ Computes the target based on hard/daily/weekly schedules and the
159
+ current local time.
160
+ Returns: float temperature.
161
+ """
151
162
  data = self.component.config['user_config']
152
163
  if data['hard']['active']:
153
164
  return data['hard']['target']
@@ -157,8 +168,7 @@ class Thermostat(ControllerBase):
157
168
  return self._get_target_from_options(
158
169
  data['weekly'][str(localtime.weekday() + 1)])
159
170
 
160
- def evaluate(self):
161
-
171
+ def _evaluate(self):
162
172
  from simo.core.models import Component
163
173
  self.component.refresh_from_db()
164
174
  tz = pytz.timezone(self.component.zone.instance.timezone)
@@ -341,6 +351,12 @@ class Thermostat(ControllerBase):
341
351
 
342
352
 
343
353
  def update_user_conf(self, new_conf):
354
+ """Update thermostat user configuration.
355
+
356
+ Parameters:
357
+ - new_conf (dict): Partial or full user_config; validated and merged
358
+ with defaults. Triggers re-evaluation after save.
359
+ """
344
360
  self.component.refresh_from_db()
345
361
  self.component.config['user_config'] = validate_new_conf(
346
362
  new_conf,
@@ -348,10 +364,16 @@ class Thermostat(ControllerBase):
348
364
  self._get_default_user_config()
349
365
  )
350
366
  self.component.save()
351
- self.evaluate()
367
+ self._evaluate()
352
368
 
353
369
 
354
370
  def hold(self, temperature=None):
371
+ """Hold a temporary target temperature.
372
+
373
+ Parameters:
374
+ - temperature (float|None): If provided, enables hard hold at this
375
+ target; if None, disables hard hold to resume schedules.
376
+ """
355
377
  if temperature != None:
356
378
  self.component.config['user_config']['hard'] = {
357
379
  'active': True, 'target': temperature
@@ -363,7 +385,7 @@ class Thermostat(ControllerBase):
363
385
 
364
386
  class AlarmGroup(ControllerBase):
365
387
  name = _("Alarm Group")
366
- base_type = 'alarm-group'
388
+ base_type = AlarmGroupType
367
389
  gateway_class = GenericGatewayHandler
368
390
  app_widget = AlarmGroupWidget
369
391
  config_form = AlarmGroupConfigForm
@@ -386,18 +408,31 @@ class AlarmGroup(ControllerBase):
386
408
  )
387
409
  return value
388
410
 
411
+ def send(self, value):
412
+ """Set group state.
413
+
414
+ Parameters:
415
+ - value (str): 'armed', 'disarmed' (or 'breached' by system).
416
+ Prefer using `arm()` and `disarm()` helpers.
417
+ """
418
+ return super().send(value)
419
+
389
420
  def arm(self):
421
+ """Arm the entire group (children remain in their states)."""
390
422
  self.send('armed')
391
423
 
392
424
  def disarm(self):
425
+ """Disarm the entire group."""
393
426
  self.send('disarmed')
394
427
 
395
428
  def get_children(self):
429
+ """Return the queryset of child components that form this group."""
396
430
  return Component.objects.filter(
397
431
  pk__in=self.component.config['components']
398
432
  )
399
433
 
400
434
  def refresh_status(self):
435
+ """Recompute and persist the group's aggregated security status."""
401
436
  stats = {
402
437
  'disarmed': 0, 'pending-arm': 0, 'armed': 0, 'breached': 0
403
438
  }
@@ -435,7 +470,7 @@ class AlarmGroup(ControllerBase):
435
470
 
436
471
  class Weather(ControllerBase):
437
472
  name = _("Weather")
438
- base_type = 'weather'
473
+ base_type = WeatherType
439
474
  gateway_class = GenericGatewayHandler
440
475
  config_form = WeatherForm
441
476
  app_widget = WeatherWidget
@@ -443,6 +478,7 @@ class Weather(ControllerBase):
443
478
  default_config = {}
444
479
  default_value = {}
445
480
  manual_add = False
481
+ accepts_value = False
446
482
 
447
483
  def _validate_val(self, value, occasion=None):
448
484
  return value
@@ -450,18 +486,20 @@ class Weather(ControllerBase):
450
486
 
451
487
  class IPCamera(ControllerBase):
452
488
  name = _("IP Camera")
453
- base_type = 'ip-camera'
489
+ base_type = IPCameraType
454
490
  gateway_class = GenericGatewayHandler
455
491
  app_widget = IPCameraWidget
456
492
  config_form = IPCameraConfigForm
457
493
  admin_widget_template = 'admin/controller_widgets/ip_camera.html'
458
494
  default_config = {'rtsp_address': ''}
459
495
  default_value = ''
496
+ accepts_value = False
460
497
 
461
498
  def _validate_val(self, value, occasion=None):
462
499
  raise ValidationError("This component type does not accept set value!")
463
500
 
464
501
  def get_stream_socket_url(self):
502
+ """Return the Channels WebSocket URL for live RTSP streaming."""
465
503
  return reverse_lazy(
466
504
  'ws-cam-stream', kwargs={'component_id': self.component.id},
467
505
  urlconf=settings.CHANNELS_URLCONF
@@ -474,7 +512,7 @@ class Watering(ControllerBase):
474
512
  'paused_program', 'paused_custom'
475
513
  )
476
514
  name = _("Watering")
477
- base_type = 'watering'
515
+ base_type = WateringType
478
516
  gateway_class = GenericGatewayHandler
479
517
  config_form = WateringConfigForm
480
518
  app_widget = WateringWidget
@@ -525,8 +563,19 @@ class Watering(ControllerBase):
525
563
  )
526
564
  return value
527
565
 
566
+ def send(self, value):
567
+ """Control watering.
568
+
569
+ Parameters:
570
+ - value (str): 'start', 'pause', 'reset', or
571
+ - value (dict): {'status': <status>, 'program_progress': <minute>}
572
+ Prefer `start()`, `pause()`, `reset()`, `set_program_progress()`.
573
+ """
574
+ return super().send(value)
575
+
528
576
 
529
577
  def start(self):
578
+ """Start the watering program at current or last progress point."""
530
579
  self.component.refresh_from_db()
531
580
  if not self.component.value.get('program_progress', 0):
532
581
  self.component.meta['last_run'] = timezone.now().timestamp()
@@ -538,9 +587,11 @@ class Watering(ControllerBase):
538
587
  self.set_program_progress(self.component.value['program_progress'])
539
588
 
540
589
  def play(self):
590
+ """Alias for `start()` (for consistency with media-like controls)."""
541
591
  return self.start()
542
592
 
543
593
  def pause(self):
594
+ """Pause the watering program and disengage all contours."""
544
595
  self.component.refresh_from_db()
545
596
  self.set({
546
597
  'status': 'paused_program',
@@ -549,13 +600,15 @@ class Watering(ControllerBase):
549
600
  self.disengage_all()
550
601
 
551
602
  def reset(self):
603
+ """Stop the watering program and reset progress to 0."""
552
604
  self.set({'status': 'stopped', 'program_progress': 0})
553
605
  self.disengage_all()
554
606
 
555
607
  def stop(self):
608
+ """Alias for `reset()` to stop the program."""
556
609
  return self.reset()
557
610
 
558
- def set_program_progress(self, program_minute, run=True):
611
+ def _set_program_progress(self, program_minute, run=True):
559
612
  engaged_contours = []
560
613
  for flow_data in self.component.config['program']['flow']:
561
614
  if flow_data['minute'] <= program_minute:
@@ -588,6 +641,11 @@ class Watering(ControllerBase):
588
641
  )
589
642
 
590
643
  def ai_assist_update(self, data):
644
+ """Update AI-assistant computed watering parameters.
645
+
646
+ Parameters:
647
+ - data (dict): Partial program/schedule/contour updates from AI.
648
+ """
591
649
  for key, val in data.items():
592
650
  assert key in ('ai_assist', 'soil_type', 'ai_assist_level')
593
651
  if key == 'ai_assist':
@@ -602,6 +660,11 @@ class Watering(ControllerBase):
602
660
  self.component.save()
603
661
 
604
662
  def contours_update(self, contours):
663
+ """Replace contours config and rebuild program accordingly.
664
+
665
+ Parameters:
666
+ - contours (list[dict]): Contour entries with uid and runtime updates.
667
+ """
605
668
  current_contours = {
606
669
  c['uid']: c
607
670
  for c in self.component.config.get('contours')
@@ -618,6 +681,7 @@ class Watering(ControllerBase):
618
681
  self.component.save()
619
682
 
620
683
  def schedule_update(self, new_schedule):
684
+ """Replace schedule config and rebuild program accordingly."""
621
685
  self.component.refresh_from_db()
622
686
  self.component.config['schedule'] = validate_new_conf(
623
687
  new_schedule,
@@ -686,6 +750,7 @@ class Watering(ControllerBase):
686
750
  return {'duration': 0, 'flow': []}
687
751
 
688
752
  def disengage_all(self):
753
+ """Turn off all configured watering contours immediately."""
689
754
  for contour_data in self.component.config['contours']:
690
755
  try:
691
756
  switch = Component.objects.get(pk=contour_data['switch'])
@@ -784,7 +849,7 @@ class Watering(ControllerBase):
784
849
 
785
850
  class AlarmClock(ControllerBase):
786
851
  name = _("Alarm Clock")
787
- base_type = 'alarm-clock'
852
+ base_type = AlarmClockType
788
853
  gateway_class = GenericGatewayHandler
789
854
  config_form = AlarmClockConfigForm
790
855
  app_widget = AlarmClockWidget
@@ -795,6 +860,7 @@ class AlarmClock(ControllerBase):
795
860
  'events_triggered': [],
796
861
  'alarm_timestamp': None
797
862
  }
863
+ accepts_value = False
798
864
 
799
865
  def _validate_val(self, value, occasion=None):
800
866
  # this component does not accept value set.
@@ -1058,13 +1124,14 @@ class AlarmClock(ControllerBase):
1058
1124
 
1059
1125
  return current_value
1060
1126
 
1061
- def tick(self):
1127
+ def _tick(self):
1062
1128
  self.component.value = self._check_alarm(
1063
1129
  self.component.meta, self.component.value
1064
1130
  )
1065
1131
  self.component.save()
1066
1132
 
1067
1133
  def play_all(self):
1134
+ """Execute all enabled alarm events immediately for the current alarm."""
1068
1135
  alarms = self.component.meta
1069
1136
  current_value = self.component.value
1070
1137
 
@@ -1100,6 +1167,7 @@ class AlarmClock(ControllerBase):
1100
1167
 
1101
1168
 
1102
1169
  def cancel_all(self):
1170
+ """Cancel all enabled events of the current alarm and move to next alarm."""
1103
1171
  alarms = self.component.meta
1104
1172
  current_value = self.component.value
1105
1173
 
@@ -1133,6 +1201,12 @@ class AlarmClock(ControllerBase):
1133
1201
  return self.component.value
1134
1202
 
1135
1203
  def snooze(self, mins):
1204
+ """Delay the current alarm by a number of minutes.
1205
+
1206
+ Parameters:
1207
+ - mins (int): Minutes to postpone both the alarm and all its events.
1208
+ Returns updated clock state.
1209
+ """
1136
1210
  current_value = self.component.value
1137
1211
  localtime = timezone.localtime()
1138
1212
  if not current_value.get('in_alarm'):
@@ -1165,6 +1239,11 @@ class AudioAlert(Switch):
1165
1239
  config_form = AudioAlertConfigForm
1166
1240
 
1167
1241
  def send(self, value):
1242
+ """Trigger or cancel an alert on all configured player components.
1243
+
1244
+ Parameters:
1245
+ - value (bool): True to play alert; False to cancel.
1246
+ """
1168
1247
  for player in Component.objects.filter(
1169
1248
  id__in=self.component.config['players']
1170
1249
  ):
@@ -1178,7 +1257,7 @@ class AudioAlert(Switch):
1178
1257
  class StateSelect(ControllerBase):
1179
1258
  gateway_class = GenericGatewayHandler
1180
1259
  name = _("State select")
1181
- base_type = 'state-select'
1260
+ base_type = StateSelectType
1182
1261
  app_widget = StateSelectWidget
1183
1262
  config_form = StateSelectForm
1184
1263
 
@@ -1197,6 +1276,14 @@ class StateSelect(ControllerBase):
1197
1276
  raise ValidationError("Unsupported value!")
1198
1277
  return value
1199
1278
 
1279
+ def send(self, value):
1280
+ """Select state by slug.
1281
+
1282
+ Parameters:
1283
+ - value (str): Must match one of configured state slugs.
1284
+ """
1285
+ return super().send(value)
1286
+
1200
1287
 
1201
1288
  class MainState(StateSelect):
1202
1289
  name = _("Main State")
@@ -1232,7 +1319,7 @@ class MainState(StateSelect):
1232
1319
  ]
1233
1320
  }
1234
1321
 
1235
- def get_day_evening_night_morning(self):
1322
+ def _get_day_evening_night_morning(self):
1236
1323
  from simo.automation.helpers import LocalSun
1237
1324
  sun = LocalSun(self.component.zone.instance.location)
1238
1325
  timezone.activate(self.component.zone.instance.timezone)
@@ -1257,7 +1344,7 @@ class MainState(StateSelect):
1257
1344
  return 'night'
1258
1345
 
1259
1346
 
1260
- def check_is_away(self, last_sensor_action):
1347
+ def _check_is_away(self, last_sensor_action):
1261
1348
  away_on_no_action = self.component.config.get('away_on_no_action')
1262
1349
  if not away_on_no_action:
1263
1350
  return False
@@ -1271,7 +1358,7 @@ class MainState(StateSelect):
1271
1358
  return (time.time() - last_sensor_action) // 60 >= away_on_no_action
1272
1359
 
1273
1360
 
1274
- def is_sleep_time(self):
1361
+ def _is_sleep_time(self):
1275
1362
  timezone.activate(self.component.zone.instance.timezone)
1276
1363
  localtime = timezone.localtime()
1277
1364
  if localtime.weekday() < 5:
@@ -1289,7 +1376,7 @@ class MainState(StateSelect):
1289
1376
  return False
1290
1377
 
1291
1378
 
1292
- def owner_phones_on_charge(self, all_phones=False):
1379
+ def _owner_phones_on_charge(self, all_phones=False):
1293
1380
  sleeping_phones_hour = self.component.config.get('sleeping_phones_hour')
1294
1381
  if sleeping_phones_hour is None:
1295
1382
  return False
simo/generic/gateways.py CHANGED
@@ -206,7 +206,7 @@ class GenericGatewayHandler(
206
206
  ):
207
207
  tz = pytz.timezone(thermostat.zone.instance.timezone)
208
208
  timezone.activate(tz)
209
- thermostat.evaluate()
209
+ thermostat._evaluate()
210
210
 
211
211
 
212
212
  def watch_alarm_clocks(self):
@@ -217,7 +217,7 @@ class GenericGatewayHandler(
217
217
  ):
218
218
  tz = pytz.timezone(alarm_clock.zone.instance.timezone)
219
219
  timezone.activate(tz)
220
- alarm_clock.tick()
220
+ alarm_clock._tick()
221
221
 
222
222
 
223
223
  def watch_watering(self):
@@ -227,7 +227,7 @@ class GenericGatewayHandler(
227
227
  tz = pytz.timezone(watering.zone.instance.timezone)
228
228
  timezone.activate(tz)
229
229
  if watering.value['status'] == 'running_program':
230
- watering.set_program_progress(
230
+ watering._set_program_progress(
231
231
  watering.value['program_progress'] + 1
232
232
  )
233
233
  else:
@@ -340,7 +340,7 @@ class GenericGatewayHandler(
340
340
  def set_get_day_evening_night_morning(self, state):
341
341
  if state.value not in ('day', 'night', 'evening', 'morning'):
342
342
  return
343
- new_state = state.get_day_evening_night_morning()
343
+ new_state = state._get_day_evening_night_morning()
344
344
  if new_state == state.value:
345
345
  self.last_set_state = state.value
346
346
  return
@@ -377,7 +377,7 @@ class GenericGatewayHandler(
377
377
  self.sensors_on_watch[state.id][sensor.id] = i_id
378
378
  sensor.on_change(self.security_sensor_change)
379
379
 
380
- if state.check_is_away(self.last_sensor_actions.get(i_id, 0)):
380
+ if state._check_is_away(self.last_sensor_actions.get(i_id, 0)):
381
381
  if state.value != 'away':
382
382
  print(f"New main state of "
383
383
  f"{state.zone.instance} - away")
@@ -385,7 +385,7 @@ class GenericGatewayHandler(
385
385
  else:
386
386
  if state.value == 'away':
387
387
  try:
388
- new_state = state.get_day_evening_night_morning()
388
+ new_state = state._get_day_evening_night_morning()
389
389
  except:
390
390
  new_state = 'day'
391
391
  print(f"New main state of "
@@ -394,14 +394,14 @@ class GenericGatewayHandler(
394
394
 
395
395
  if state.config.get('sleeping_phones_hour') is not None:
396
396
  if state.value != 'sleep':
397
- if state.is_sleep_time() and state.owner_phones_on_charge(True):
397
+ if state._is_sleep_time() and state._owner_phones_on_charge(True):
398
398
  print(f"New main state of {state.zone.instance} - sleep")
399
399
  state.send('sleep')
400
400
  else:
401
- if not state.owner_phones_on_charge(True) \
402
- and not state.is_sleep_time():
401
+ if not state._owner_phones_on_charge(True) \
402
+ and not state._is_sleep_time():
403
403
  try:
404
- new_state = state.get_day_evening_night_morning()
404
+ new_state = state._get_day_evening_night_morning()
405
405
  except:
406
406
  new_state = 'day'
407
407
  print(f"New main state of "
File without changes
@@ -0,0 +1,18 @@
1
+ from django.contrib import admin
2
+ from django.utils.safestring import mark_safe
3
+ from django.templatetags.static import static
4
+ from .models import InstanceAccessToken
5
+
6
+
7
+ @admin.register(InstanceAccessToken)
8
+ class InstanceAccessTokenAdmin(admin.ModelAdmin):
9
+ list_display = 'token', 'instance', 'user', 'is_valid'
10
+
11
+ def is_valid(self, obj):
12
+ if obj.date_expired:
13
+ return mark_safe(
14
+ '<img src="%s" alt="False">' % static('admin/img/icon-no.svg')
15
+ )
16
+ return mark_safe(
17
+ '<img src="%s" alt="True">' % static('admin/img/icon-yes.svg')
18
+ )
simo/mcp_server/app.py ADDED
@@ -0,0 +1,4 @@
1
+ from fastmcp import FastMCP
2
+ from .auth import DjangoTokenVerifier
3
+
4
+ mcp = FastMCP(name="SIMO_MCP", auth=DjangoTokenVerifier())
@@ -0,0 +1,34 @@
1
+ from typing import Any, Optional
2
+ from asgiref.sync import sync_to_async
3
+ from simo.mcp_server.models import InstanceAccessToken
4
+ from simo.core.middleware import introduce_instance
5
+ from fastmcp.server.auth.auth import AccessToken, TokenVerifier
6
+
7
+
8
+
9
+ class DjangoTokenVerifier(TokenVerifier):
10
+
11
+ async def verify_token(self, token: str) -> Optional[AccessToken]:
12
+
13
+ def _load():
14
+ access_token = InstanceAccessToken.objects.select_related(
15
+ "instance"
16
+ ).filter(
17
+ token=token, date_expired=None
18
+ ).first()
19
+ if not access_token:
20
+ return
21
+ introduce_instance(access_token.instance)
22
+ return access_token
23
+
24
+ access_token = await sync_to_async(_load, thread_sensitive=True)()
25
+ if not access_token:
26
+ return None
27
+
28
+ # Build a minimal AccessToken; scopes optional
29
+ return AccessToken(
30
+ token = token,
31
+ client_id=str(access_token.instance.id),
32
+ scopes=["simo:read", "simo:write"],
33
+ claims={"instance_id": access_token.instance.id},
34
+ )
@@ -0,0 +1,22 @@
1
+ """
2
+ Dummy MCP server to run mcp inspector
3
+ mcp dev simo/mcp_server/dummy.py
4
+ """
5
+ import uvicorn
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ mcp = FastMCP("Dummy MCP")
9
+
10
+ def create_app():
11
+ return mcp.streamable_http_app()
12
+
13
+
14
+ if __name__ == "__main__":
15
+ uvicorn.run(
16
+ "simo.mcp_server.run:create_app",
17
+ host="0.0.0.0",
18
+ port=3333,
19
+ factory=True,
20
+ log_config=None
21
+ )
22
+
@@ -0,0 +1,30 @@
1
+ # Generated by Django 4.2.10 on 2025-09-23 06:24
2
+
3
+ from django.conf import settings
4
+ from django.db import migrations, models
5
+ import django.db.models.deletion
6
+ import simo.mcp_server.models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ initial = True
12
+
13
+ dependencies = [
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ('core', '0050_componenthistory_alive'),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.CreateModel(
20
+ name='InstanceAccessToken',
21
+ fields=[
22
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23
+ ('date_created', models.DateTimeField(auto_now_add=True, db_index=True)),
24
+ ('token', models.CharField(db_index=True, default=simo.mcp_server.models.get_new_token, max_length=20, unique=True)),
25
+ ('date_expired', models.DateTimeField(db_index=True, null=True)),
26
+ ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.instance')),
27
+ ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
28
+ ],
29
+ ),
30
+ ]