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
Binary file
simo/asgi.py CHANGED
@@ -1,4 +1,4 @@
1
- import importlib, datetime, sys
1
+ import importlib, importlib.util, datetime, sys, traceback
2
2
  from django.core.asgi import get_asgi_application
3
3
  from channels.auth import AuthMiddlewareStack
4
4
  from channels.routing import ProtocolTypeRouter, URLRouter
@@ -13,13 +13,32 @@ for name, app in apps.app_configs.items():
13
13
  'staticfiles'
14
14
  ):
15
15
  continue
16
+
17
+ module_path = f"{app.name}.routing"
18
+
19
+ # Only attempt import if the module exists
20
+ spec = importlib.util.find_spec(module_path)
21
+ if spec is None:
22
+ continue
23
+
24
+ # Import and collect urlpatterns; on failure, print full traceback and continue
25
+ try:
26
+ routing = importlib.import_module(module_path)
27
+ except Exception:
28
+ print(
29
+ f"Failed to import {module_path}:\n{traceback.format_exc()}"
30
+ )
31
+ continue
32
+
16
33
  try:
17
- routing = importlib.import_module('%s.routing' % app.name)
18
- except ModuleNotFoundError:
34
+ app_urlpatterns = getattr(routing, 'urlpatterns', None)
35
+ if isinstance(app_urlpatterns, list):
36
+ urlpatterns.extend(app_urlpatterns)
37
+ except Exception:
38
+ print(
39
+ f"Error while processing urlpatterns from {module_path}:\n{traceback.format_exc()}"
40
+ )
19
41
  continue
20
- for var_name, item in routing.__dict__.items():
21
- if isinstance(item, list) and var_name == 'urlpatterns':
22
- urlpatterns.extend(item)
23
42
 
24
43
 
25
44
  class TimestampedStream:
@@ -63,24 +63,38 @@ class Script(ControllerBase, TimerMixin):
63
63
  return 'stopped'
64
64
 
65
65
  def start(self, new_code=None):
66
+ """Start the script process (optionally updating source code).
67
+
68
+ Parameters:
69
+ - new_code (str|None): Optional Python script to persist before start.
70
+ """
66
71
  if new_code:
67
72
  self.component.new_code = new_code
68
73
  self.send('start')
69
74
 
70
75
  def play(self):
76
+ """Alias for `start()` to harmonize with media-like controls."""
71
77
  return self.start()
72
78
 
73
79
  def stop(self):
80
+ """Stop the running script process."""
74
81
  self.send('stop')
75
82
 
76
83
  def toggle(self):
84
+ """Toggle script run state between running and stopped."""
77
85
  self.component.refresh_from_db()
78
86
  if self.component.value == 'running':
79
87
  self.send('stop')
80
88
  else:
81
89
  self.send('start')
82
90
 
83
- def ai_assistant(self, wish):
91
+ def ai_assistant(self, wish, current_code=None):
92
+ """Request an AI-generated script for the given natural-language wish.
93
+
94
+ Parameters:
95
+ - wish (str): User intent in natural language.
96
+ Returns: dict with status, generated script, and description.
97
+ """
84
98
  try:
85
99
  request_data = {
86
100
  'hub_uid': dynamic_settings['core__hub_uid'],
@@ -88,6 +102,7 @@ class Script(ControllerBase, TimerMixin):
88
102
  'instance_uid': get_current_instance().uid,
89
103
  'system_data': json.dumps(get_current_state()),
90
104
  'wish': wish,
105
+ 'current_code': current_code
91
106
  }
92
107
  except Exception as e:
93
108
  print(traceback.format_exc(), file=sys.stderr)
@@ -97,7 +112,7 @@ class Script(ControllerBase, TimerMixin):
97
112
  request_data['current_user'] = UserSerializer(user, many=False).data
98
113
  try:
99
114
  response = requests.post(
100
- 'https://simo.io/hubs/ai-assist/scripts/', json=request_data
115
+ 'https://simo.io/ai/scripts/', json=request_data
101
116
  )
102
117
  except:
103
118
  return {'status': 'error', 'result': "Connection error"}
