This commit is contained in:
2026-05-05 01:32:27 +03:30
parent cfe60f6729
commit 39efd537bf
27 changed files with 1874 additions and 101 deletions
+1
View File
@@ -41,6 +41,7 @@ class RouteFeatureAccessMiddleware(MiddlewareMixin):
"products",
"sensors",
"sensors__sensor_catalog",
"sensors__device_catalogs",
).get(farm_uuid=farm_uuid, owner=user)
except FarmHub.DoesNotExist:
return JsonResponse(
+1 -1
View File
@@ -70,7 +70,7 @@ class AccessRule(models.Model):
subscription_plans = models.ManyToManyField("SubscriptionPlan", related_name="access_rules", blank=True)
farm_types = models.ManyToManyField("farm_hub.FarmType", related_name="access_rules", blank=True)
products = models.ManyToManyField("farm_hub.Product", related_name="access_rules", blank=True)
sensor_catalogs = models.ManyToManyField("device_hub.SensorCatalog", related_name="access_rules", blank=True)
sensor_catalogs = models.ManyToManyField("device_hub.DeviceCatalog", related_name="access_rules", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+1
View File
@@ -27,6 +27,7 @@ class FeatureAccessPermission(BasePermission):
"products",
"sensors",
"sensors__sensor_catalog",
"sensors__device_catalogs",
).get(farm_uuid=farm_uuid, owner=request.user)
except FarmHub.DoesNotExist:
self.message = f"Access to feature `{feature_code}` is denied."
+7 -6
View File
@@ -109,16 +109,17 @@ def build_farm_access_profile(farm):
"products",
"sensors",
"sensors__sensor_catalog",
"sensors__device_catalogs",
).get(pk=farm.pk)
subscription_plan = get_effective_subscription_plan(farm)
product_ids = list(farm.products.values_list("id", flat=True))
sensor_catalog_ids = list(
farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog_id", flat=True)
)
sensor_catalog_codes = set(
farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog__code", flat=True)
)
sensor_catalog_ids = set()
sensor_catalog_codes = set()
for sensor in farm.sensors.all():
for catalog in sensor.get_device_catalogs():
sensor_catalog_ids.add(catalog.id)
sensor_catalog_codes.add(catalog.code)
features = {
feature.code: {
+1
View File
@@ -31,6 +31,7 @@ class FarmFeatureAuthorizationView(APIView):
"products",
"sensors",
"sensors__sensor_catalog",
"sensors__device_catalogs",
).get(
farm_uuid=farm_uuid,
owner=request.user,
+1
View File
@@ -11,6 +11,7 @@ urlpatterns = [
path("api/account/", include("account.urls")),
path("api/farm-hub/", include("farm_hub.urls")),
path("api/access-control/", include("access_control.urls")),
path("api/device-hub/", include("device_hub.urls")),
path("api/sensor-catalog/", include("device_hub.sensor_catalog_urls")),
path("api/farm-dashboard-config/", include("dashboard.urls_config")),
path("api/farm-dashboard/", include("dashboard.urls")),
+4 -3
View File
@@ -1,4 +1,4 @@
from .models import SensorCatalog
from .models import DeviceCatalog
SENSOR_CATALOG_ITEMS = [
@@ -10,6 +10,7 @@ SENSOR_CATALOG_ITEMS = [
"It measures only soil moisture and provides analog and digital outputs. "
"It does not report soil temperature, pH, or nutrients."
),
"device_communication_type": "output_only",
"customizable_fields": [],
"supported_power_sources": ["solar", "direct_power"],
"returned_data_fields": ["soil_moisture", "analog_output", "digital_output"],
@@ -29,11 +30,12 @@ def seed_sensor_catalog():
results = []
for item in SENSOR_CATALOG_ITEMS:
sensor, created = SensorCatalog.objects.update_or_create(
sensor, created = DeviceCatalog.objects.update_or_create(
code=item["code"],
defaults={
"name": item["name"],
"description": item["description"],
"device_communication_type": item.get("device_communication_type", "output_only"),
"customizable_fields": item["customizable_fields"],
"supported_power_sources": item["supported_power_sources"],
"returned_data_fields": item["returned_data_fields"],
@@ -48,4 +50,3 @@ def seed_sensor_catalog():
updated_count += 1
return results, created_count, updated_count
-1
View File
@@ -7,4 +7,3 @@ urlpatterns = [
path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"),
path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"),
]
+2 -2
View File
@@ -34,7 +34,7 @@ class Migration(migrations.Migration):
options={"db_table": "sensor_catalogs", "ordering": ["code"]},
),
migrations.CreateModel(
name="FarmSensor",
name="FarmDevice",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
@@ -47,7 +47,7 @@ class Migration(migrations.Migration):
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("farm", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="sensors", to="farm_hub.farmhub")),
("sensor_catalog", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_sensors", to="device_hub.sensorcatalog")),
("sensor_catalog", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_devices", to="device_hub.sensorcatalog")),
],
options={"db_table": "farm_sensors", "ordering": ["-created_at"]},
),
@@ -0,0 +1,19 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0004_absorb_sensor_catalog"),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.RenameModel(
old_name="FarmSensor",
new_name="FarmDevice",
),
],
),
]
@@ -0,0 +1,31 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0005_rename_farm_sensor_to_farm_device"),
]
operations = [
migrations.RenameModel(
old_name="SensorCatalog",
new_name="DeviceCatalog",
),
migrations.AddField(
model_name="devicecatalog",
name="device_communication_type",
field=models.CharField(
choices=[("output_only", "Output Only"), ("input_only", "Input Only")],
db_index=True,
default="output_only",
max_length=32,
),
),
migrations.AlterField(
model_name="farmdevice",
name="sensor_catalog",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_devices", to="device_hub.devicecatalog"),
),
]
@@ -0,0 +1,36 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type"),
]
operations = [
migrations.AddField(
model_name="devicecatalog",
name="capabilities",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="devicecatalog",
name="commands_schema",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="devicecatalog",
name="display_schema",
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name="devicecatalog",
name="payload_mapping",
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name="devicecatalog",
name="supported_widgets",
field=models.JSONField(blank=True, default=list),
),
]
@@ -0,0 +1,23 @@
from django.db import migrations, models
def copy_sensor_catalog_to_device_catalogs(apps, schema_editor):
FarmDevice = apps.get_model("device_hub", "FarmDevice")
for farm_device in FarmDevice.objects.exclude(sensor_catalog__isnull=True).iterator():
farm_device.device_catalogs.add(farm_device.sensor_catalog_id)
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0007_devicecatalog_dynamic_fields"),
]
operations = [
migrations.AddField(
model_name="farmdevice",
name="device_catalogs",
field=models.ManyToManyField(blank=True, related_name="composite_farm_devices", to="device_hub.devicecatalog"),
),
migrations.RunPython(copy_sensor_catalog_to_device_catalogs, migrations.RunPython.noop),
]
+44 -5
View File
@@ -3,14 +3,32 @@ import uuid as uuid_lib
from django.db import models
class SensorCatalog(models.Model):
class DeviceCatalog(models.Model):
OUTPUT_ONLY = "output_only"
INPUT_ONLY = "input_only"
DEVICE_COMMUNICATION_TYPES = [
(OUTPUT_ONLY, "Output Only"),
(INPUT_ONLY, "Input Only"),
]
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.CharField(max_length=255, unique=True, db_index=True)
name = models.CharField(max_length=255, unique=True, db_index=True)
description = models.TextField(blank=True, default="")
device_communication_type = models.CharField(
max_length=32,
choices=DEVICE_COMMUNICATION_TYPES,
default=OUTPUT_ONLY,
db_index=True,
)
customizable_fields = models.JSONField(default=list, blank=True)
supported_power_sources = models.JSONField(default=list, blank=True)
returned_data_fields = models.JSONField(default=list, blank=True)
payload_mapping = models.JSONField(default=dict, blank=True)
display_schema = models.JSONField(default=dict, blank=True)
supported_widgets = models.JSONField(default=list, blank=True)
commands_schema = models.JSONField(default=list, blank=True)
capabilities = models.JSONField(default=list, blank=True)
sample_payload = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -24,16 +42,21 @@ class SensorCatalog(models.Model):
return self.name
class FarmSensor(models.Model):
class FarmDevice(models.Model):
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
farm = models.ForeignKey("farm_hub.FarmHub", on_delete=models.CASCADE, related_name="sensors")
sensor_catalog = models.ForeignKey(
SensorCatalog,
DeviceCatalog,
on_delete=models.PROTECT,
related_name="farm_sensors",
related_name="farm_devices",
null=True,
blank=True,
)
device_catalogs = models.ManyToManyField(
DeviceCatalog,
related_name="composite_farm_devices",
blank=True,
)
physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True)
name = models.CharField(max_length=255)
sensor_type = models.CharField(max_length=255, blank=True, default="")
@@ -50,6 +73,23 @@ class FarmSensor(models.Model):
def __str__(self):
return f"{self.name} ({self.uuid})"
def get_device_catalogs(self):
catalogs = list(self.device_catalogs.all())
if catalogs:
return catalogs
if self.sensor_catalog_id:
return [self.sensor_catalog]
return []
def get_device_catalog_by_code(self, code):
if not code:
return None
normalized_code = str(code).strip().lower()
for catalog in self.get_device_catalogs():
if catalog.code.lower() == normalized_code:
return catalog
return None
class SensorExternalRequestLog(models.Model):
farm_uuid = models.UUIDField(db_index=True)
@@ -64,4 +104,3 @@ class SensorExternalRequestLog(models.Model):
def __str__(self):
return f"{self.physical_device_uuid}:{self.created_at.isoformat()}"
+4 -3
View File
@@ -6,7 +6,7 @@ from django.utils import timezone
from farm_hub.seeds import seed_admin_farm
from .models import FarmSensor, SensorCatalog, SensorExternalRequestLog
from .models import DeviceCatalog, FarmDevice, SensorExternalRequestLog
SENSOR_7_IN_1_CATALOG_CODE = "sensor-7-in-1"
@@ -23,11 +23,12 @@ SENSOR_7_IN_1_LOG_SERIES = [
def seed_sensor_7_in_1_catalog():
sensor_catalog, created = SensorCatalog.objects.update_or_create(
sensor_catalog, created = DeviceCatalog.objects.update_or_create(
code=SENSOR_7_IN_1_CATALOG_CODE,
defaults={
"name": "Sensor 7 in 1 Soil Sensor",
"description": "Demo 7 in 1 soil sensor for dashboard summary and chart endpoints.",
"device_communication_type": "output_only",
"customizable_fields": [],
"supported_power_sources": ["solar", "battery", "direct_power"],
"returned_data_fields": ["soil_moisture", "soil_temperature", "soil_ph", "electrical_conductivity", "nitrogen", "phosphorus", "potassium"],
@@ -42,7 +43,7 @@ def seed_sensor_7_in_1_catalog():
def seed_sensor_7_in_1_demo_data():
farm, _ = seed_admin_farm()
sensor_catalog, catalog_created = seed_sensor_7_in_1_catalog()
sensor, sensor_created = FarmSensor.objects.update_or_create(
sensor, sensor_created = FarmDevice.objects.update_or_create(
farm=farm,
physical_device_uuid=SENSOR_7_IN_1_DEVICE_UUID,
defaults={"sensor_catalog": sensor_catalog, "name": "Sensor 7 in 1 Demo", "sensor_type": "soil_7_in_1", "is_active": True, "specifications": {"capabilities": sensor_catalog.returned_data_fields, "demo_seed": True}, "power_source": {"type": "solar"}},
+2 -3
View File
@@ -1,8 +1,7 @@
from django.urls import path
from .views import SensorCatalogListView
from .views import DeviceCatalogListView
urlpatterns = [
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"),
]
+32
View File
@@ -77,3 +77,35 @@ class Sensor7In1SummarySerializer(serializers.Serializer):
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
class DeviceMetaSerializer(serializers.Serializer):
name = serializers.CharField(required=False, allow_blank=True)
physicalDeviceUuid = serializers.CharField(required=False, allow_null=True)
sensorCatalogCode = serializers.CharField(required=False, allow_blank=True)
updatedAt = serializers.CharField(required=False, allow_null=True)
class DeviceFieldValueSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True)
title = serializers.CharField(required=False, allow_blank=True)
subtitle = serializers.CharField(required=False, allow_blank=True)
trendNumber = serializers.FloatField(required=False)
trend = serializers.CharField(required=False, allow_blank=True)
unit = serializers.CharField(required=False, allow_blank=True)
class DeviceValuesListSerializer(serializers.Serializer):
sensor = DeviceMetaSerializer(required=False)
sensors = DeviceFieldValueSerializer(many=True, required=False)
class DeviceSummarySerializer(serializers.Serializer):
sensor = DeviceMetaSerializer(required=False)
supportedWidgets = serializers.ListField(child=serializers.CharField(), required=False)
sensorValuesList = DeviceValuesListSerializer(required=False)
avgSoilMoisture = SoilKpiSerializer(required=False)
sensorRadarChart = SoilRadarChartSerializer(required=False)
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
commands = serializers.ListField(child=serializers.JSONField(), required=False)
+96 -20
View File
@@ -1,19 +1,25 @@
from rest_framework import serializers
from .models import FarmSensor, SensorCatalog, SensorExternalRequestLog
from .models import DeviceCatalog, FarmDevice, SensorExternalRequestLog
class SensorCatalogSerializer(serializers.ModelSerializer):
class DeviceCatalogSerializer(serializers.ModelSerializer):
class Meta:
model = SensorCatalog
model = DeviceCatalog
fields = [
"uuid",
"code",
"name",
"description",
"device_communication_type",
"customizable_fields",
"supported_power_sources",
"returned_data_fields",
"payload_mapping",
"display_schema",
"supported_widgets",
"commands_schema",
"capabilities",
"sample_payload",
"is_active",
]
@@ -42,14 +48,16 @@ class SensorExternalRequestLogQuerySerializer(serializers.Serializer):
return attrs
class FarmSensorLogSerializer(serializers.ModelSerializer):
class FarmDeviceLogSerializer(serializers.ModelSerializer):
sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True)
device_catalogs = DeviceCatalogSerializer(many=True, read_only=True)
class Meta:
model = FarmSensor
model = FarmDevice
fields = [
"uuid",
"sensor_catalog_uuid",
"device_catalogs",
"physical_device_uuid",
"name",
"sensor_type",
@@ -61,17 +69,23 @@ class FarmSensorLogSerializer(serializers.ModelSerializer):
]
class SensorCatalogLogSerializer(serializers.ModelSerializer):
class DeviceCatalogLogSerializer(serializers.ModelSerializer):
class Meta:
model = SensorCatalog
model = DeviceCatalog
fields = [
"uuid",
"code",
"name",
"description",
"device_communication_type",
"customizable_fields",
"supported_power_sources",
"returned_data_fields",
"payload_mapping",
"display_schema",
"supported_widgets",
"commands_schema",
"capabilities",
"sample_payload",
"is_active",
"created_at",
@@ -79,8 +93,70 @@ class SensorCatalogLogSerializer(serializers.ModelSerializer):
]
class DeviceDetailSerializer(serializers.ModelSerializer):
device_catalog = DeviceCatalogSerializer(source="sensor_catalog", read_only=True)
device_catalogs = serializers.SerializerMethodField()
last_payload_at = serializers.SerializerMethodField()
class Meta:
model = FarmDevice
fields = [
"uuid",
"physical_device_uuid",
"name",
"sensor_type",
"is_active",
"specifications",
"power_source",
"device_catalog",
"device_catalogs",
"last_payload_at",
"created_at",
"updated_at",
]
def get_last_payload_at(self, obj):
latest_log = self.context.get("latest_log")
if latest_log is None:
return None
return latest_log.created_at
def get_device_catalogs(self, obj):
return DeviceCatalogSerializer(obj.get_device_catalogs(), many=True).data
class DeviceLatestPayloadSerializer(serializers.Serializer):
physical_device_uuid = serializers.UUIDField()
device_code = serializers.CharField()
device_catalog_code = serializers.CharField(allow_blank=True, allow_null=True)
raw_payload = serializers.JSONField()
normalized_payload = serializers.JSONField()
readings = serializers.JSONField()
created_at = serializers.DateTimeField(allow_null=True)
class DeviceCommandRequestSerializer(serializers.Serializer):
device_code = serializers.CharField()
command = serializers.CharField()
payload = serializers.JSONField(required=False, default=dict)
class DeviceCodeQuerySerializer(serializers.Serializer):
device_code = serializers.CharField()
class DeviceRangeQuerySerializer(DeviceCodeQuerySerializer):
range = serializers.CharField()
class DeviceCommandResponseSerializer(serializers.Serializer):
physical_device_uuid = serializers.UUIDField()
command = serializers.CharField()
status = serializers.CharField()
class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
farm_sensor = serializers.SerializerMethodField()
farm_device = serializers.SerializerMethodField()
sensor_catalog = serializers.SerializerMethodField()
class Meta:
@@ -90,26 +166,26 @@ class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
"farm_uuid",
"sensor_catalog_uuid",
"physical_device_uuid",
"farm_sensor",
"farm_device",
"sensor_catalog",
"payload",
"created_at",
]
def get_farm_sensor(self, obj):
farm_sensor_map = self.context.get("farm_sensor_map", {})
farm_sensor = farm_sensor_map.get(
def get_farm_device(self, obj):
farm_device_map = self.context.get("farm_device_map", {})
farm_device = farm_device_map.get(
(obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)
) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
if farm_sensor is None:
) or farm_device_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
if farm_device is None:
return None
return FarmSensorLogSerializer(farm_sensor).data
return FarmDeviceLogSerializer(farm_device).data
def get_sensor_catalog(self, obj):
farm_sensor_map = self.context.get("farm_sensor_map", {})
farm_sensor = farm_sensor_map.get(
farm_device_map = self.context.get("farm_device_map", {})
farm_device = farm_device_map.get(
(obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)
) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
if farm_sensor is None or farm_sensor.sensor_catalog is None:
) or farm_device_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
if farm_device is None or farm_device.sensor_catalog is None:
return None
return SensorCatalogLogSerializer(farm_sensor.sensor_catalog).data
return DeviceCatalogLogSerializer(farm_device.sensor_catalog).data
+517 -24
View File
@@ -11,7 +11,7 @@ from external_api_adapter.exceptions import ExternalAPIRequestError
from notifications.services import create_notification_for_farm_uuid
from .mock_data import ANOMALY_DETECTION_CARD, AVG_SOIL_MOISTURE, SENSOR_COMPARISON_CHART, SENSOR_RADAR_CHART, SENSOR_VALUES_LIST, SOIL_MOISTURE_HEATMAP
from .models import FarmSensor, SensorExternalRequestLog
from .models import FarmDevice, SensorExternalRequestLog
logger = logging.getLogger(__name__)
@@ -46,7 +46,7 @@ def get_sensor_external_request_logs_for_farm(*, farm_uuid, physical_device_uuid
if physical_device_uuid:
queryset = queryset.filter(physical_device_uuid=physical_device_uuid)
if sensor_type:
physical_device_uuids = FarmSensor.objects.filter(farm__farm_uuid=farm_uuid, sensor_type=sensor_type).values_list("physical_device_uuid", flat=True)
physical_device_uuids = FarmDevice.objects.filter(farm__farm_uuid=farm_uuid, sensor_type=sensor_type).values_list("physical_device_uuid", flat=True)
queryset = queryset.filter(physical_device_uuid__in=physical_device_uuids)
if date_from:
queryset = queryset.filter(created_at__date__gte=date_from)
@@ -57,22 +57,22 @@ def get_sensor_external_request_logs_for_farm(*, farm_uuid, physical_device_uuid
raise ValueError("Sensor external API tables are not migrated.") from exc
def get_farm_sensor_map_for_logs(*, logs):
def get_farm_device_map_for_logs(*, logs):
try:
logs = list(logs)
if not logs:
return {}
farm_sensor_queryset = FarmSensor.objects.select_related("farm", "sensor_catalog").filter(
farm_device_queryset = FarmDevice.objects.select_related("farm", "sensor_catalog").filter(
farm__farm_uuid__in={log.farm_uuid for log in logs},
physical_device_uuid__in={log.physical_device_uuid for log in logs},
).order_by("-created_at", "-id")
farm_sensor_map = {}
for farm_sensor in farm_sensor_queryset:
exact_key = (farm_sensor.farm.farm_uuid, farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None, farm_sensor.physical_device_uuid)
fallback_key = (farm_sensor.farm.farm_uuid, None, farm_sensor.physical_device_uuid)
farm_sensor_map.setdefault(exact_key, farm_sensor)
farm_sensor_map.setdefault(fallback_key, farm_sensor)
return farm_sensor_map
farm_device_map = {}
for farm_device in farm_device_queryset:
exact_key = (farm_device.farm.farm_uuid, farm_device.sensor_catalog.uuid if farm_device.sensor_catalog else None, farm_device.physical_device_uuid)
fallback_key = (farm_device.farm.farm_uuid, None, farm_device.physical_device_uuid)
farm_device_map.setdefault(exact_key, farm_device)
farm_device_map.setdefault(fallback_key, farm_device)
return farm_device_map
except (ProgrammingError, OperationalError) as exc:
raise ValueError("Sensor external API tables are not migrated.") from exc
@@ -86,7 +86,7 @@ def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, ph
def create_sensor_external_notification(*, physical_device_uuid, payload=None):
payload = payload or {}
sensor = FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
if sensor is None:
raise ValueError("Physical device not found.")
try:
@@ -110,7 +110,7 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None):
def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None):
payload = payload or {}
sensor = FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
if sensor is None:
raise ValueError("Physical device not found.")
farm_boundary = _get_farm_boundary(sensor=sensor)
@@ -273,23 +273,23 @@ def _get_sensor_context(farm=None):
if not history:
return None
latest_log, latest_readings = history[0]
farm_sensor_map = get_farm_sensor_map_for_logs(logs=[latest_log])
farm_sensor = farm_sensor_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor
return {"farm_sensor": farm_sensor, "latest_log": latest_log, "latest_readings": latest_readings, "previous_readings": history[1][1] if len(history) > 1 else {}, "history": history}
farm_device_map = get_farm_device_map_for_logs(logs=[latest_log])
farm_device = farm_device_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor
return {"farm_device": farm_device, "latest_log": latest_log, "latest_readings": latest_readings, "previous_readings": history[1][1] if len(history) > 1 else {}, "history": history}
def _build_sensor_meta(context, fallback_sensor):
sensor = deepcopy(fallback_sensor)
if not context:
return sensor
farm_sensor = context.get("farm_sensor")
farm_device = context.get("farm_device")
latest_log = context["latest_log"]
sensor["physicalDeviceUuid"] = str(latest_log.physical_device_uuid)
sensor["updatedAt"] = latest_log.created_at.isoformat()
if farm_sensor is not None:
sensor["name"] = farm_sensor.name or sensor["name"]
if farm_sensor.sensor_catalog is not None:
sensor["sensorCatalogCode"] = farm_sensor.sensor_catalog.code
if farm_device is not None:
sensor["name"] = farm_device.name or sensor["name"]
if farm_device.sensor_catalog is not None:
sensor["sensorCatalogCode"] = farm_device.sensor_catalog.code
return sensor
@@ -434,9 +434,9 @@ def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None):
if not chart_points:
return data
sensor_name = data["zones"][0]
farm_sensor = context.get("farm_sensor")
if farm_sensor is not None and farm_sensor.name:
sensor_name = farm_sensor.name
farm_device = context.get("farm_device")
if farm_device is not None and farm_device.name:
sensor_name = farm_device.name
data["zones"] = [sensor_name]
data["hours"] = [point["x"] for point in chart_points]
data["series"] = [{"name": sensor_name, "data": chart_points}]
@@ -557,3 +557,496 @@ def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value):
current_data.append(round(current_value, 2))
ideal_data.append(round(ideal_value, 2))
return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}]}
DEVICE_COMMAND_PAYLOAD_TYPES = {
"string": str,
"integer": int,
"number": (int, float),
"boolean": bool,
"object": dict,
"array": list,
}
DEFAULT_DEVICE_WIDGETS = [
"values_list",
"comparison_chart",
"radar_chart",
"latest_payload",
"anomaly_card",
"soil_moisture_heatmap",
]
def get_farm_device_by_physical_uuid(*, physical_device_uuid, owner=None):
queryset = FarmDevice.objects.select_related("farm", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid)
if owner is not None:
queryset = queryset.filter(farm__owner=owner)
return queryset.first()
def get_device_catalog_for_farm_device(farm_device, *, device_code=None):
if farm_device is None:
return None
if device_code:
return farm_device.get_device_catalog_by_code(device_code)
return farm_device.sensor_catalog if farm_device.sensor_catalog_id else (farm_device.get_device_catalogs()[0] if farm_device.get_device_catalogs() else None)
def get_latest_device_log(farm_device, *, device_catalog=None):
if farm_device is None:
return None
return get_latest_sensor_external_request_log(
farm_uuid=farm_device.farm.farm_uuid,
sensor_catalog_uuid=device_catalog.uuid if device_catalog else (farm_device.sensor_catalog.uuid if farm_device.sensor_catalog else None),
physical_device_uuid=farm_device.physical_device_uuid,
)
def get_device_logs(farm_device, *, range_value=None, date_from=None, date_to=None):
if farm_device is None:
return SensorExternalRequestLog.objects.none()
if range_value:
date_from = timezone.localdate() - timedelta(days=max(range_value - 1, 0))
return get_sensor_external_request_logs_for_farm(
farm_uuid=farm_device.farm.farm_uuid,
physical_device_uuid=farm_device.physical_device_uuid,
date_from=date_from,
date_to=date_to,
)
def validate_output_device_catalog(*, farm_device, device_code):
device_catalog = get_device_catalog_for_farm_device(farm_device, device_code=device_code)
if device_catalog is None:
raise ValueError("Device code is not attached to this farm device.")
if device_catalog.device_communication_type == "input_only":
raise ValueError("Selected device code is input-only and cannot be used for output data endpoints.")
return device_catalog
def _get_default_field_definition_map():
return {field["id"]: field for field in SENSOR_FIELDS}
def _normalize_payload_keys(payload_keys):
if isinstance(payload_keys, str):
return [payload_keys]
if isinstance(payload_keys, (list, tuple)):
return [item for item in payload_keys if isinstance(item, str) and item]
return []
def _get_device_field_definitions(device_catalog):
default_field_map = _get_default_field_definition_map()
if device_catalog is None:
return list(default_field_map.values())
payload_mapping = device_catalog.payload_mapping if isinstance(device_catalog.payload_mapping, dict) else {}
display_schema = device_catalog.display_schema if isinstance(device_catalog.display_schema, dict) else {}
display_fields = display_schema.get("fields", []) if isinstance(display_schema.get("fields", []), list) else []
ordered_ids = []
for item in display_fields:
if isinstance(item, dict) and item.get("id"):
ordered_ids.append(item["id"])
for item in device_catalog.returned_data_fields:
if isinstance(item, str) and item not in ordered_ids:
ordered_ids.append(item)
for item in payload_mapping.keys():
if item not in ordered_ids:
ordered_ids.append(item)
if not ordered_ids:
ordered_ids = list(default_field_map.keys())
display_field_map = {
item["id"]: item for item in display_fields if isinstance(item, dict) and item.get("id")
}
field_definitions = []
for field_id in ordered_ids:
default_field = default_field_map.get(field_id, {})
display_field = display_field_map.get(field_id, {})
payload_keys = _normalize_payload_keys(payload_mapping.get(field_id)) or list(default_field.get("payload_keys", [])) or [field_id]
field_definitions.append(
{
"id": field_id,
"label": display_field.get("label") or default_field.get("label") or field_id.replace("_", " ").title(),
"unit": display_field.get("unit") or default_field.get("unit") or "",
"payload_keys": payload_keys,
"ideal_min": display_field.get("ideal_min", default_field.get("ideal_min", 0.0)),
"ideal_max": display_field.get("ideal_max", default_field.get("ideal_max", 100.0)),
"radar_label": display_field.get("radar_label") or default_field.get("radar_label") or display_field.get("label") or default_field.get("label") or field_id,
}
)
return field_definitions
def _extract_payload_with_field_definitions(payload, field_definitions):
if not isinstance(payload, dict):
return {}
if isinstance(payload.get("payload"), dict):
payload = payload["payload"]
expected_keys = {key for field in field_definitions for key in field.get("payload_keys", [])}
if isinstance(payload.get("data"), dict):
nested = payload["data"]
if not expected_keys or any(key in nested for key in expected_keys):
payload = nested
return payload
def normalize_device_payload(device_catalog, payload):
field_definitions = _get_device_field_definitions(device_catalog)
payload = _extract_payload_with_field_definitions(payload, field_definitions)
normalized_payload = {}
for field in field_definitions:
for key in field["payload_keys"]:
if key in payload:
normalized_payload[field["id"]] = payload[key]
break
return normalized_payload
def extract_device_readings(device_catalog, payload):
normalized_payload = normalize_device_payload(device_catalog, payload)
readings = {}
for key, value in normalized_payload.items():
numeric_value = _to_float(value)
if numeric_value is not None:
readings[key] = numeric_value
return readings
def _get_device_supported_widgets(device_catalog):
if device_catalog is None:
return list(DEFAULT_DEVICE_WIDGETS)
widgets = device_catalog.supported_widgets if isinstance(device_catalog.supported_widgets, list) else []
if widgets:
return widgets
if device_catalog.device_communication_type == "input_only":
return []
return list(DEFAULT_DEVICE_WIDGETS)
def _get_device_history_context(farm_device):
if farm_device is None:
return None
try:
logs_queryset = get_device_logs(farm_device)
except ValueError:
return None
history = []
device_catalog = get_device_catalog_for_farm_device(farm_device)
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
readings = extract_device_readings(device_catalog, log.payload)
normalized_payload = normalize_device_payload(device_catalog, log.payload)
if readings or normalized_payload:
history.append((log, readings, normalized_payload))
if not history:
return {
"farm_device": farm_device,
"latest_log": None,
"latest_readings": {},
"latest_payload": {},
"previous_readings": {},
"history": [],
}
latest_log, latest_readings, latest_payload = history[0]
return {
"farm_device": farm_device,
"latest_log": latest_log,
"latest_readings": latest_readings,
"latest_payload": latest_payload,
"previous_readings": history[1][1] if len(history) > 1 else {},
"history": history,
}
def build_device_meta(farm_device, context=None, *, device_catalog=None):
device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device)
latest_log = (context or {}).get("latest_log")
return {
"name": farm_device.name if farm_device else "",
"physicalDeviceUuid": str(farm_device.physical_device_uuid) if farm_device else None,
"sensorCatalogCode": device_catalog.code if device_catalog else "",
"updatedAt": latest_log.created_at.isoformat() if latest_log else None,
}
def build_device_latest_payload(farm_device, *, device_code):
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
latest_log = get_latest_device_log(farm_device, device_catalog=device_catalog)
if latest_log is None:
return {
"physical_device_uuid": farm_device.physical_device_uuid,
"device_code": device_code,
"device_catalog_code": device_catalog.code if device_catalog else None,
"raw_payload": {},
"normalized_payload": {},
"readings": {},
"created_at": None,
}
return {
"physical_device_uuid": farm_device.physical_device_uuid,
"device_code": device_code,
"device_catalog_code": device_catalog.code if device_catalog else None,
"raw_payload": latest_log.payload,
"normalized_payload": normalize_device_payload(device_catalog, latest_log.payload),
"readings": extract_device_readings(device_catalog, latest_log.payload),
"created_at": latest_log.created_at,
}
def build_device_values_list(farm_device, range_value, *, device_code):
try:
logs_queryset = get_device_logs(farm_device)
except ValueError:
return {"sensors": []}
start_time = timezone.now() - VALUES_LIST_RANGES[range_value]
logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id"))
if not logs:
latest_log = logs_queryset.order_by("-created_at", "-id").first()
if latest_log is None:
return {"sensors": []}
logs = [latest_log]
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
earliest_payload = {}
latest_payload = {}
for log in logs:
normalized_payload = extract_device_readings(device_catalog, log.payload)
if not normalized_payload:
continue
if not earliest_payload:
earliest_payload = normalized_payload
latest_payload = normalized_payload
if not latest_payload:
return {"sensors": []}
sensors = []
for field in _get_device_field_definitions(device_catalog):
current_value = latest_payload.get(field["id"])
if current_value is None:
continue
previous_value = earliest_payload.get(field["id"], current_value)
delta = round(current_value - previous_value, 2)
sensors.append(
{
"title": field["label"],
"subtitle": _format_current_value_subtitle(field["label"], current_value, field["unit"]),
"trendNumber": abs(delta),
"trend": "positive" if delta >= 0 else "negative",
"unit": field["unit"],
}
)
return {"sensors": sensors}
def build_device_summary_values_list(farm_device, context=None, *, device_catalog=None):
context = _get_device_history_context(farm_device) if context is None else context
device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device)
data = {"sensor": build_device_meta(farm_device, context), "sensors": []}
latest_readings = context.get("latest_readings", {}) if context else {}
previous_readings = context.get("previous_readings", {}) if context else {}
for field in _get_device_field_definitions(device_catalog):
value = latest_readings.get(field["id"])
if value is None:
continue
previous = previous_readings.get(field["id"])
change = 0.0 if previous is None else round(value - previous, 2)
data["sensors"].append(
{
"id": field["id"],
"title": _format_value(value, field["unit"]),
"subtitle": field["label"],
"trendNumber": abs(change),
"trend": "positive" if change >= 0 else "negative",
"unit": field["unit"],
}
)
return data
def build_device_radar_chart(farm_device, range_value=None, *, device_code):
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
context = _get_device_history_context(farm_device)
if not context or not context.get("latest_readings"):
return {"labels": [], "series": []}
labels, current_data, ideal_data = [], [], []
for field in _get_device_field_definitions(device_catalog):
current_value = context["latest_readings"].get(field["id"])
if current_value is None:
continue
labels.append(field["radar_label"])
current_data.append(round(current_value, 2))
midpoint = (field["ideal_min"] + field["ideal_max"]) / 2
ideal_data.append(round(midpoint, 2))
return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}]}
def build_device_comparison_chart(farm_device, range_value, *, device_code):
days = COMPARISON_CHART_RANGES[range_value]
start_date = timezone.localdate() - timedelta(days=days - 1)
try:
logs_queryset = get_device_logs(farm_device, date_from=start_date)
except ValueError:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
field_definitions = _get_device_field_definitions(device_catalog)
grouped_logs = {}
for log in reversed(list(logs_queryset[: days * 24])):
bucket_date = timezone.localtime(log.created_at).date()
grouped_logs[bucket_date] = extract_device_readings(device_catalog, log.payload)
if not grouped_logs:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
sorted_dates = sorted(grouped_logs.keys())
categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates]
series = []
primary_data = []
for field in field_definitions:
data_points = []
for bucket_date in sorted_dates:
value = grouped_logs[bucket_date].get(field["id"])
if value is None:
data_points.append(0.0)
else:
data_points.append(round(value, 2))
if any(point != 0.0 for point in data_points):
series.append({"name": field["label"], "data": data_points})
if not primary_data:
primary_data = data_points
if not series or not primary_data:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
return {
"series": series,
"categories": categories,
"currentValue": round(primary_data[-1], 2),
"vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]),
}
def build_device_anomaly_detection_card(farm_device, context=None, *, device_catalog=None):
context = _get_device_history_context(farm_device) if context is None else context
device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device)
anomalies = []
latest_readings = context.get("latest_readings", {}) if context else {}
for field in _get_device_field_definitions(device_catalog):
value = latest_readings.get(field["id"])
if value is None:
continue
anomaly = _build_anomaly_item(field, value)
if anomaly is not None:
anomalies.append(anomaly)
return {
"anomalies": anomalies or [
{
"sensor": farm_device.name if farm_device else "Device",
"value": "نرمال",
"expected": "تمام شاخص‌ها در بازه مجاز هستند",
"deviation": "0",
"severity": "success",
}
]
}
def build_device_soil_moisture_heatmap(farm_device, context=None, *, device_catalog=None):
data = deepcopy(SOIL_MOISTURE_HEATMAP)
context = _get_device_history_context(farm_device) if context is None else context
if not context or not context.get("history"):
return data
device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device)
field_definitions = _get_device_field_definitions(device_catalog)
primary_field = field_definitions[0] if field_definitions else None
if primary_field is None:
return data
chart_points = []
for log, readings, _normalized_payload in reversed(context["history"][:MAX_CHART_POINTS]):
value = readings.get(primary_field["id"])
if value is None:
continue
chart_points.append({"x": log.created_at.strftime("%H:%M"), "y": round(value, 2)})
if not chart_points:
return data
sensor_name = farm_device.name if farm_device and farm_device.name else data["zones"][0]
data["zones"] = [sensor_name]
data["hours"] = [point["x"] for point in chart_points]
data["series"] = [{"name": sensor_name, "data": chart_points}]
return data
def build_device_avg_primary_metric(farm_device, context=None, *, device_catalog=None):
data = deepcopy(AVG_SOIL_MOISTURE)
context = _get_device_history_context(farm_device) if context is None else context
latest_readings = context.get("latest_readings", {}) if context else {}
device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device)
field_definitions = _get_device_field_definitions(device_catalog)
primary_field = field_definitions[0] if field_definitions else None
if primary_field is None:
return data
primary_value = latest_readings.get(primary_field["id"])
if primary_value is None:
return data
chip_text, chip_color, avatar_color = _calculate_status_chip(primary_value)
data["title"] = primary_field["label"]
data["stats"] = _format_value(primary_value, primary_field["unit"])
data["chipText"] = chip_text
data["chipColor"] = chip_color
data["avatarColor"] = avatar_color
return data
def build_device_summary(farm_device, *, device_code):
context = _get_device_history_context(farm_device)
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
summary = {"sensor": build_device_meta(farm_device, context, device_catalog=device_catalog), "supportedWidgets": _get_device_supported_widgets(device_catalog)}
if device_catalog and device_catalog.device_communication_type == "input_only":
summary["commands"] = device_catalog.commands_schema if isinstance(device_catalog.commands_schema, list) else []
return summary
summary["sensorValuesList"] = build_device_summary_values_list(farm_device, context=context, device_catalog=device_catalog)
if "comparison_chart" in summary["supportedWidgets"]:
summary["sensorComparisonChart"] = build_device_comparison_chart(farm_device, "7d", device_code=device_code)
if "radar_chart" in summary["supportedWidgets"]:
summary["sensorRadarChart"] = build_device_radar_chart(farm_device, device_code=device_code)
if "anomaly_card" in summary["supportedWidgets"]:
summary["anomalyDetectionCard"] = build_device_anomaly_detection_card(farm_device, context=context, device_catalog=device_catalog)
if "soil_moisture_heatmap" in summary["supportedWidgets"]:
summary["soilMoistureHeatmap"] = build_device_soil_moisture_heatmap(farm_device, context=context, device_catalog=device_catalog)
summary["avgSoilMoisture"] = build_device_avg_primary_metric(farm_device, context=context, device_catalog=device_catalog)
return summary
def validate_device_command(farm_device, command, payload, *, device_code):
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
commands_schema = device_catalog.commands_schema if device_catalog and isinstance(device_catalog.commands_schema, list) else []
if not commands_schema:
raise ValueError("This device does not support commands.")
matched_command = next(
(item for item in commands_schema if isinstance(item, dict) and item.get("command") == command),
None,
)
if matched_command is None:
raise ValueError("Command is not supported for this device.")
payload = payload or {}
if not isinstance(payload, dict):
raise ValueError("`payload` must be an object.")
payload_schema = matched_command.get("payload_schema", {})
if not isinstance(payload_schema, dict):
return matched_command
for key, expected_type in payload_schema.items():
if key not in payload:
raise ValueError(f"`{key}` is required for this command.")
expected_python_type = DEVICE_COMMAND_PAYLOAD_TYPES.get(expected_type)
if expected_python_type is None:
continue
if expected_type == "integer" and isinstance(payload[key], bool):
raise ValueError(f"`{key}` must be of type {expected_type}.")
if not isinstance(payload[key], expected_python_type):
raise ValueError(f"`{key}` must be of type {expected_type}.")
return matched_command
def execute_device_command(*, farm_device, device_code, command, payload=None):
validate_device_command(farm_device, command, payload or {}, device_code=device_code)
return {
"physical_device_uuid": farm_device.physical_device_uuid,
"device_code": device_code,
"command": command,
"status": "queued",
}
+118
View File
@@ -0,0 +1,118 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from farm_hub.models import FarmHub, FarmType
from .models import DeviceCatalog, SensorExternalRequestLog
from .views import DeviceCommandView, DeviceDetailView, DeviceLatestPayloadView, DeviceSummaryView
class DeviceHubGenericViewsTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="device-user",
password="secret123",
email="device@example.com",
phone_number="09120001000",
)
self.farm_type = FarmType.objects.create(name="گلخانه ای")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Device Farm",
)
self.catalog = DeviceCatalog.objects.create(
code="soil_sensor_v2",
name="Soil Sensor V2",
device_communication_type=DeviceCatalog.OUTPUT_ONLY,
returned_data_fields=["soil_moisture", "soil_temperature"],
payload_mapping={
"soil_moisture": ["moisture", "soil_moisture"],
"soil_temperature": ["temperature", "soil_temperature"],
},
display_schema={
"fields": [
{"id": "soil_moisture", "label": "رطوبت خاک", "unit": "%", "ideal_min": 40, "ideal_max": 70},
{"id": "soil_temperature", "label": "دمای خاک", "unit": "°C", "ideal_min": 18, "ideal_max": 30},
]
},
supported_widgets=["values_list", "comparison_chart", "radar_chart"],
)
self.device = self.farm.sensors.create(
name="Soil Device 1",
sensor_catalog=self.catalog,
sensor_type="soil",
)
SensorExternalRequestLog.objects.create(
farm_uuid=self.farm.farm_uuid,
sensor_catalog_uuid=self.catalog.uuid,
physical_device_uuid=self.device.physical_device_uuid,
payload={"moisture": 52.4, "temperature": 23.1},
)
def test_device_detail_view_returns_generic_payload(self):
request = self.factory.get(
f"/api/device-hub/devices/{self.device.physical_device_uuid}/",
{"device_code": self.catalog.code},
)
force_authenticate(request, user=self.user)
response = DeviceDetailView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["physical_device_uuid"], str(self.device.physical_device_uuid))
self.assertEqual(response.data["data"]["device_catalog"]["code"], self.catalog.code)
def test_device_latest_payload_view_returns_normalized_readings(self):
request = self.factory.get(
f"/api/device-hub/devices/{self.device.physical_device_uuid}/latest/",
{"device_code": self.catalog.code},
)
force_authenticate(request, user=self.user)
response = DeviceLatestPayloadView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["normalized_payload"]["soil_moisture"], 52.4)
self.assertEqual(response.data["data"]["readings"]["soil_temperature"], 23.1)
def test_device_summary_view_returns_supported_widgets(self):
request = self.factory.get(
f"/api/device-hub/devices/{self.device.physical_device_uuid}/summary/",
{"device_code": self.catalog.code},
)
force_authenticate(request, user=self.user)
response = DeviceSummaryView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid)
self.assertEqual(response.status_code, 200)
self.assertIn("values_list", response.data["data"]["supportedWidgets"])
self.assertIn("sensorValuesList", response.data["data"])
def test_input_only_device_command_view_rejects_input_only_device_code(self):
input_catalog = DeviceCatalog.objects.create(
code="valve_v1",
name="Valve V1",
device_communication_type=DeviceCatalog.INPUT_ONLY,
commands_schema=[
{"command": "open", "label": "Open", "payload_schema": {"duration_seconds": "integer"}},
],
)
input_device = self.farm.sensors.create(
name="Valve 1",
sensor_catalog=input_catalog,
sensor_type="valve",
)
request = self.factory.post(
f"/api/device-hub/devices/{input_device.physical_device_uuid}/commands/",
{"device_code": input_catalog.code, "command": "open", "payload": {"duration_seconds": 120}},
format="json",
)
force_authenticate(request, user=self.user)
response = DeviceCommandView.as_view()(request, physical_device_uuid=input_device.physical_device_uuid)
self.assertEqual(response.status_code, 400)
self.assertIn("device_code", response.data)
+11 -3
View File
@@ -1,11 +1,19 @@
from django.urls import path
from .views import Sensor7In1SummaryView, SensorCatalogListView, SensorExternalAPIView, SensorExternalRequestLogListAPIView
from .views import DeviceCatalogListView, DeviceCommandView, DeviceComparisonChartView, DeviceDetailView, DeviceLatestPayloadView, DeviceLogListView, DeviceRadarChartView, DeviceSummaryView, DeviceValuesListView, Sensor7In1SummaryView, SensorExternalAPIView, SensorExternalRequestLogListAPIView
urlpatterns = [
path("catalog/", DeviceCatalogListView.as_view(), name="device-catalog-list"),
path("devices/<uuid:physical_device_uuid>/", DeviceDetailView.as_view(), name="device-detail"),
path("devices/<uuid:physical_device_uuid>/latest/", DeviceLatestPayloadView.as_view(), name="device-latest-payload"),
path("devices/<uuid:physical_device_uuid>/summary/", DeviceSummaryView.as_view(), name="device-summary"),
path("devices/<uuid:physical_device_uuid>/values-list/", DeviceValuesListView.as_view(), name="device-values-list"),
path("devices/<uuid:physical_device_uuid>/comparison-chart/", DeviceComparisonChartView.as_view(), name="device-comparison-chart"),
path("devices/<uuid:physical_device_uuid>/radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"),
path("devices/<uuid:physical_device_uuid>/logs/", DeviceLogListView.as_view(), name="device-log-list"),
path("devices/<uuid:physical_device_uuid>/commands/", DeviceCommandView.as_view(), name="device-command"),
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"),
path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"),
path("external/logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
]
+154 -9
View File
@@ -11,18 +11,163 @@ from notifications.serializers import FarmNotificationSerializer
from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer
from .authentication import SensorExternalAPIKeyAuthentication
from .sensor_serializers import Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer
from .serializers import SensorCatalogSerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer
from .services import FarmDataForwardError, create_sensor_external_notification, forward_sensor_payload_to_farm_data, get_farm_sensor_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data
from .sensor_serializers import DeviceSummarySerializer, Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer
from .serializers import DeviceCatalogSerializer, DeviceCodeQuerySerializer, DeviceCommandRequestSerializer, DeviceCommandResponseSerializer, DeviceDetailSerializer, DeviceLatestPayloadSerializer, DeviceRangeQuerySerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer
from .services import FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog
class SensorCatalogListView(APIView):
class DeviceCatalogListView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(tags=["Sensor Catalog"], responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))})
@extend_schema(tags=["Sensor Catalog"], responses={200: code_response("DeviceCatalogListResponse", data=DeviceCatalogSerializer(many=True))})
def get(self, request):
from .models import SensorCatalog
return Response({"code": 200, "msg": "success", "data": SensorCatalogSerializer(SensorCatalog.objects.order_by("code"), many=True).data}, status=status.HTTP_200_OK)
from .models import DeviceCatalog
return Response({"code": 200, "msg": "success", "data": DeviceCatalogSerializer(DeviceCatalog.objects.order_by("code"), many=True).data}, status=status.HTTP_200_OK)
class DeviceBaseView(APIView):
permission_classes = [IsAuthenticated]
def get_farm_device(self, request, physical_device_uuid):
farm_device = get_farm_device_by_physical_uuid(physical_device_uuid=physical_device_uuid, owner=request.user)
if farm_device is None:
raise serializers.ValidationError({"physical_device_uuid": ["Device not found."]})
return farm_device
def get_device_code(self, request):
serializer = DeviceCodeQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
return serializer.validated_data["device_code"]
class DeviceDetailView(DeviceBaseView):
@extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceDetailResponse", data=DeviceDetailSerializer())})
def get(self, request, physical_device_uuid):
farm_device = self.get_farm_device(request, physical_device_uuid)
device_code = self.get_device_code(request)
validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
latest_payload = build_device_latest_payload(farm_device, device_code=device_code)
serializer = DeviceDetailSerializer(farm_device, context={"latest_log": type("LatestLog", (), {"created_at": latest_payload["created_at"]})() if latest_payload["created_at"] else None})
return Response({"code": 200, "msg": "success", "data": serializer.data}, status=status.HTTP_200_OK)
class DeviceLatestPayloadView(DeviceBaseView):
@extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceLatestPayloadResponse", data=DeviceLatestPayloadSerializer())})
def get(self, request, physical_device_uuid):
farm_device = self.get_farm_device(request, physical_device_uuid)
device_code = self.get_device_code(request)
try:
data = build_device_latest_payload(farm_device, device_code=device_code)
except ValueError as exc:
raise serializers.ValidationError({"device_code": [str(exc)]}) from exc
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
class DeviceSummaryView(DeviceBaseView):
@extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceSummaryResponse", data=DeviceSummarySerializer())})
def get(self, request, physical_device_uuid):
farm_device = self.get_farm_device(request, physical_device_uuid)
device_code = self.get_device_code(request)
try:
data = build_device_summary(farm_device, device_code=device_code)
except ValueError as exc:
raise serializers.ValidationError({"device_code": [str(exc)]}) from exc
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
class DeviceComparisonChartView(DeviceBaseView):
@extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Chart range, supported values: 7d, 30d. Defaults to 7d.")], responses={200: SensorComparisonChartResponseSerializer})
def get(self, request, physical_device_uuid):
serializer = DeviceRangeQuerySerializer(data={"device_code": request.query_params.get("device_code"), "range": request.query_params.get("range", "7d")})
serializer.is_valid(raise_exception=True)
farm_device = self.get_farm_device(request, physical_device_uuid)
try:
data = build_device_comparison_chart(farm_device, serializer.validated_data["range"], device_code=serializer.validated_data["device_code"])
except ValueError as exc:
raise serializers.ValidationError({"device_code": [str(exc)]}) from exc
return Response(data, status=status.HTTP_200_OK)
class DeviceValuesListView(DeviceBaseView):
@extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Values list range, supported values: 1h, 24h, 7d. Defaults to 7d.")], responses={200: SensorValuesListResponseSerializer})
def get(self, request, physical_device_uuid):
serializer = DeviceRangeQuerySerializer(data={"device_code": request.query_params.get("device_code"), "range": request.query_params.get("range", "7d")})
serializer.is_valid(raise_exception=True)
farm_device = self.get_farm_device(request, physical_device_uuid)
try:
data = build_device_values_list(farm_device, serializer.validated_data["range"], device_code=serializer.validated_data["device_code"])
except ValueError as exc:
raise serializers.ValidationError({"device_code": [str(exc)]}) from exc
return Response(data, status=status.HTTP_200_OK)
class DeviceRadarChartView(DeviceBaseView):
@extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Radar chart range, supported values: today, 7d, 30d. Defaults to 7d.")], responses={200: SensorRadarChartResponseSerializer})
def get(self, request, physical_device_uuid):
serializer = DeviceRangeQuerySerializer(data={"device_code": request.query_params.get("device_code"), "range": request.query_params.get("range", "7d")})
serializer.is_valid(raise_exception=True)
farm_device = self.get_farm_device(request, physical_device_uuid)
try:
data = build_device_radar_chart(farm_device, serializer.validated_data["range"], device_code=serializer.validated_data["device_code"])
except ValueError as exc:
raise serializers.ValidationError({"device_code": [str(exc)]}) from exc
return Response(data, status=status.HTTP_200_OK)
class DeviceLogListView(DeviceBaseView):
pagination_class = SensorExternalRequestLogPagination
@extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceLogListResponse", data=SensorExternalRequestLogSerializer(many=True), extra_fields={"count": serializers.IntegerField(), "next": serializers.CharField(allow_null=True), "previous": serializers.CharField(allow_null=True)})})
def get(self, request, physical_device_uuid):
page = request.query_params.get("page", 1)
page_size = request.query_params.get("page_size", 20)
device_code = request.query_params.get("device_code")
serializer = SensorExternalRequestLogQuerySerializer(
data={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"page": page,
"page_size": page_size,
"physical_device_uuid": physical_device_uuid,
}
)
serializer.is_valid(raise_exception=True)
farm_device = self.get_farm_device(request, physical_device_uuid)
try:
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
except ValueError as exc:
raise serializers.ValidationError({"device_code": [str(exc)]}) from exc
queryset = get_sensor_external_request_logs_for_farm(
farm_uuid=farm_device.farm.farm_uuid,
physical_device_uuid=farm_device.physical_device_uuid,
)
queryset = queryset.filter(sensor_catalog_uuid=device_catalog.uuid)
paginator = self.pagination_class()
paginator.page_size = serializer.validated_data["page_size"]
page_obj = paginator.paginate_queryset(queryset, request, view=self)
farm_device_map = get_farm_device_map_for_logs(logs=page_obj)
data = SensorExternalRequestLogSerializer(page_obj, many=True, context={"farm_device_map": farm_device_map}).data
return Response({"code": 200, "msg": "success", "count": paginator.page.paginator.count, "next": paginator.get_next_link(), "previous": paginator.get_previous_link(), "data": data}, status=status.HTTP_200_OK)
class DeviceCommandView(DeviceBaseView):
@extend_schema(tags=["Device Hub"], request=DeviceCommandRequestSerializer, responses={200: code_response("DeviceCommandResponse", data=DeviceCommandResponseSerializer())})
def post(self, request, physical_device_uuid):
serializer = DeviceCommandRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
farm_device = self.get_farm_device(request, physical_device_uuid)
try:
device_catalog = farm_device.get_device_catalog_by_code(serializer.validated_data["device_code"])
if device_catalog is None:
raise ValueError("Device code is not attached to this farm device.")
result = execute_device_command(
farm_device=farm_device,
device_code=serializer.validated_data["device_code"],
command=serializer.validated_data["command"],
payload=serializer.validated_data.get("payload"),
)
except ValueError as exc:
raise serializers.ValidationError({"device_code": [str(exc)]}) from exc
return Response({"code": 200, "msg": "command accepted", "data": result}, status=status.HTTP_200_OK)
class Sensor7In1SummaryView(APIView):
@@ -137,6 +282,6 @@ class SensorExternalRequestLogListAPIView(APIView):
paginator = self.pagination_class()
paginator.page_size = serializer.validated_data["page_size"]
page = paginator.paginate_queryset(queryset, request, view=self)
farm_sensor_map = get_farm_sensor_map_for_logs(logs=page)
data = SensorExternalRequestLogSerializer(page, many=True, context={"farm_sensor_map": farm_sensor_map}).data
farm_device_map = get_farm_device_map_for_logs(logs=page)
data = SensorExternalRequestLogSerializer(page, many=True, context={"farm_device_map": farm_device_map}).data
return Response({"code": 200, "msg": "success", "count": paginator.page.paginator.count, "next": paginator.get_next_link(), "previous": paginator.get_previous_link(), "data": data}, status=status.HTTP_200_OK)
+706
View File
@@ -0,0 +1,706 @@
# راهنمای طراحی Device Catalog داینامیک
## هدف
هدف این تغییر این است که اضافه کردن یک دیوایس جدید فقط با ثبت اطلاعات در دیتابیس یا پنل ادمین انجام شود و برای هر دیوایس جدید نیازی به اضافه کردن فایل، ویو، serializer یا service جدید در کد نباشد.
الان ساختار پروژه برای بعضی دیوایس‌ها device-specific است؛ مثلا:
- `device_hub/sensor_7_in_1_urls.py`
- `Sensor7In1SummaryView`
- `get_sensor_7_in_1_summary_data`
- `get_sensor_7_in_1_radar_chart_data`
- `get_sensor_7_in_1_comparison_chart_data`
این ساختار برای یک MVP خوب است، ولی برای scale شدن مناسب نیست. چون برای هر دیوایس جدید باید:
- route جدید بسازید
- view جدید بسازید
- serializer جدید بسازید
- service جدید بسازید
- منطق mapping payload جدید اضافه کنید
این دقیقا چیزی است که باید حذف شود.
---
## مشکل ساختار فعلی
الان backend تا حدی بر اساس `device type` یا `sensor-7-in-1` branch می‌زند، نه بر اساس یک configuration عمومی.
نمونه‌ها:
- `device_hub/views.py`
- `Sensor7In1SummaryView`
- `Sensor7In1RadarChartView`
- `Sensor7In1ComparisonChartView`
- `device_hub/services.py`
- `get_primary_soil_sensor`
- `get_sensor_7_in_1_summary_data`
- `get_sensor_7_in_1_values_list_data`
- `get_sensor_7_in_1_radar_chart_data`
- `get_sensor_7_in_1_comparison_chart_data`
- `device_hub/sensor_serializers.py`
- `Sensor7In1SummarySerializer`
- `Sensor7In1MetaSerializer`
مشکل این approach:
1. اضافه شدن هر device جدید نیاز به deploy کد دارد.
2. naming پروژه به device خاص وابسته می‌شود.
3. APIها generic نیستند.
4. frontend مجبور می‌شود endpointهای مخصوص هر device را صدا بزند.
5. منطق business به‌جای data-driven بودن، hard-coded شده است.
---
## معماری پیشنهادی
### اصل طراحی
به‌جای این‌که برای هر device endpoint جدا داشته باشیم، باید فقط یک سری endpoint عمومی داشته باشیم که بر اساس:
- `physical_device_uuid`
یا
- `device_catalog_uuid`
یا
- `device_catalog.code`
اطلاعات همان device را برگردانند.
یعنی backend باید:
1. device را پیدا کند
2. configuration آن device را از catalog بخواند
3. payload mapping آن device را بخواند
4. widgetهای قابل نمایش آن را تشخیص دهد
5. خروجی استاندارد بسازد
---
## APIهای پیشنهادی
### 1) لیست دیوایس‌ها
```http
GET /api/device-hub/catalog/
```
کاربرد:
- لیست همه device catalogها
- metadata هر catalog
- نوع ارتباط device
- فیلدهای قابل نمایش
---
### 2) جزئیات یک دیوایس ثبت‌شده روی مزرعه
```http
GET /api/device-hub/devices/{physical_device_uuid}/
```
پاسخ نمونه:
```json
{
"code": 200,
"msg": "success",
"data": {
"uuid": "farm-device-uuid",
"physical_device_uuid": "device-uuid",
"name": "Soil Sensor #1",
"device_catalog": {
"uuid": "catalog-uuid",
"code": "soil_sensor_v1",
"name": "Soil Sensor V1",
"device_communication_type": "output_only"
},
"specifications": {},
"power_source": {},
"last_payload_at": "2025-01-01T10:00:00Z"
}
}
```
---
### 3) آخرین داده‌ی یک device
```http
GET /api/device-hub/devices/{physical_device_uuid}/latest/
```
کاربرد:
- آخرین payload خام
- آخرین payload نرمال‌شده
- آخرین readingهای قابل نمایش
---
### 4) summary داینامیک برای یک device
```http
GET /api/device-hub/devices/{physical_device_uuid}/summary/
```
کاربرد:
- به‌جای `sensor_7_in_1/summary`
- خروجی بر اساس config همان device
---
### 5) نمودار مقایسه‌ای داینامیک
```http
GET /api/device-hub/devices/{physical_device_uuid}/comparison-chart/?range=7d
```
---
### 6) نمودار رادار داینامیک
```http
GET /api/device-hub/devices/{physical_device_uuid}/radar-chart/?range=7d
```
---
### 7) values list داینامیک
```http
GET /api/device-hub/devices/{physical_device_uuid}/values-list/?range=7d
```
---
### 8) دریافت history خام
```http
GET /api/device-hub/devices/{physical_device_uuid}/logs/?page=1&page_size=20
```
این endpoint برای debug و audit خیلی مهم است.
---
## تغییر مهم در مدل‌ها
### 1) `DeviceCatalog`
الان این مدل شروع خوبی دارد، ولی برای dynamic شدن کافی نیست.
مدل فعلی در:
- `device_hub/models.py:6`
فیلدهای پیشنهادی جدید:
```python
display_schema = models.JSONField(default=dict, blank=True)
payload_mapping = models.JSONField(default=dict, blank=True)
supported_widgets = models.JSONField(default=list, blank=True)
commands_schema = models.JSONField(default=list, blank=True)
capabilities = models.JSONField(default=list, blank=True)
```
### توضیح هر فیلد
#### `payload_mapping`
مشخص می‌کند payload خام این device چطور به فیلدهای استاندارد سیستم map شود.
مثال:
```json
{
"soil_moisture": ["soil_moisture", "soilMoisture", "moisture"],
"soil_temperature": ["soil_temperature", "soilTemperature", "temperature"],
"soil_ph": ["soil_ph", "soilPh", "ph"]
}
```
#### `display_schema`
مشخص می‌کند کدام فیلدها در UI نمایش داده شوند و label و unit آن‌ها چیست.
مثال:
```json
{
"fields": [
{
"id": "soil_moisture",
"label": "رطوبت خاک",
"unit": "%",
"ideal_min": 45,
"ideal_max": 65
},
{
"id": "soil_temperature",
"label": "دمای خاک",
"unit": "°C",
"ideal_min": 18,
"ideal_max": 28
}
]
}
```
#### `supported_widgets`
مشخص می‌کند برای این device چه widgetهایی فعال باشند.
مثال:
```json
[
"values_list",
"comparison_chart",
"radar_chart",
"latest_payload",
"anomaly_card"
]
```
#### `commands_schema`
برای deviceهایی که `input_only` هستند.
مثال:
```json
[
{
"command": "turn_on",
"label": "روشن کردن",
"payload_schema": {
"duration_seconds": "integer"
}
},
{
"command": "turn_off",
"label": "خاموش کردن",
"payload_schema": {}
}
]
```
#### `capabilities`
فهرست capabilityهای device:
```json
["measure", "history", "alert", "command"]
```
---
## برای deviceهای ورودی‌محور
شما گفتی بعضی deviceها فقط باید دستور بگیرند و خروجی نمی‌دهند. این دقیقا باید در مدل و API مشخص باشد.
برای این نوع device:
- `device_communication_type = "input_only"`
- `returned_data_fields = []`
- `supported_widgets = []`
- `commands_schema` باید پر باشد
API پیشنهادی:
```http
POST /api/device-hub/devices/{physical_device_uuid}/commands/
```
payload نمونه:
```json
{
"command": "turn_on",
"payload": {
"duration_seconds": 120
}
}
```
پاسخ نمونه:
```json
{
"code": 200,
"msg": "command accepted",
"data": {
"physical_device_uuid": "device-uuid",
"command": "turn_on",
"status": "queued"
}
}
```
---
## چه جاهایی باید در پروژه تغییر کند
### 1) حذف وابستگی به `sensor_7_in_1`
#### فایل‌هایی که باید refactor شوند
- `device_hub/views.py`
- `device_hub/services.py`
- `device_hub/sensor_serializers.py`
- `device_hub/sensor_7_in_1_urls.py`
- `device_hub/comparison_urls.py`
- `device_hub/urls.py`
#### چه چیزی باید تغییر کند
- viewهای device-specific حذف شوند
- routeهای generic جایگزین شوند
- serviceهای `get_sensor_7_in_1_*` به serviceهای generic تبدیل شوند
---
### 2) ساخت service عمومی برای پیدا کردن device
در `device_hub/services.py` باید این لایه‌ها ایجاد شود:
#### الف) resolver
```python
get_farm_device_by_physical_uuid(physical_device_uuid)
get_device_catalog_for_farm_device(farm_device)
get_latest_device_log(farm_device)
get_device_logs(farm_device, range_value=None)
```
#### ب) normalizer
```python
normalize_device_payload(device_catalog, payload)
extract_device_readings(device_catalog, payload)
```
این بخش باید از `payload_mapping` استفاده کند، نه از `SENSOR_FIELDS` ثابت.
#### ج) presenter / builder
```python
build_device_summary(farm_device)
build_device_values_list(farm_device, range_value)
build_device_comparison_chart(farm_device, range_value)
build_device_radar_chart(farm_device, range_value)
```
---
### 3) ثابت‌های hard-coded باید از کد خارج شوند
الان این موارد hard-coded هستند:
- `SENSOR_FIELDS`
- `COMPARISON_CHART_FIELD_ALIASES`
- `VALUES_LIST_FIELDS`
- `RADAR_CHART_FIELDS`
این‌ها الان در:
- `device_hub/services.py:16`
هستند و باید به config وابسته به `DeviceCatalog` منتقل شوند.
یعنی:
- به‌جای constant سراسری
- از `device_catalog.display_schema`
- و `device_catalog.payload_mapping`
استفاده شود.
---
### 4) serializerهای اختصاصی باید generic شوند
الان در:
- `device_hub/sensor_serializers.py:6`
serializerها مخصوص 7-in-1 هستند.
باید این‌ها جایگزین شوند:
- `DeviceMetaSerializer`
- `DeviceFieldValueSerializer`
- `DeviceValuesListSerializer`
- `DeviceSummarySerializer`
- `DeviceComparisonChartSerializer`
- `DeviceRadarChartSerializer`
یعنی نام serializer نباید به یک device خاص گره خورده باشد.
---
### 5) endpointهای generic بسازید
در `device_hub/urls.py` بهتر است چیزی شبیه این داشته باشید:
```python
urlpatterns = [
path("catalog/", DeviceCatalogListView.as_view(), name="device-catalog-list"),
path("devices/<uuid:physical_device_uuid>/", DeviceDetailView.as_view(), name="device-detail"),
path("devices/<uuid:physical_device_uuid>/latest/", DeviceLatestPayloadView.as_view(), name="device-latest-payload"),
path("devices/<uuid:physical_device_uuid>/summary/", DeviceSummaryView.as_view(), name="device-summary"),
path("devices/<uuid:physical_device_uuid>/values-list/", DeviceValuesListView.as_view(), name="device-values-list"),
path("devices/<uuid:physical_device_uuid>/comparison-chart/", DeviceComparisonChartView.as_view(), name="device-comparison-chart"),
path("devices/<uuid:physical_device_uuid>/radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"),
path("devices/<uuid:physical_device_uuid>/logs/", DeviceLogListView.as_view(), name="device-log-list"),
path("devices/<uuid:physical_device_uuid>/commands/", DeviceCommandView.as_view(), name="device-command"),
path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"),
]
```
---
## روند اضافه کردن device جدید بدون تغییر کد
بعد از این refactor، اضافه کردن device جدید باید این‌طوری باشد:
### مرحله 1
یک رکورد جدید در `DeviceCatalog` ایجاد شود.
### مرحله 2
این اطلاعات برایش ثبت شود:
- `code`
- `name`
- `device_communication_type`
- `payload_mapping`
- `display_schema`
- `supported_widgets`
- `commands_schema`
### مرحله 3
هنگام ثبت `FarmDevice`، آن device به همین catalog وصل شود.
### مرحله 4
از این به بعد frontend فقط با `physical_device_uuid` به endpointهای generic می‌زند.
بدون تغییر کد.
---
## نمونه config برای یک سنسور خروجی‌محور
```json
{
"code": "soil_sensor_v2",
"name": "Soil Sensor V2",
"device_communication_type": "output_only",
"returned_data_fields": [
"soil_moisture",
"soil_temperature",
"soil_ph"
],
"payload_mapping": {
"soil_moisture": ["moisture", "soil_moisture"],
"soil_temperature": ["temperature", "soil_temperature"],
"soil_ph": ["ph", "soil_ph"]
},
"display_schema": {
"fields": [
{
"id": "soil_moisture",
"label": "رطوبت خاک",
"unit": "%",
"ideal_min": 45,
"ideal_max": 65
},
{
"id": "soil_temperature",
"label": "دمای خاک",
"unit": "°C",
"ideal_min": 18,
"ideal_max": 28
},
{
"id": "soil_ph",
"label": "PH خاک",
"unit": "pH",
"ideal_min": 6,
"ideal_max": 7.5
}
]
},
"supported_widgets": [
"values_list",
"comparison_chart",
"radar_chart",
"latest_payload"
],
"commands_schema": []
}
```
---
## نمونه config برای یک device فقط ورودی
مثلا شیر برقی یا پمپ:
```json
{
"code": "irrigation_valve_v1",
"name": "Irrigation Valve V1",
"device_communication_type": "input_only",
"returned_data_fields": [],
"payload_mapping": {},
"display_schema": {
"fields": []
},
"supported_widgets": [],
"commands_schema": [
{
"command": "open",
"label": "باز کردن شیر",
"payload_schema": {
"duration_seconds": "integer"
}
},
{
"command": "close",
"label": "بستن شیر",
"payload_schema": {}
}
]
}
```
---
## پیشنهاد مرحله‌بندی پیاده‌سازی
### فاز 1: Generic read API
اول این‌ها را بسازید:
- `DeviceDetailView`
- `DeviceLatestPayloadView`
- `DeviceSummaryView`
- `DeviceValuesListView`
- `DeviceComparisonChartView`
- `DeviceRadarChartView`
و فعلا داده را با fallback از منطق فعلی بسازید.
### فاز 2: Config-driven normalization
بعد:
- `payload_mapping`
- `display_schema`
- `supported_widgets`
را به `DeviceCatalog` اضافه کنید و منطق hard-coded را حذف کنید.
### فاز 3: Command API
برای `input_only` deviceها:
- `DeviceCommandView`
- command validation
- queue / external broker integration
### فاز 4: Admin / CMS support
برای اینکه بدون کد device جدید اضافه شود، باید از طریق:
- Django Admin
یا
- پنل داخلی
بتوانید `DeviceCatalog` را مدیریت کنید.
---
## حداقل تغییر‌هایی که همین الان باید انجام بدهید
اگر بخواهی با کمترین تغییر از ساختار فعلی به ساختار بهتر برسی، این‌ها مهم‌ترین کارها هستند:
### ضروری
1. حذف endpointهای `sensor_7_in_1`-محور
2. ساخت endpointهای generic با `physical_device_uuid`
3. جدا کردن منطق extraction از device-specific code
4. انتقال field mapping از constant به دیتابیس
5. اضافه کردن schema برای commandها
### مهم ولی فاز بعدی
1. admin برای `DeviceCatalog`
2. validation قوی برای `payload_mapping`
3. caching برای summary/chartها
4. swagger dynamic docs برای command schema
---
## جمع‌بندی
اگر هدفت این است که:
- device جدید بدون تغییر کد اضافه شود
- frontend فقط با `device_uuid` کار کند
- بعضی deviceها فقط command بگیرند
- بعضی deviceها telemetry بدهند
پس باید طراحی از:
- `device-specific code`
به این مدل تغییر کند:
- `catalog-driven architecture`
یعنی:
- `DeviceCatalog` منبع حقیقت باشد
- APIها generic باشند
- parsing و rendering بر اساس config انجام شود
- commandها هم از schema خود device خوانده شوند
---
## فایل‌های کلیدی برای refactor
- `device_hub/models.py:6`
- `device_hub/views.py:19`
- `device_hub/services.py:16`
- `device_hub/sensor_serializers.py:1`
- `device_hub/urls.py:1`
- `device_hub/sensor_7_in_1_urls.py:1`
- `device_hub/comparison_urls.py:1`
- `device_hub/seeds.py:12`
---
## پیشنهاد نهایی
بهترین مسیر این است که:
1. endpointهای generic را اضافه کنی
2. endpointهای قدیمی `sensor_7_in_1` را deprecated کنی
3. config مورد نیاز را به `DeviceCatalog` اضافه کنی
4. frontend را به `physical_device_uuid`-based API منتقل کنی
اگر خواستی، در مرحله بعد من می‌توانم همین طراحی را به تسک اجرایی تبدیل کنم و دقیقا بگویم:
- چه model fieldهایی اضافه شوند
- چه serializerهایی ساخته شوند
- چه endpointهایی پیاده شوند
- و refactor را در چه ترتیب انجام بدهی
+2 -2
View File
@@ -4,7 +4,7 @@ from django.db import transaction
from account.seeds import seed_admin_user
from device_hub.catalog_seed import seed_sensor_catalog
from device_hub.models import SensorCatalog
from device_hub.models import DeviceCatalog
from .catalog import CATALOG_SEED_DATA
from .models import FarmHub, FarmType, Product
@@ -68,7 +68,7 @@ def _get_default_catalog():
def _get_sensor_catalog_by_code(code):
return SensorCatalog.objects.filter(code=code).first()
return DeviceCatalog.objects.filter(code=code).first()
@transaction.atomic
+55 -14
View File
@@ -3,7 +3,7 @@ from access_control.models import SubscriptionPlan
from access_control.serializers import SubscriptionPlanSerializer
from access_control.catalog import GOLD_PLAN_CODE
from access_control.services import get_effective_subscription_plan
from device_hub.models import FarmSensor, SensorCatalog
from device_hub.models import DeviceCatalog, FarmDevice
from .models import FarmHub, FarmType, Product
from .services import normalize_farm_boundary_input
@@ -37,15 +37,19 @@ class ProductSerializer(serializers.ModelSerializer):
]
class FarmSensorSerializer(serializers.ModelSerializer):
class FarmDeviceSerializer(serializers.ModelSerializer):
last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True)
device_catalog_uuids = serializers.SerializerMethodField()
device_catalog_codes = serializers.SerializerMethodField()
class Meta:
model = FarmSensor
model = FarmDevice
fields = [
"uuid",
"sensor_catalog_uuid",
"device_catalog_uuids",
"device_catalog_codes",
"physical_device_uuid",
"name",
"sensor_type",
@@ -56,13 +60,19 @@ class FarmSensorSerializer(serializers.ModelSerializer):
]
read_only_fields = ["uuid", "last_updated"]
def get_device_catalog_uuids(self, obj):
return [str(catalog.uuid) for catalog in obj.get_device_catalogs()]
def get_device_catalog_codes(self, obj):
return [catalog.code for catalog in obj.get_device_catalogs()]
class FarmHubSerializer(serializers.ModelSerializer):
last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
farm_type = FarmTypeSerializer(read_only=True)
subscription_plan = serializers.SerializerMethodField()
products = ProductSerializer(many=True, read_only=True)
sensors = FarmSensorSerializer(many=True, read_only=True)
sensors = FarmDeviceSerializer(many=True, read_only=True)
area_uuid = serializers.UUIDField(source="current_crop_area.uuid", read_only=True)
class Meta:
@@ -89,13 +99,20 @@ class FarmHubSerializer(serializers.ModelSerializer):
return SubscriptionPlanSerializer(subscription_plan, context=self.context).data
class FarmSensorWriteSerializer(serializers.ModelSerializer):
class FarmDeviceWriteSerializer(serializers.ModelSerializer):
sensor_catalog_uuid = serializers.UUIDField(write_only=True, required=False)
device_catalog_uuids = serializers.ListField(
child=serializers.UUIDField(),
write_only=True,
required=False,
allow_empty=False,
)
class Meta:
model = FarmSensor
model = FarmDevice
fields = [
"sensor_catalog_uuid",
"device_catalog_uuids",
"physical_device_uuid",
"name",
"sensor_type",
@@ -106,13 +123,27 @@ class FarmSensorWriteSerializer(serializers.ModelSerializer):
def validate(self, attrs):
sensor_catalog_uuid = attrs.pop("sensor_catalog_uuid", None)
device_catalog_uuids = attrs.pop("device_catalog_uuids", None)
catalog_uuids = []
if sensor_catalog_uuid is not None:
catalog_uuids.append(sensor_catalog_uuid)
if device_catalog_uuids:
catalog_uuids.extend(device_catalog_uuids)
if catalog_uuids:
try:
sensor_catalog = SensorCatalog.objects.get(uuid=sensor_catalog_uuid)
except SensorCatalog.DoesNotExist as exc:
raise serializers.ValidationError({"sensor_catalog_uuid": ["Sensor catalog not found."]}) from exc
attrs["sensor_catalog"] = sensor_catalog
attrs.setdefault("name", sensor_catalog.name)
catalog_map = {
catalog.uuid: catalog
for catalog in DeviceCatalog.objects.filter(uuid__in=catalog_uuids)
}
except DeviceCatalog.DoesNotExist as exc:
raise serializers.ValidationError({"device_catalog_uuids": ["Device catalog not found."]}) from exc
if len(catalog_map) != len({uuid for uuid in catalog_uuids}):
raise serializers.ValidationError({"device_catalog_uuids": ["One or more device catalogs were not found."]})
device_catalogs = [catalog_map[uuid] for uuid in dict.fromkeys(catalog_uuids)]
attrs["sensor_catalog"] = device_catalogs[0]
attrs["device_catalogs"] = device_catalogs
attrs.setdefault("name", device_catalogs[0].name)
return attrs
@@ -127,7 +158,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
write_only=True,
allow_empty=False,
)
sensors = FarmSensorWriteSerializer(many=True, required=False)
sensors = FarmDeviceWriteSerializer(many=True, required=False)
sensor_key = serializers.CharField(write_only=True, required=False, allow_blank=True, default="sensor-7-1")
sensor_payload = serializers.JSONField(write_only=True, required=False)
irrigation_method_id = serializers.IntegerField(required=False, allow_null=True)
@@ -247,7 +278,13 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
if products:
farm.products.set(products)
if sensors_data:
FarmSensor.objects.bulk_create([FarmSensor(farm=farm, **sensor_data) for sensor_data in sensors_data])
created_devices = []
for sensor_data in sensors_data:
device_catalogs = sensor_data.pop("device_catalogs", [])
farm_device = FarmDevice.objects.create(farm=farm, **sensor_data)
if device_catalogs:
farm_device.device_catalogs.set(device_catalogs)
created_devices.append(farm_device)
return farm
def update(self, instance, validated_data):
@@ -275,7 +312,11 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
if sensors_data is not None:
instance.sensors.all().delete()
if sensors_data:
FarmSensor.objects.bulk_create([FarmSensor(farm=instance, **sensor_data) for sensor_data in sensors_data])
for sensor_data in sensors_data:
device_catalogs = sensor_data.pop("device_catalogs", [])
farm_device = FarmDevice.objects.create(farm=instance, **sensor_data)
if device_catalogs:
farm_device.device_catalogs.set(device_catalogs)
return instance
+4 -4
View File
@@ -7,7 +7,7 @@ from access_control.models import AccessFeature, AccessRule, FarmAccessProfile,
from access_control.services import build_farm_access_profile
from access_control.views import FarmAccessProfileView
from crop_zoning.models import CropArea
from device_hub.models import SensorCatalog
from device_hub.models import DeviceCatalog
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType, Product
from farm_hub.serializers import FarmHubSerializer
@@ -49,7 +49,7 @@ class FarmListCreateViewTests(TestCase):
self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی")
self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم")
self.plan = SubscriptionPlan.objects.create(code="gold", name="Gold")
self.weather_station, _ = SensorCatalog.objects.get_or_create(
self.weather_station, _ = DeviceCatalog.objects.get_or_create(
code="sensor_7_soil_moisture_sensor_v1_2",
name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={"supported_power_sources": ["solar", "direct_power"]},
@@ -290,7 +290,7 @@ class FarmSeedTests(TestCase):
self.assertEqual(farm.irrigation_method_id, 1)
self.assertEqual(farm.irrigation_method_name, "آبیاری قطره ای")
self.assertIsNotNone(farm.sensors.first().physical_device_uuid)
self.assertTrue(SensorCatalog.objects.filter(code="sensor_7_soil_moisture_sensor_v1_2").exists())
self.assertTrue(DeviceCatalog.objects.filter(code="sensor_7_soil_moisture_sensor_v1_2").exists())
def test_seed_admin_farm_does_not_dispatch_twice_for_existing_seed(self):
first_farm, first_created = seed_admin_farm()
@@ -369,7 +369,7 @@ class FarmAccessProfileTests(TestCase):
self.plan = SubscriptionPlan.objects.create(code="starter", name="Starter")
self.farm_type = FarmType.objects.create(name="گلخانه ای")
self.product = Product.objects.create(farm_type=self.farm_type, name="خیار")
self.sensor_catalog = SensorCatalog.objects.create(code="climate_sensor", name="Climate Sensor")
self.sensor_catalog = DeviceCatalog.objects.create(code="climate_sensor", name="Climate Sensor")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
+2 -1
View File
@@ -23,7 +23,7 @@ class FarmHubBaseView(APIView):
def _get_farm(self, request, farm_uuid):
try:
return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related(
return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog", "sensors__device_catalogs").select_related(
"farm_type",
"subscription_plan",
"current_crop_area",
@@ -49,6 +49,7 @@ class FarmListCreateView(FarmHubBaseView):
"products",
"sensors",
"sensors__sensor_catalog",
"sensors__device_catalogs",
)
data = FarmHubSerializer(farms, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)