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/fleet/forms.py CHANGED
@@ -7,7 +7,7 @@ from django.urls.base import get_script_prefix
7
7
  from django.contrib.contenttypes.models import ContentType
8
8
  from django.utils import timezone
9
9
  from dal import forward
10
- from simo.core.models import Component
10
+ from simo.core.models import Component, Category
11
11
  from simo.core.forms import (
12
12
  BaseComponentForm, ValueLimitForm, NumericSensorForm
13
13
  )
@@ -25,7 +25,7 @@ from simo.core.form_fields import (
25
25
  )
26
26
  from simo.core.form_fields import PlainLocationField
27
27
  from simo.users.models import PermissionsRole
28
- from .models import Colonel, ColonelPin, Interface, CustomDaliDevice
28
+ from .models import Colonel, ColonelPin, Interface
29
29
  from .utils import INTERFACES_PINS_MAP, get_all_control_input_choices
30
30
 
31
31
 
@@ -1792,81 +1792,55 @@ class DALIButtonConfigForm(DALIDeviceConfigForm, BaseComponentForm):
1792
1792
  pass
1793
1793
 
1794
1794
 
1795
- class CustomDaliDeviceForm(BaseComponentForm):
1796
- device = forms.ChoiceField()
1795
+ class SentinelDeviceConfigForm(BaseComponentForm):
1796
+ colonel = Select2ModelChoiceField(
1797
+ label="Sentinel", queryset=Colonel.objects.filter(type='sentinel'),
1798
+ url='autocomplete-colonels',
1799
+ )
1797
1800
 
1798
1801
  def __init__(self, *args, **kwargs):
1799
1802
  super().__init__(*args, **kwargs)
1800
- choices = []
1803
+ # Limit colonels to current instance for convenience
1801
1804
  instance = get_current_instance()