@@ -121,6 +136,7 @@ class PresenceLighting(Script):
121
136
  masters_only = False
122
137
  name = _("Presence lighting")
123
138
  config_form = PresenceLightingConfigForm
139
+ accepts_value = False
124
140
 
125
141
  def __init__(self, *args, **kwargs):
126
142
  super().__init__(*args, **kwargs)
simo/automation/forms.py CHANGED
@@ -33,13 +33,12 @@ class ScriptConfigForm(BaseComponentForm):
33
33
  "in my living room when it get's dark."
34
34
  }
35
35
  ),
36
- help_text="Clearly describe in your own words what kind of automation "
37
- "you want to happen with this scenario script. <br>"
38
- "The more defined, exact and clear is your description the more "
36
+ help_text="The more defined, exact and clear is your description the more "
39
37
  "accurate automation script SIMO.io AI assistanw will generate.<br>"
40
- "Use component, zone and category id's for best accuracy. <br>"
41
- "SIMO.io AI will re-generate your automation code and update it's description in Notes field "
42
- "every time this field is changed and it might take up to 60s to do it. <br>"
38
+ "Use component, zone and category ID's for best accuracy. <br>"
39
+ "SIMO.io AI will re-generate your automation code and update it's description "
40
+ "every time you enter something in this field. <br>"
41
+ "Takes up to 60s to do it. <br>"
43
42
  "Actual script code can only be edited via SIMO.io Admin.",
44
43
  )
45
44
  code = forms.CharField(widget=PythonCode, required=False)
@@ -85,24 +84,16 @@ class ScriptConfigForm(BaseComponentForm):
85
84
 
86
85
 
87
86
  def clean(self):
88
- if self.cleaned_data.get('assistant_request'):
89
- if self.instance.pk:
90
- org = Component.objects.get(pk=self.instance.pk)
91
- call_assistant = org.config.get('assistant_request') \
92
- and org.config.get('assistant_request') \
93
- != self.cleaned_data['assistant_request']
94
- else:
95
- call_assistant = True
96
-
97
- if call_assistant:
98
- resp = self.instance.ai_assistant(
99
- self.cleaned_data['assistant_request'],
100
- )
101
- if resp['status'] == 'success':
102
- self._ai_resp = resp
103
- elif resp['status'] == 'error':
104
- self.add_error('assistant_request', resp['result'])
105
-
87
+ if self.cleaned_data['assistant_request']:
88
+ resp = self.instance.ai_assistant(
89
+ self.cleaned_data['assistant_request'],
90
+ self.instance.config.get('code')
91
+ )
92
+ if resp['status'] == 'success':
93
+ self._ai_resp = resp
94
+ self.cleaned_data['assistant_request'] = None
95
+ elif resp['status'] == 'error':
96
+ self.add_error('assistant_request', resp['result'])
106
97
  return self.cleaned_data
107
98
 
108
99
  def save(self, commit=True):
@@ -257,23 +257,43 @@ class AutomationsGatewayHandler(GatesHandler, BaseObjectCommandsGatewayHandler):
257
257
  # however the process is actually still running
258
258
  process.kill()
259
259
  self.last_death = time.time()
260
- self.running_scripts.pop(id, None) # no longer running for sure!
261
- if not comp or comp.value != 'running':
262
- continue
263
-
264
- if id not in self.terminating_scripts: # was not intentionaly terminated
265
- if comp:
260
+ # If component exists and is marked running, attempt to persist error
261
+ # BEFORE removing from in-memory tracking, so we can retry if DB is down.
262
+ if comp and comp.value == 'running' and id not in self.terminating_scripts:
263
+ try:
266
264
  tz = pytz.timezone(comp.zone.instance.timezone)
267
265
  timezone.activate(tz)
268
- logger = get_component_logger(comp)
269
- logger.log(logging.INFO, "-------DEAD!-------")
270
- comp.value = 'error'
271
- comp.save()
266
+ logger = get_component_logger(comp)
267
+ logger.log(logging.INFO, "-------DEAD!-------")
268
+ comp.value = 'error'
269
+ comp.save()
270
+ except Exception:
271
+ # Leave entry in running_scripts to retry on next tick
272
+ continue
273
+ # For any other case or after successful DB update, drop tracking entry
274
+ self.running_scripts.pop(id, None)
272
275
 