1802
- for colonel in Colonel.objects.filter(
1803
- type='room-sensor', instance=instance
1804
- ):
1805
- choices.append((f"wifi-{colonel.id}", colonel.name))
1806
- for device in CustomDaliDevice.objects.filter(
1807
- instance=instance,
1808
- last_seen__gt=timezone.now() - datetime.timedelta(minutes=10)
1809
- ):
1810
- choices.append((f"dali-{device.id}", device.name))
1811
- self.fields['device'].choices = choices
1812
-
1813
- def get_device(self, field_val):
1814
- if field_val.startswith('wifi'):
1815
- return Colonel.objects.filter(
1816
- id=self.cleaned_data['device'][5:]
1817
- ).first()
1818
- else:
1819
- return CustomDaliDevice.objects.filter(
1820
- id=self.cleaned_data['device'][5:]
1805
+ if instance:
1806
+ self.fields['colonel'].queryset = self.fields['colonel'].queryset.filter(
1807
+ instance=instance
1821
1808
  )
1822
1809
 
1823
-
1824
- class RoomSensorDeviceConfigForm(CustomDaliDeviceForm):
1825
-
1826
-
1827
1810
  def save(self, commit=True):
1828
1811
  from simo.core.models import Icon
1829
- colonel = None
1830
- dali_device = None
1831
- device = self.get_device(self.cleaned_data['device'])
1832
- if not device:
1812
+ colonel = self.cleaned_data.get('colonel')
1813
+ if not colonel:
1833
1814
  return
1834
- if isinstance(device, CustomDaliDevice):
1835
- dali_device = device
1836
- else:
1837
- colonel = device
1838
1815
 
1839
1816
  from .controllers import (
1840
1817
  RoomSiren, AirQualitySensor, TempHumSensor, AmbientLightSensor,
1841
- RoomPresenceSensor
1818
+ RoomPresenceSensor, VoiceAssistant, SmokeDetector
1842
1819
  )
1843
1820
 
1844
1821
  org_name = self.cleaned_data['name']
1845
1822
  org_icon = self.cleaned_data['icon']
1846
- for CtrlClass, icon, suffix in (
1847
- (RoomSiren, 'siren', 'siren'),
1848
- (AirQualitySensor, 'leaf', 'air quality'),
1849
- (TempHumSensor, 'temperature-half', 'temperature'),
1850
- (AmbientLightSensor, 'brightness-low', 'brightness'),
1851
- (RoomPresenceSensor, 'person', 'presence')
1823
+ last_comp = None
1824
+ for CtrlClass, icon, suffix, cat_slug in (
1825
+ (RoomSiren, 'siren', 'siren', 'security'),
1826
+ (AirQualitySensor, 'leaf', 'air quality', 'climate'),
1827
+ (TempHumSensor, 'temperature-half', 'temperature', 'climate'),
1828
+ (AmbientLightSensor, 'brightness-low', 'brightness', 'light'),
1829
+ (RoomPresenceSensor, 'person', 'presence', 'security'),
1830
+ (VoiceAssistant, 'microphone-lines', 'voice assistant', 'other'),
1831
+ (SmokeDetector, 'fire-smoke', 'dust/pollution', 'security'),
1852
1832
  ):
1853
1833
  default_icon = Icon.objects.filter(slug=icon).first()
1854
- if default_icon:
1855
- self.cleaned_data['icon'] = default_icon.slug
1856
- else:
1857
- self.cleaned_data['icon'] = org_icon
1834
+ self.cleaned_data['icon'] = default_icon.slug if default_icon else org_icon
1858
1835
  self.cleaned_data['name'] = f"{org_name} {suffix}"
1836
+ self.cleaned_data['category'] = Category.objects.filter(
1837
+ name__icontains=cat_slug
1838
+ ).first()
1859
1839
 
1860
- if colonel:
1861
- comp = Component.objects.filter(
1862
- config__colonel=colonel.id,
1863
- controller_uid=CtrlClass.uid
1864
- ).first()
1865
- else:
1866
- comp = Component.objects.filter(
1867
- config__dali_device=dali_device.id,
1868
- controller_uid=CtrlClass.uid
1869
- ).first()
1840
+ comp = Component.objects.filter(
1841
+ config__colonel=colonel.id,
1842
+ controller_uid=CtrlClass.uid
1843
+ ).first()
1870
1844
 
1871
1845
  form = CtrlClass.config_form(
1872
1846
  controller_uid=CtrlClass.uid, instance=comp,
@@ -1874,47 +1848,43 @@ class RoomSensorDeviceConfigForm(CustomDaliDeviceForm):
1874
1848
  )
1875
1849
  if form.is_valid():
1876
1850
  comp = form.save()
1877
- if colonel:
1878
- comp.config['colonel'] = colonel.id
1879
- else:
1880
- comp.config['dali_device'] = dali_device.id
1851
+ comp.value_units = CtrlClass.default_value_units
1852
+ comp.config['colonel'] = colonel.id
1881
1853
  comp.save()
1854
+ last_comp = comp
1882
1855
  else:
1883
1856
  raise Exception(form.errors)
1884
1857
 
1885
- if colonel:
1858
+ if colonel and last_comp:
1886
1859
  GatewayObjectCommand(
1887
- comp.gateway, colonel, id=comp.id,
1860
+ last_comp.gateway, colonel, id=last_comp.id,
1888
1861
  command='call', method='update_config', args=[
1889
- comp.controller._get_colonel_config()
1862
+ last_comp.controller._get_colonel_config()
1890
1863
  ]
1891
1864
  ).publish()
1892
1865
 
1893
- return comp
1866
+ return last_comp
1894
1867
 
1895
1868
 
1896
- class RoomZonePresenceConfigForm(CustomDaliDeviceForm):
1869
+ class RoomZonePresenceConfigForm(BaseComponentForm):
1870
+ colonel = Select2ModelChoiceField(
1871
+ label="Sentinel", queryset=Colonel.objects.filter(type='sentinel'),
1872
+ url='autocomplete-colonels',
1873
+ )
1897
1874
 
1898
- def clean_device(self):
1899
- value = self.cleaned_data['device']
1900
- if value.startswith('wifi'):
1901
- return value
1902
- from .controllers import RoomZonePresenceSensor
1903
- dali_device = CustomDaliDevice.objects.filter(
1904
- id=value[5:]
1905
- ).first()
1906
- free_slots = {0, 1, 2, 3, 4, 5, 6, 7}
1907
- for comp in Component.objects.filter(
1908
- controller_uid=RoomZonePresenceSensor.uid,
1909
- config__dali_device=dali_device.id
1910
- ):
1911
- try:
1912
- free_slots.remove(int(comp.config['slot']))
1913
- except:
1914
- continue
1915
- if not free_slots:
1916
- raise forms.ValidationError(
1917
- "This device already has 8 zones defined. "
1918
- "Please first delete some to add a new one."
1875
+ def __init__(self, *args, **kwargs):
1876
+ super().__init__(*args, **kwargs)
1877
+ instance = get_current_instance()
1878
+ if instance:
1879
+ self.fields['colonel'].queryset = self.fields['colonel'].queryset.filter(
1880
+ instance=instance
1919
1881
  )
1920
- return value
1882
+
1883
+
1884
+ class VoiceAssistantConfigForm(BaseComponentForm):
1885
+ voice = forms.ChoiceField(
1886
+ label="Voice",
1887
+ required=True, choices=(
1888
+ ('male', "Male"), ('female', "Female"),
1889
+ ),
1890
+ )
simo/fleet/gateways.py CHANGED
@@ -127,24 +127,17 @@ class FleetGatewayHandler(BaseObjectCommandsGatewayHandler):
127
127
  form_cleaned_data = deserialize_form_data(
128
128
  gw.discovery['init_data']
129
129
  )
130
- if form_cleaned_data['device'].startswith('wifi'):
131
- colonel = Colonel.objects.filter(
132
- id=form_cleaned_data['device'][5:]
133
- ).first()
130
+ # Room-zone presence discovery now only supports network sentinels
131
+ colonel = Colonel.objects.filter(
132
+ id=form_cleaned_data['colonel'].id
133
+ if hasattr(form_cleaned_data.get('colonel'), 'id')
134
+ else form_cleaned_data.get('colonel')
135
+ ).first()
136
+ if colonel:
134
137
  GatewayObjectCommand(
135
138
  gw, colonel,
136
139
  command='discover', type=self.uid.split('.')[-1],
137
140
  ).publish()
138
- else:
139
- from .models import CustomDaliDevice
140
- from .custom_dali_operations import Frame
141
- dali_device = CustomDaliDevice.objects.filter(
142
- id=form_cleaned_data['device'][5:]
143
- ).first()
144
- frame = Frame(40, bytes(bytearray(5)))
145
- frame[8:11] = 15 # command to custom dali device
146
- frame[12:15] = 0 # action to perform: start room zone discovery
147
- dali_device.transmit(frame)
148
141
 
149
142
 
150
143
 
@@ -178,4 +171,4 @@ class FleetGatewayHandler(BaseObjectCommandsGatewayHandler):
178
171
  f"| Btn type: {method}"
179
172
  )
180
173
  comp.controller._ctrl(j, btn.value, method)
181
- break
174
+ break
@@ -0,0 +1,28 @@
1
+ # Generated by Django 4.2.10 on 2025-09-11 06:02
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('fleet', '0054_auto_20250507_1256'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='colonel',
15
+ name='is_vo_active',
16
+ field=models.BooleanField(db_index=True, default=False),
17
+ ),
18
+ migrations.AddField(
19
+ model_name='colonel',
20
+ name='last_wake',
21
+ field=models.DateTimeField(db_index=True, editable=False, null=True),
22
+ ),
23
+ migrations.AddField(
24
+ model_name='colonel',
25
+ name='wake_stats',
26
+ field=models.JSONField(db_index=True, default=dict, editable=False),
27
+ ),
28
+ ]
@@ -0,0 +1,16 @@
1
+ # Generated by Django 4.2.10 on 2025-09-26 06:54
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('fleet', '0055_colonel_is_vo_active_colonel_last_wake_and_more'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.DeleteModel(
14
+ name='CustomDaliDevice',
15
+ ),
16
+ ]
simo/fleet/models.py CHANGED
@@ -1,6 +1,5 @@
1
1
  import requests
2
2
  import time
3
- import random
4
3
  import datetime
5
4
  from actstream import action
6
5
  from django.core.exceptions import ValidationError
@@ -68,7 +67,7 @@ class Colonel(DirtyFieldsMixin, models.Model):
68
67
  ('ample-wall', "Ample Wall"),
69
68
  ('game-changer', "Game Changer"),
70
69
  ('game-changer-mini', "Game Changer Mini"),
71
- ('room-sensor', "Room Sensor")
70
+ ('sentinel', "Sentinel")
72
71
  )