273
276
  if self.last_death and time.time() - self.last_death < 5:
274
277
  # give 10s air before we wake these dead scripts up!
275
278
  return
276
279
 
280
+ # Reconcile scripts marked as 'running' in DB but not tracked or with dead PID
281
+ for comp in Component.objects.filter(base_type='script', value='running'):
282
+ if comp.id in self.running_scripts:
283
+ continue
284
+ pid = None
285
+ try:
286
+ pid = int(comp.meta.get('pid')) if comp.meta and 'pid' in comp.meta else None
287
+ except Exception:
288
+ pid = None
289
+ is_pid_alive = bool(pid) and os.path.exists(f"/proc/{pid}")
290
+ if not is_pid_alive:
291
+ try:
292
+ comp.value = 'error'
293
+ comp.save(update_fields=['value'])
294
+ except Exception:
295
+ pass
296
+
277
297
  for script in Component.objects.filter(
278
298
  base_type='script', config__keep_alive=True
279
299
  ).exclude(value__in=('running', 'stopped', 'finished')):
@@ -357,13 +377,9 @@ class AutomationsGatewayHandler(GatesHandler, BaseObjectCommandsGatewayHandler):
357
377
  print("START SCRIPT %s" % str(component))
358
378
 
359
379
  if component.id in self.running_scripts:
360
- # it appears that the script is running and is perfectly healthy
361
- # so we make sure it has correct value, do nothing else and return!
380
+ # Script appears to be healthy; do nothing and return.
362
381
  if component.id not in self.terminating_scripts \
363
382
  and self.running_scripts[component.id]['proc'].is_alive():
364
- if component.value != 'running':
365
- component.value = 'running'
366
- component.save()
367
383
  return
368
384
 
369
385
  # script is in terminating state or is no longer alive
@@ -429,4 +445,4 @@ class AutomationsGatewayHandler(GatesHandler, BaseObjectCommandsGatewayHandler):
429
445
  self.running_scripts.pop(component.id, None)
430
446
  logger.handlers = []
431
447
 
432
- threading.Thread(target=kill, daemon=True).start()
448
+ threading.Thread(target=kill, daemon=True).start()
Binary file
Binary file
Binary file
simo/core/admin.py CHANGED
@@ -352,9 +352,10 @@ class ComponentAdmin(EasyObjectsDeleteMixin, admin.ModelAdmin):
352
352
 
353
353
  ctx['is_last'] = True
354
354
  ctx['current_step'] = 3
355
- ctx['selected_type'] = ALL_BASE_TYPES.get(
356
- controller_cls.base_type, controller_cls.base_type
357
- )
355
+ # Normalize controller base type to slug for display
356
+ bt = getattr(controller_cls, 'base_type', None)
357
+ slug = bt if isinstance(bt, str) else getattr(bt, 'slug', None)
358
+ ctx['selected_type'] = ALL_BASE_TYPES.get(slug or bt, slug or bt)
358
359
  ctx['info'] = controller_cls.info(controller_cls)
359
360
  if request.method == 'POST':
360
361
  ctx['form'] = add_form(
@@ -506,4 +507,4 @@ class ComponentAdmin(EasyObjectsDeleteMixin, admin.ModelAdmin):
506
507
  'value_history': obj.history.filter(type='value').order_by('-date')[:50],
507
508
  'arm_status_history': obj.history.filter(type='security').order_by('-date')[:50]
508
509
  }
509
- )
510
+ )
simo/core/base_types.py CHANGED
@@ -1,23 +1,196 @@
1
1
  """
2
- This is where apps define custom component base types.
2
+ Component base types as first-class, self-describing classes.
3
+
4
+ Apps can define their base types in their own `<app>.base_types` module
5
+ by subclassing `BaseComponentType`. For backward compatibility, each
6
+ module should also export a `BASE_TYPES` mapping `{slug: name}`; this
7
+ file exports such mapping automatically from declared classes.
3
8
  """
4
9
 