73
72
  )
74
73
  firmware_version = models.CharField(
@@ -105,6 +104,13 @@ class Colonel(DirtyFieldsMixin, models.Model):
105
104
  (0, "3kHz"), (1, "22kHz")
106
105
  ), help_text="Affects Ample Wall dimmer PWM output (dimmer) frequency")
107
106
 
107
+ # Sentinel voice assistant specific fields
108
+ wake_stats = models.JSONField(
109
+ default=dict, editable=False, db_index=True
110
+ )
111
+ last_wake = models.DateTimeField(null=True, editable=False, db_index=True)
112
+ is_vo_active = models.BooleanField(default=False, db_index=True)
113
+
108
114
  objects = ColonelsManager()
109
115
 
110
116
  def __str__(self):
@@ -121,6 +127,11 @@ class Colonel(DirtyFieldsMixin, models.Model):
121
127
  if self.major_upgrade_available and self.firmware_version == self.major_upgrade_available:
122
128
  self.major_upgrade_available = None
123
129
 
130
+ if self.is_vo_active:
131
+ Colonel.objects.filter(
132
+ instance=self.instance
133
+ ).exclude(id=self.id).update(is_vo_active=False)
134
+
124
135
  return super().save(*args, **kwargs)
125
136
 
126
137
  @property
@@ -558,73 +569,3 @@ def post_interface_delete(sender, instance, *args, **kwargs):
558
569
  ColonelPin.objects.bulk_update(
559
570
  pins, ["occupied_by_content_type", "occupied_by_id"]
560
571
  )