10
+ from abc import ABC
5
11
  from django.utils.translation import gettext_lazy as _
6
12
 
7
- BASE_TYPES = {
8
- 'numeric-sensor': _("Numeric sensor"),
9
- 'multi-sensor': _("Multi sensor"),
10
- 'binary-sensor': _("Binary sensor"),
11
- 'button': _("Button"),
12
- 'dimmer': _("Dimmer"),
13
- 'dimmer-plus': _("Dimmer Plus"),
14
- 'rgbw-light': _('RGB(W) light'),
15
- 'switch': _("Switch"),
16
- 'switch-double': _("Switch Double"),
17
- 'switch-triple': _("Switch Triple"),
18
- 'switch-quadruple': _("Switch Quadruple"),
19
- 'switch-quintuple': _("Switch Quintuple"),
20
- 'lock': _("Lock"),
21
- 'gate': _("Gate"),
22
- 'blinds': _("Blinds"),
23
- }
13
+
14
+ class BaseComponentType(ABC):
15
+ """Abstract base for component base types.
16
+
17
+ Subclasses should set:
18
+ - slug: string identifier stored in Component.base_type
19
+ - name: human-friendly name (lazy-translated)
20
+ - description: short explanation of what this type represents
21
+ - purpose: when/why to use this type
22
+ - required_methods: tuple of controller method names that must be
23
+ implemented by controllers of this base type (optional).
24
+ """
25
+
26
+ slug: str = ''
27
+ name: str = ''
28
+ description: str = ''
29
+ purpose: str = ''
30
+ required_methods: tuple = tuple()
31
+
32
+ @classmethod
33
+ def describe(cls) -> dict:
34
+ return {
35
+ 'slug': cls.slug,
36
+ 'name': cls.name,
37
+ 'description': cls.description,
38
+ 'purpose': cls.purpose,
39
+ 'required_methods': list(cls.required_methods or ()),
40
+ }
41
+
42
+ @classmethod
43
+ def validate_controller(cls, controller_cls):
44
+ """Validate that a controller satisfies this type's contract.
45
+
46
+ Raises TypeError with a helpful message on mismatch.
47
+ """
48
+ # If there are no explicit requirements, nothing to validate.
49
+ if not cls.required_methods:
50
+ return
51
+
52
+ missing = []
53
+ for method in cls.required_methods:
54
+ attr = getattr(controller_cls, method, None)
55
+ if not callable(attr):
56
+ missing.append(method)
57
+ if missing:
58
+ reqs = ', '.join(cls.required_methods)
59
+ raise TypeError(
60
+ f"Controller {controller_cls.__module__}.{controller_cls.__name__} "
61
+ f"for base type '{cls.slug}' is missing required method(s): "
62
+ f"{', '.join(missing)}. Expected: {reqs}"
63
+ )
64
+
65
+
66
+ # ---- Core base types -------------------------------------------------
67
+
68
+
69
+ class NumericSensorType(BaseComponentType):
70
+ slug = 'numeric-sensor'
71
+ name = _("Numeric sensor")
72
+ description = _("Represents a single numeric value that changes over time.")
73
+ purpose = _("Use for temperature, humidity, light level, etc.")
74
+
75
+
76
+ class MultiSensorType(BaseComponentType):
77
+ slug = 'multi-sensor'
78
+ name = _("Multi sensor")
79
+ description = _("Represents several labeled readings in one component.")
80
+ purpose = _("Use when a single device reports multiple values.")
81
+
82
+
83
+ class BinarySensorType(BaseComponentType):
84
+ slug = 'binary-sensor'
85
+ name = _("Binary sensor")
86
+ description = _("A boolean on/off style sensor.")
87
+ purpose = _("Use for motion, door, presence, and similar states.")
88
+
89
+
90
+ class ButtonType(BaseComponentType):
91
+ slug = 'button'
92
+ name = _("Button")
93
+ description = _("Momentary button events like click, double-click, hold.")
94
+ purpose = _("Use to model input-only button devices.")
95
+
96
+
97
+ class SwitchType(BaseComponentType):
98
+ slug = 'switch'
99
+ name = _("Switch")
100
+ description = _("Binary on/off actuator.")
101
+ purpose = _("Use to control relays, power sockets, or generic toggles.")
102
+ required_methods = ('turn_on', 'turn_off', 'toggle')
103
+
104
+
105
+ class DoubleSwitchType(BaseComponentType):
106
+ slug = 'switch-double'
107
+ name = _("Switch Double")
108
+ description = _("Two-channel on/off actuator.")
109
+ purpose = _("Use to control two separate loads in one device.")
110
+
111
+
112
+ class TripleSwitchType(BaseComponentType):
113
+ slug = 'switch-triple'
114
+ name = _("Switch Triple")
115
+ description = _("Three-channel on/off actuator.")
116
+ purpose = _("Use to control three separate loads in one device.")
117
+
118
+
119
+ class QuadrupleSwitchType(BaseComponentType):
120
+ slug = 'switch-quadruple'
121
+ name = _("Switch Quadruple")
122
+ description = _("Four-channel on/off actuator.")
123
+ purpose = _("Use to control four loads in one device.")
124
+
125
+
126
+ class QuintupleSwitchType(BaseComponentType):
127
+ slug = 'switch-quintuple'
128
+ name = _("Switch Quintuple")
129
+ description = _("Five-channel on/off actuator.")
130
+ purpose = _("Use to control five loads in one device.")
131
+
132
+
133
+ class DimmerType(BaseComponentType):
134
+ slug = 'dimmer'
135
+ name = _("Dimmer")
136
+ description = _("Continuous actuator with settable output level.")
137
+ purpose = _("Use to control lights or devices with variable output.")
138
+ required_methods = ('turn_on', 'turn_off', 'toggle', 'output_percent', 'max_out')
139
+
140
+
141
+ class DimmerPlusType(BaseComponentType):
142
+ slug = 'dimmer-plus'
143
+ name = _("Dimmer Plus")
144
+ description = _("Multi-channel dimmer with main and secondary outputs.")
145
+ purpose = _("Use for fixtures with multiple dimmable channels.")
146
+
147
+
148
+ class RGBWLightType(BaseComponentType):
149
+ slug = 'rgbw-light'
150
+ name = _("RGB(W) light")
151
+ description = _("Color-capable light with optional white channel.")
152
+ purpose = _("Use for RGB/RGBW lighting control.")
153
+ required_methods = ('turn_on', 'turn_off', 'toggle')
154
+
155
+
156
+ class LockType(BaseComponentType):
157
+ slug = 'lock'
158
+ name = _("Lock")
159
+ description = _("Door lock actuator with state reporting.")
160
+ purpose = _("Use to control smart locks and display their status.")
161
+ required_methods = ('lock', 'unlock')
162
+
163
+
164
+ class GateType(BaseComponentType):
165
+ slug = 'gate'
166
+ name = _("Gate")
167
+ description = _("Gate/door opener with open/close/call commands.")
168
+ purpose = _("Use to manage gates with impulse or directional control.")
169
+ required_methods = ('open', 'close', 'call')
170
+
171
+
172
+ class BlindsType(BaseComponentType):
173
+ slug = 'blinds'
174
+ name = _("Blinds")
175
+ description = _("Window coverings with position and optional angle control.")
176
+ purpose = _("Use to control roller blinds, shades, or venetians.")
177
+ required_methods = ('open', 'close', 'stop')
178
+
179
+
180
+ def _export_base_types_dict():
181
+ """Derive legacy BASE_TYPES mapping from declared classes.
182
+
183
+ Returns {slug: name} for compatibility with legacy loaders.
184
+ """
185
+ import inspect as _inspect
186
+ mapping = {}
187
+ g = globals()
188
+ for _name, _obj in g.items():
189
+ if _inspect.isclass(_obj) and issubclass(_obj, BaseComponentType) \
190
+ and _obj is not BaseComponentType and getattr(_obj, 'slug', None):
191
+ mapping[_obj.slug] = _obj.name
192
+ return mapping
193
+
194
+
195
+ # Backwards-compatible export for code still expecting a dict.
196
+ BASE_TYPES = _export_base_types_dict()