561
-
562
-
563
- class CustomDaliDevice(models.Model):
564
- '''
565
- Our own custom dali line device,
566
- not compatible with anything else of DALI!
567
- '''
568
- instance = models.ForeignKey(Instance, on_delete=models.CASCADE)
569
- uid = models.CharField(max_length=100, db_index=True)
570
- random_address = models.PositiveIntegerField(db_index=True, editable=False)
571
- name = models.CharField(
572
- max_length=200, help_text="User given name on initial pairing"
573
- )
574
- interface = models.ForeignKey(
575
- Interface, null=True, blank=True, editable=False,
576
- on_delete=models.SET_NULL, related_name='custom_devices',
577
- help_text="Colonel interface on which it operates."
578
- )
579
- last_seen = models.DateTimeField(null=True, editable=False)
580
- components = models.ManyToManyField(Component)
581
-
582
- class Meta:
583
- unique_together = 'instance', 'random_address'
584
-
585
- def save(self, *args, **kwargs):
586
- if not self.random_address:
587
- while True:
588
- self.random_address = random.randint(0, 255)
589
- if CustomDaliDevice.objects.filter(
590
- random_address=self.random_address,
591
- instance=self.instance
592
- ).first():
593
- continue
594
- break
595
- return super().save(*args, **kwargs)
596
-
597
- def transmit(self, frame):
598
- from .gateways import FleetGatewayHandler
599
- frame[0:7] = self.random_address
600
- gateway = Gateway.objects.filter(type=FleetGatewayHandler.uid).first()
601
- GatewayObjectCommand(
602
- gateway, self.interface.colonel,
603
- command='da-tx', interface=self.interface.no,
604
- msg=frame.pack.hex()
605
- ).publish()
606
-
607
- @property
608
- def is_alive(self):
609
- if not self.last_seen:
610
- return False
611
- return self.last_seen + datetime.timedelta(seconds=60) > timezone.now()
612
-
613
-
614
- @receiver(post_save, sender=Component)
615
- def attatch_components_to_dali_device(sender, instance, created, *args, **kwargs):
616
- if not instance.controller_uid.startswith('simo.fleet'):
617
- return
618
- if 'config' not in instance.get_dirty_fields():
619
- return
620
- dali_device = CustomDaliDevice.objects.filter(
621
- id=instance.config.get('dali_device', 0)
622
- ).first()
623
- if not dali_device:
624
- return
625
- dali_device.components.add(instance)
626
-
627
-
628
- @receiver(pre_delete, sender=CustomDaliDevice)
629
- def delete_dali_device_components(sender, instance, *args, **kwargs):
630
- instance.components.all().delete()
simo/fleet/serializers.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from rest_framework import serializers
2
2
  from simo.core.serializers import TimestampField
3
3
  from .models import (
4
- InstanceOptions, Colonel, ColonelPin, Interface, CustomDaliDevice
4
+ InstanceOptions, Colonel, ColonelPin, Interface
5
5
  )
6
6
 
7
7
 
@@ -87,50 +87,3 @@ class ColonelSerializer(serializers.ModelSerializer):
87
87
  instance.update_config()
88
88
  return instance
89
89
 
90
-
91
- class CustomDaliDeviceSerializer(serializers.ModelSerializer):
92
- is_empty = serializers.SerializerMethodField()
93
- is_alive = serializers.SerializerMethodField()
94
- last_seen = TimestampField(read_only=True)
95
-
96
- class Meta:
97
- model = CustomDaliDevice
98
- fields = (
99
- 'id', 'uid', 'random_address', 'name', 'is_empty',
100
- 'is_alive', 'last_seen'
101
- )
102
- read_only_fields = (
103
- 'random_address', 'is_empty', 'is_alive', 'last_seen'
104
- )
105
-
106
- def validate(self, data):
107
- instance = self.context.get('instance')
108
- uid = data.get('uid')
109
- if instance and uid:
110
- if CustomDaliDevice.objects.filter(
111
- uid=uid, instance=instance
112
- ).exists():
113
- raise serializers.ValidationError(
114
- f"A device with uid '{uid}' already exists for this instance."
115
- )
116
- return data
117
-
118
- def validate_uid(self, value):
119
- """
120
- Prevent changing the uid on update.
121
- """
122
- # self.instance will be None for creation, but set for updates.
123
- if self.instance and self.instance.uid != value:
124
- raise serializers.ValidationError("Changing uid is not allowed.")
125
- return value
126
-
127
- def create(self, validated_data):
128
- validated_data['instance'] = self.context['instance']
129
- return super().create(validated_data)
130
-
131
- def get_is_empty(self, obj):
132
- return not bool(obj.components.all().count())
133
-
134
- def get_is_alive(self, obj):
135
- return obj.is_alive
136
-