This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
+734
View File
@@ -0,0 +1,734 @@
# Device Hub API Guide
این فایل مستند API های تعریف‌شده در `device_hub/urls.py` را توضیح می‌دهد. مسیر پایه این API ها طبق `config/urls.py` برابر است با:
- `api/device-hub/`
بیشتر endpointها نیاز به احراز هویت کاربر دارند و معمولاً با ساختار زیر پاسخ می‌دهند:
```json
{
"code": 200,
"msg": "success",
"data": {}
}
```
## نحوه ارتباط با API ها
### 1) احراز هویت
- برای بیشتر endpointها باید کاربر لاگین باشد.
- معمولاً توکن را در هدر `Authorization` ارسال می‌کنید.
- نمونه:
```http
Authorization: Bearer <token>
Content-Type: application/json
```
### 2) آدرس پایه
اگر دامنه پروژه مثلاً `https://example.com` باشد، آدرس کامل endpointها به این صورت است:
- `https://example.com/api/device-hub/catalog/`
- `https://example.com/api/device-hub/devices/<physical_device_uuid>/latest/?device_code=<code>`
### 3) پارامترهای مهم
- `physical_device_uuid`: شناسه فیزیکی دستگاه
- `device_code`: کد نوع device داخل catalog
- `range`: بازه زمانی (`1h`, `24h`, `7d`, `30d`, `today` بسته به endpoint)
- `page` و `page_size`: برای صفحه‌بندی لاگ‌ها
---
## 1. دریافت لیست کاتالوگ دستگاه‌ها
### Endpoint
- `GET /api/device-hub/catalog/`
- `GET /api/device-hub/`
### کاربرد
برای گرفتن لیست همه device catalogها استفاده می‌شود.
### درخواست نمونه
```bash
curl -X GET "https://example.com/api/device-hub/catalog/" \
-H "Authorization: Bearer <token>"
```
### پاسخ نمونه
```json
{
"code": 200,
"msg": "success",
"data": [
{
"uuid": "11111111-1111-1111-1111-111111111111",
"code": "soil_sensor_v2",
"name": "Soil Sensor V2",
"description": "",
"device_communication_type": "output_only",
"customizable_fields": [],
"supported_power_sources": [],
"returned_data_fields": ["soil_moisture", "soil_temperature"],
"payload_mapping": {
"soil_moisture": ["moisture", "soil_moisture"],
"soil_temperature": ["temperature", "soil_temperature"]
},
"display_schema": {},
"supported_widgets": ["values_list", "comparison_chart", "radar_chart"],
"commands_schema": [],
"capabilities": [],
"sample_payload": {},
"is_active": true
}
]
}
```
### فیلدهای مهم پاسخ
- `device_communication_type`: نوع ارتباط دستگاه (`output_only` یا `input_only`)
- `returned_data_fields`: داده‌هایی که device برمی‌گرداند
- `payload_mapping`: نگاشت کلیدهای payload خام به فیلدهای نرمال
- `supported_widgets`: ویجت‌هایی که فرانت می‌تواند نمایش دهد
- `commands_schema`: لیست commandهای قابل ارسال برای deviceهای commandable
---
## 2. جزئیات یک دستگاه
### Endpoint
- `GET /api/device-hub/devices/<physical_device_uuid>/?device_code=<device_code>`
### کاربرد
اطلاعات کلی دستگاه ثبت‌شده در فارم را برمی‌گرداند.
### Query Params
- `device_code` اجباری
### درخواست نمونه
```bash
curl -X GET "https://example.com/api/device-hub/devices/22222222-2222-2222-2222-222222222222/?device_code=soil_sensor_v2" \
-H "Authorization: Bearer <token>"
```
### پاسخ نمونه
```json
{
"code": 200,
"msg": "success",
"data": {
"uuid": "33333333-3333-3333-3333-333333333333",
"physical_device_uuid": "22222222-2222-2222-2222-222222222222",
"name": "Soil Device 1",
"sensor_type": "soil",
"is_active": true,
"specifications": {},
"power_source": {},
"device_catalog": {
"uuid": "11111111-1111-1111-1111-111111111111",
"code": "soil_sensor_v2",
"name": "Soil Sensor V2",
"description": "",
"device_communication_type": "output_only",
"customizable_fields": [],
"supported_power_sources": [],
"returned_data_fields": ["soil_moisture", "soil_temperature"],
"payload_mapping": {},
"display_schema": {},
"supported_widgets": ["values_list", "comparison_chart", "radar_chart"],
"commands_schema": [],
"capabilities": [],
"sample_payload": {},
"is_active": true
},
"device_catalogs": [],
"last_payload_at": "2025-01-01T10:00:00Z",
"created_at": "2025-01-01T09:00:00Z",
"updated_at": "2025-01-01T09:00:00Z"
}
}
```
### نکته
- اگر `physical_device_uuid` متعلق به کاربر نباشد، خطای 400 با متن `Device not found.` برمی‌گردد.
- اگر `device_code` به این دستگاه attach نشده باشد، خطای validation دریافت می‌کنید.
---
## 3. آخرین payload دستگاه
### Endpoint
- `GET /api/device-hub/devices/<physical_device_uuid>/latest/?device_code=<device_code>`
### کاربرد
آخرین payload خام و نرمال‌شده دستگاه را می‌دهد.
### Query Params
- `device_code` اجباری
### پاسخ نمونه
```json
{
"code": 200,
"msg": "success",
"data": {
"physical_device_uuid": "22222222-2222-2222-2222-222222222222",
"device_code": "soil_sensor_v2",
"device_catalog_code": "soil_sensor_v2",
"raw_payload": {
"moisture": 52.4,
"temperature": 23.1
},
"normalized_payload": {
"soil_moisture": 52.4,
"soil_temperature": 23.1
},
"readings": {
"soil_moisture": 52.4,
"soil_temperature": 23.1
},
"created_at": "2025-01-01T10:00:00Z"
}
}
```
### معنی فیلدها
- `raw_payload`: داده خامی که از لاگ ذخیره‌شده آمده
- `normalized_payload`: داده تبدیل‌شده بر اساس `payload_mapping`
- `readings`: مقادیر قابل نمایش برای UI
---
## 4. خلاصه دستگاه
### Endpoint
- `GET /api/device-hub/devices/<physical_device_uuid>/summary/?device_code=<device_code>`
### کاربرد
یک summary مناسب UI برمی‌گرداند؛ مثلاً ویجت‌های قابل نمایش، values list، chartها و commandها.
### Query Params
- `device_code` اجباری
### پاسخ نمونه
```json
{
"code": 200,
"msg": "success",
"data": {
"sensor": {
"name": "Soil Device 1",
"physicalDeviceUuid": "22222222-2222-2222-2222-222222222222",
"sensorCatalogCode": "soil_sensor_v2",
"updatedAt": "2025-01-01T10:00:00Z"
},
"supportedWidgets": ["values_list", "comparison_chart", "radar_chart"],
"sensorValuesList": {
"sensor": {
"name": "Soil Device 1",
"physicalDeviceUuid": "22222222-2222-2222-2222-222222222222",
"sensorCatalogCode": "soil_sensor_v2",
"updatedAt": "2025-01-01T10:00:00Z"
},
"sensors": [
{
"id": "soil_moisture",
"title": "رطوبت خاک",
"subtitle": "45-65%",
"trendNumber": 52.4,
"trend": "normal",
"unit": "%"
}
]
},
"commands": []
}
}
```
### نکته
- شکل دقیق `data` بسته به `supported_widgets` و نوع catalog تغییر می‌کند.
- اگر history وجود نداشته باشد، معمولاً خطای 400 برمی‌گردد.
---
## 5. نمودار مقایسه‌ای دستگاه
### Endpoint
- `GET /api/device-hub/devices/<physical_device_uuid>/comparison-chart/?device_code=<device_code>&range=7d`
### Query Params
- `device_code` اجباری
- `range` اختیاری: `7d` یا `30d`
### پاسخ نمونه
```json
{
"series": [
{
"name": "Moisture",
"data": [50.0, 51.2, 52.4]
},
{
"name": "Temperature",
"data": [22.0, 22.4, 23.1]
}
],
"categories": ["شنبه", "یکشنبه", "دوشنبه"],
"currentValue": 52.4,
"vsLastWeek": "+3.1%"
}
```
### نکته
- این endpoint برخلاف بعضی endpointهای دیگر wrapper `code/msg` ندارد و مستقیم data chart را برمی‌گرداند.
---
## 6. لیست مقادیر دستگاه
### Endpoint
- `GET /api/device-hub/devices/<physical_device_uuid>/values-list/?device_code=<device_code>&range=7d`
### Query Params
- `device_code` اجباری
- `range` اختیاری: `1h`، `24h`، `7d`
### پاسخ نمونه
```json
{
"sensors": [
{
"title": "Moisture",
"subtitle": "45-65%",
"trendNumber": 52.4,
"trend": "positive",
"unit": "%"
},
{
"title": "Temperature",
"subtitle": "18-28°C",
"trendNumber": 23.1,
"trend": "positive",
"unit": "°C"
}
]
}
```
### نکته
- این endpoint هم مستقیم JSON داده را برمی‌گرداند و wrapper `code/msg` ندارد.
---
## 7. رادار چارت دستگاه
### Endpoint
- `GET /api/device-hub/devices/<physical_device_uuid>/radar-chart/?device_code=<device_code>&range=7d`
### Query Params
- `device_code` اجباری
- `range` اختیاری: `today`، `7d`، `30d`
### پاسخ نمونه
```json
{
"labels": ["Moisture", "Temperature", "PH", "EC"],
"series": [
{
"name": "Current",
"data": [52.4, 23.1, 6.7, 1.1]
}
]
}
```
### نکته
- این endpoint هم مستقیم data را برمی‌گرداند.
---
## 8. لاگ‌های دستگاه
### Endpoint
- `GET /api/device-hub/devices/<physical_device_uuid>/logs/?device_code=<device_code>&page=1&page_size=20`
### Query Params
- `device_code` اجباری
- `page` اختیاری، پیش‌فرض `1`
- `page_size` اختیاری، پیش‌فرض `20`، حداکثر `100`
### پاسخ نمونه
```json
{
"code": 200,
"msg": "success",
"count": 1,
"next": null,
"previous": null,
"data": [
{
"id": 10,
"farm_uuid": "44444444-4444-4444-4444-444444444444",
"sensor_catalog_uuid": "11111111-1111-1111-1111-111111111111",
"physical_device_uuid": "22222222-2222-2222-2222-222222222222",
"farm_device": {
"uuid": "33333333-3333-3333-3333-333333333333",
"sensor_catalog_uuid": "11111111-1111-1111-1111-111111111111",
"device_catalogs": [],
"physical_device_uuid": "22222222-2222-2222-2222-222222222222",
"name": "Soil Device 1",
"sensor_type": "soil",
"is_active": true,
"specifications": {},
"power_source": {},
"created_at": "2025-01-01T09:00:00Z",
"updated_at": "2025-01-01T09:00:00Z"
},
"sensor_catalog": {
"uuid": "11111111-1111-1111-1111-111111111111",
"code": "soil_sensor_v2",
"name": "Soil Sensor V2",
"description": "",
"device_communication_type": "output_only",
"customizable_fields": [],
"supported_power_sources": [],
"returned_data_fields": [],
"payload_mapping": {},
"display_schema": {},
"supported_widgets": [],
"commands_schema": [],
"capabilities": [],
"sample_payload": {},
"is_active": true,
"created_at": "2025-01-01T09:00:00Z",
"updated_at": "2025-01-01T09:00:00Z"
},
"payload": {
"moisture": 52.4,
"temperature": 23.1
},
"created_at": "2025-01-01T10:00:00Z"
}
]
}
```
### کاربرد
- برای history دستگاه
- برای نمایش payloadهای دریافت‌شده از device
- برای debug یا audit
---
## 9. ارسال command به دستگاه
### Endpoint
- `POST /api/device-hub/devices/<physical_device_uuid>/commands/`
### کاربرد
برای deviceهایی که `input_only` یا commandable هستند، command ارسال می‌کند.
### Body
```json
{
"device_code": "valve_v1",
"command": "open",
"payload": {
"duration_seconds": 120
}
}
```
### درخواست نمونه
```bash
curl -X POST "https://example.com/api/device-hub/devices/22222222-2222-2222-2222-222222222222/commands/" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"device_code": "valve_v1",
"command": "open",
"payload": {
"duration_seconds": 120
}
}'
```
### پاسخ نمونه
```json
{
"code": 200,
"msg": "command accepted",
"data": {
"physical_device_uuid": "22222222-2222-2222-2222-222222222222",
"command": "open",
"status": "accepted"
}
}
```
### نکات
- `device_code` باید جزو catalogهای متصل به آن device باشد.
- اگر command یا device code معتبر نباشد، خطای 400 برمی‌گردد.
---
## 10. خلاصه سنسور 7in1
### Endpoint
- `GET /api/device-hub/summary/?farm_uuid=<farm_uuid>`
### کاربرد
خلاصه‌ای از سنسور اصلی مزرعه برای UI برمی‌گرداند.
### Query Params
- `farm_uuid` اجباری
### پاسخ نمونه
```json
{
"code": 200,
"msg": "OK",
"data": {
"sensor": {
"name": "Main Soil Sensor",
"physicalDeviceUuid": "22222222-2222-2222-2222-222222222222",
"sensorCatalogCode": "sensor-7-in-1",
"updatedAt": "2025-01-01T10:00:00Z"
},
"sensorValuesList": {},
"avgSoilMoisture": {},
"sensorRadarChart": {},
"sensorComparisonChart": {},
"anomalyDetectionCard": {},
"soilMoistureHeatmap": {}
}
}
```
---
## 11. ثبت payload خارجی دستگاه
### Endpoint
- `POST /api/device-hub/external/`
### کاربرد
این endpoint برای سیستم یا دستگاه خارجی است تا payload را به backend ارسال کند.
این endpoint با API key اختصاصی کار می‌کند، نه با توکن کاربر.
### Header
```http
X-API-Key: <api_key>
Content-Type: application/json
```
### Body
```json
{
"uuid": "22222222-2222-2222-2222-222222222222",
"payload": {
"moisture_percent": 32.5,
"temperature_c": 21.3,
"ph": 6.7,
"ec_ds_m": 1.1,
"nitrogen_mg_kg": 42,
"phosphorus_mg_kg": 18,
"potassium_mg_kg": 210
}
}
```
### رفتار endpoint
- payload را در `SensorExternalRequestLog` ذخیره می‌کند
- notification می‌سازد
- payload را به farm-data service فوروارد می‌کند
### پاسخ موفق نمونه
```json
{
"code": 201,
"msg": "success",
"data": {
"id": 1,
"title": "Sensor external API request"
}
}
```
### خطاهای مهم
- `401`: اگر `X-API-Key` اشتباه یا خالی باشد
- `404`: اگر `physical device` پیدا نشود
- `503`: اگر migrationها آماده نباشند یا سرویس farm-data در دسترس نباشد
---
## 12. لیست لاگ‌های ورودی خارجی
### Endpoint
- `GET /api/device-hub/external/logs/?farm_uuid=<farm_uuid>&page=1&page_size=20`
### Query Params
- `farm_uuid` اجباری
- `page` اجباری در داکیومنت، ولی در عمل اگر نفرستید پیش‌فرض `1` دارد در paginator
- `page_size` اجباری در داکیومنت
- `physical_device_uuid` اختیاری
- `sensor_type` اختیاری
- `date_from` اختیاری
- `date_to` اختیاری
### پاسخ نمونه
```json
{
"code": 200,
"msg": "success",
"count": 25,
"next": "https://example.com/api/device-hub/external/logs/?page=2",
"previous": null,
"data": [
{
"id": 10,
"farm_uuid": "44444444-4444-4444-4444-444444444444",
"sensor_catalog_uuid": "11111111-1111-1111-1111-111111111111",
"physical_device_uuid": "22222222-2222-2222-2222-222222222222",
"farm_device": null,
"sensor_catalog": null,
"payload": {
"moisture": 52.4
},
"created_at": "2025-01-01T10:00:00Z"
}
]
}
```
---
## الگوی خطاها
### Validation Error
در بیشتر خطاهای اعتبارسنجی، پاسخ شبیه این است:
```json
{
"device_code": [
"Device code is not attached to this farm device."
]
}
```
یا:
```json
{
"physical_device_uuid": [
"Device not found."
]
}
```
### Unauthorized
اگر توکن کاربر یا API key درست نباشد، پاسخ 401 دریافت می‌کنید.
### Service Unavailable
در endpointهای `external` اگر migration یا سرویس وابسته آماده نباشد:
```json
{
"code": 503,
"msg": "Required tables are not ready. Run migrations."
}
```
---
## ترتیب پیشنهادی استفاده در فرانت
برای صفحه جزئیات device معمولاً این ترتیب مناسب است:
1. گرفتن catalogها از `GET /api/device-hub/catalog/`
2. گرفتن جزئیات device از `GET /api/device-hub/devices/<uuid>/?device_code=...`
3. گرفتن summary از `GET /api/device-hub/devices/<uuid>/summary/?device_code=...`
4. در صورت نیاز گرفتن:
- latest payload
- comparison chart
- radar chart
- values list
- logs
برای deviceهای commandable:
1. از `commands_schema` در catalog commandهای مجاز را بخوانید
2. سپس به `POST /api/device-hub/devices/<uuid>/commands/` درخواست بزنید
---
## محل فایل‌های مرتبط در کد
- مسیرها: `device_hub/urls.py`
- ویوها: `device_hub/views.py`
- serializerها: `device_hub/serializers.py`
- serializerهای summary/chart: `device_hub/sensor_serializers.py`
- مسیر پایه پروژه: `config/urls.py`
+1
View File
@@ -0,0 +1 @@
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class DeviceHubConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "device_hub"
verbose_name = "Device Hub"
@@ -0,0 +1,18 @@
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class SensorExternalAPIKeyAuthentication(BaseAuthentication):
keyword = "Api-Key"
def authenticate(self, request):
provided_key = request.headers.get("X-API-Key") or request.headers.get("Authorization")
expected_key = getattr(settings, "SENSOR_EXTERNAL_API_KEY", "12345")
if not provided_key:
raise AuthenticationFailed("API key is required.")
if provided_key.startswith(f"{self.keyword} "):
provided_key = provided_key[len(self.keyword) + 1 :]
if provided_key != expected_key:
raise AuthenticationFailed("Invalid API key.")
return (None, None)
@@ -0,0 +1,52 @@
from .models import DeviceCatalog
SENSOR_CATALOG_ITEMS = [
{
"code": "sensor_7_soil_moisture_sensor_v1_2",
"name": "Sensor 7 - Soil Moisture Sensor v1.2",
"description": (
"This sensor is typically the YL-69 or FC-28 soil moisture sensor. "
"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"],
"sample_payload": {
"soil_moisture": 42,
"analog_output": 610,
"digital_output": 1,
},
"is_active": True,
}
]
def seed_sensor_catalog():
created_count = 0
updated_count = 0
results = []
for item in SENSOR_CATALOG_ITEMS:
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"],
"sample_payload": item["sample_payload"],
"is_active": item["is_active"],
},
)
results.append((sensor, created))
if created:
created_count += 1
else:
updated_count += 1
return results, created_count, updated_count
@@ -0,0 +1,9 @@
from django.urls import path
from .views import SensorComparisonChartView, SensorRadarChartView, SensorValuesListView
urlpatterns = [
path("comparison-chart/", SensorComparisonChartView.as_view(), name="sensor-comparison-chart"),
path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"),
path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"),
]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand, CommandError
from device_hub.seeds import seed_sensor_7_in_1_demo_data
class Command(BaseCommand):
help = "Create or refresh demo sensor 7 in 1 data for summary and chart endpoints."
def handle(self, *args, **options):
try:
result = seed_sensor_7_in_1_demo_data()
except ValueError as exc:
raise CommandError(str(exc)) from exc
self.stdout.write(
self.style.SUCCESS(
"Sensor 7 in 1 demo data seeded: "
f"farm_uuid={result['farm'].farm_uuid}, "
f"sensor_catalog={result['sensor_catalog'].code}, "
f"physical_device_uuid={result['sensor'].physical_device_uuid}, "
f"logs={result['log_count']}"
)
)
@@ -0,0 +1,22 @@
from django.core.management.base import BaseCommand
from device_hub.catalog_seed import seed_sensor_catalog
class Command(BaseCommand):
help = "Seed sensor catalog data."
def handle(self, *args, **options):
results, created_count, updated_count = seed_sensor_catalog()
for sensor, created in results:
if created:
self.stdout.write(self.style.SUCCESS(f"Created sensor catalog item: {sensor.name}"))
else:
self.stdout.write(self.style.WARNING(f"Updated sensor catalog item: {sensor.name}"))
self.stdout.write(
self.style.SUCCESS(
f"Sensor catalog seeding complete. Created: {created_count}, Updated: {updated_count}"
)
)
@@ -0,0 +1,108 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
def _create_model_if_missing(app_label, model_name):
def _operation(apps, schema_editor):
model = apps.get_model(app_label, model_name)
existing_tables = set(schema_editor.connection.introspection.table_names())
if model._meta.db_table in existing_tables:
return
schema_editor.create_model(model)
return _operation
class Migration(migrations.Migration):
initial = True
atomic = False
dependencies = [
("farm_hub", "0001_initial"),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.CreateModel(
name="SensorCatalog",
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)),
("code", models.CharField(db_index=True, max_length=255, unique=True)),
("name", models.CharField(db_index=True, max_length=255, unique=True)),
("description", models.TextField(blank=True, default="")),
("customizable_fields", models.JSONField(blank=True, default=list)),
("supported_power_sources", models.JSONField(blank=True, default=list)),
("returned_data_fields", models.JSONField(blank=True, default=list)),
("sample_payload", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"db_table": "sensor_catalogs", "ordering": ["code"]},
),
],
),
migrations.RunPython(
_create_model_if_missing("device_hub", "SensorCatalog"),
migrations.RunPython.noop,
),
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.CreateModel(
name="FarmSensor",
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)),
("name", models.CharField(max_length=255)),
("sensor_type", models.CharField(blank=True, default="", max_length=255)),
("is_active", models.BooleanField(default=True)),
("specifications", models.JSONField(blank=True, default=dict)),
("power_source", models.JSONField(blank=True, default=dict)),
("customization", models.JSONField(blank=True, default=dict)),
("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",
),
),
],
options={"db_table": "farm_sensors", "ordering": ["-created_at"]},
),
],
),
migrations.RunPython(
_create_model_if_missing("device_hub", "FarmSensor"),
migrations.RunPython.noop,
),
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.CreateModel(
name="SensorExternalRequestLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("farm_uuid", models.UUIDField(db_index=True)),
("sensor_catalog_uuid", models.UUIDField(blank=True, db_index=True, null=True)),
("physical_device_uuid", models.UUIDField(db_index=True)),
("payload", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={"db_table": "sensor_external_request_logs", "ordering": ["-created_at", "-id"]},
),
],
),
migrations.RunPython(
_create_model_if_missing("device_hub", "SensorExternalRequestLog"),
migrations.RunPython.noop,
),
]
@@ -0,0 +1,9 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0001_initial"),
]
operations = []
@@ -0,0 +1,9 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0002_absorb_sensor_7_in_1"),
]
operations = []
@@ -0,0 +1,35 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0003_absorb_sensor_external_api"),
("farm_hub", "0003_farmsensor_catalog_and_physical_device"),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.AddField(
model_name="farmsensor",
name="physical_device_uuid",
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
),
migrations.AddField(
model_name="farmsensor",
name="sensor_catalog",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="farm_sensors",
to="device_hub.sensorcatalog",
),
),
],
),
]
@@ -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,42 @@
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.SeparateDatabaseAndState(
database_operations=[],
state_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,51 @@
from django.db import migrations, models
def ensure_device_catalogs_m2m_table(apps, schema_editor):
FarmDevice = apps.get_model("device_hub", "FarmDevice")
through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through
existing_tables = set(schema_editor.connection.introspection.table_names())
if through_model._meta.db_table not in existing_tables:
schema_editor.create_model(through_model)
def copy_sensor_catalog_to_device_catalogs(apps, schema_editor):
FarmDevice = apps.get_model("device_hub", "FarmDevice")
through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through
through_table = through_model._meta.db_table
farm_device_column = through_model._meta.get_field("farmdevice").column
device_catalog_column = through_model._meta.get_field("devicecatalog").column
with schema_editor.connection.cursor() as cursor:
for farm_device_id, sensor_catalog_id in FarmDevice.objects.exclude(sensor_catalog__isnull=True).values_list("pk", "sensor_catalog_id").iterator():
cursor.execute(
f"""
INSERT IGNORE INTO {schema_editor.quote_name(through_table)}
({schema_editor.quote_name(farm_device_column)}, {schema_editor.quote_name(device_catalog_column)})
VALUES (%s, %s)
""",
[farm_device_id, sensor_catalog_id],
)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("device_hub", "0007_devicecatalog_dynamic_fields"),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_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(ensure_device_catalogs_m2m_table, migrations.RunPython.noop),
migrations.RunPython(copy_sensor_catalog_to_device_catalogs, migrations.RunPython.noop),
]
@@ -0,0 +1,47 @@
from django.db import migrations, models
def add_column_if_missing(schema_editor, table_name, column_name, field):
existing_columns = {
column.name
for column in schema_editor.connection.introspection.get_table_description(
schema_editor.connection.cursor(),
table_name,
)
}
if column_name in existing_columns:
return
field.set_attributes_from_name(column_name)
schema_editor.add_field(
field.model,
field,
)
def sync_devicecatalog_schema(apps, schema_editor):
DeviceCatalog = apps.get_model("device_hub", "DeviceCatalog")
table_name = DeviceCatalog._meta.db_table
fields = [
DeviceCatalog._meta.get_field("device_communication_type"),
DeviceCatalog._meta.get_field("payload_mapping"),
DeviceCatalog._meta.get_field("display_schema"),
DeviceCatalog._meta.get_field("supported_widgets"),
DeviceCatalog._meta.get_field("commands_schema"),
DeviceCatalog._meta.get_field("capabilities"),
]
for field in fields:
add_column_if_missing(schema_editor, table_name, field.column, field)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("device_hub", "0008_farmdevice_device_catalogs"),
]
operations = [
migrations.RunPython(sync_devicecatalog_schema, migrations.RunPython.noop),
]
@@ -0,0 +1 @@
+57
View File
@@ -0,0 +1,57 @@
AVG_SOIL_MOISTURE = {
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "سنسور 7 در 1 خاک",
"stats": "45%",
"avatarColor": "primary",
"avatarIcon": "tabler-droplet",
"chipText": "متوسط",
"chipColor": "warning",
}
SENSOR_VALUES_LIST = {
"sensor": {
"name": "سنسور 7 در 1 خاک",
"physicalDeviceUuid": None,
"sensorCatalogCode": "sensor-7-in-1",
"updatedAt": None,
},
"sensors": [
{"id": "soil_moisture", "title": "45%", "subtitle": "رطوبت خاک", "trendNumber": 1.5, "trend": "positive", "unit": "%"},
{"id": "soil_temperature", "title": "22.5°C", "subtitle": "دمای خاک", "trendNumber": 0.8, "trend": "positive", "unit": "°C"},
{"id": "soil_ph", "title": "6.8", "subtitle": "pH خاک", "trendNumber": 0.1, "trend": "positive", "unit": "pH"},
{"id": "electrical_conductivity", "title": "1.2 dS/m", "subtitle": "هدایت الکتریکی", "trendNumber": -0.1, "trend": "negative", "unit": "dS/m"},
{"id": "nitrogen", "title": "30 mg/kg", "subtitle": "نیتروژن", "trendNumber": 2.0, "trend": "positive", "unit": "mg/kg"},
{"id": "phosphorus", "title": "15 mg/kg", "subtitle": "فسفر", "trendNumber": 1.0, "trend": "positive", "unit": "mg/kg"},
{"id": "potassium", "title": "20 mg/kg", "subtitle": "پتاسیم", "trendNumber": -1.0, "trend": "negative", "unit": "mg/kg"},
],
}
SENSOR_RADAR_CHART = {
"labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"],
"series": [{"name": "اکنون", "data": [82, 76, 90, 72, 68, 62, 70]}, {"name": "هدف", "data": [100, 100, 100, 100, 100, 100, 100]}],
}
SENSOR_COMPARISON_CHART = {
"currentValue": 45,
"vsLastWeek": "+4.7%",
"vsLastWeekValue": 4.7,
"categories": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
"series": [{"name": "رطوبت خاک", "data": [42, 44, 45, 47, 46, 45, 45]}, {"name": "بازه هدف", "data": [55, 55, 55, 55, 55, 55, 55]}],
}
ANOMALY_DETECTION_CARD = {
"anomalies": [{"sensor": "هدایت الکتریکی", "value": "1.2 dS/m", "expected": "0.8-1.1 dS/m", "deviation": "+0.1 dS/m", "severity": "warning"}]
}
SOIL_MOISTURE_HEATMAP = {
"zones": ["سنسور 7 در 1 خاک"],
"hours": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
"series": [{"name": "سنسور 7 در 1 خاک", "data": [{"x": "08:00", "y": 42}, {"x": "10:00", "y": 44}, {"x": "12:00", "y": 45}, {"x": "14:00", "y": 47}, {"x": "16:00", "y": 46}, {"x": "18:00", "y": 45}, {"x": "20:00", "y": 45}]}],
}
+106
View File
@@ -0,0 +1,106 @@
import uuid as uuid_lib
from django.db import models
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)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "sensor_catalogs"
ordering = ["code"]
def __str__(self):
return self.name
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(
DeviceCatalog,
on_delete=models.PROTECT,
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="")
is_active = models.BooleanField(default=True)
specifications = models.JSONField(default=dict, blank=True)
power_source = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "farm_sensors"
ordering = ["-created_at"]
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)
sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True)
physical_device_uuid = models.UUIDField(db_index=True)
payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "sensor_external_request_logs"
ordering = ["-created_at", "-id"]
def __str__(self):
return f"{self.physical_device_uuid}:{self.created_at.isoformat()}"
+60
View File
@@ -0,0 +1,60 @@
from datetime import timedelta
import uuid
from django.db import transaction
from django.utils import timezone
from farm_hub.seeds import seed_admin_farm
from .models import DeviceCatalog, FarmDevice, SensorExternalRequestLog
SENSOR_7_IN_1_CATALOG_CODE = "sensor-7-in-1"
SENSOR_7_IN_1_DEVICE_UUID = uuid.UUID("77777777-7777-7777-7777-777777777777")
SENSOR_7_IN_1_LOG_SERIES = [
{"days_ago": 6, "payload": {"soil_moisture": 44.0, "soil_temperature": 20.6, "soil_ph": 6.3, "electrical_conductivity": 1.0, "nitrogen": 25.0, "phosphorus": 13.0, "potassium": 21.0}},
{"days_ago": 5, "payload": {"soil_moisture": 45.5, "soil_temperature": 21.1, "soil_ph": 6.4, "electrical_conductivity": 1.1, "nitrogen": 26.0, "phosphorus": 13.8, "potassium": 21.8}},
{"days_ago": 4, "payload": {"soil_moisture": 46.8, "soil_temperature": 21.7, "soil_ph": 6.5, "electrical_conductivity": 1.1, "nitrogen": 27.4, "phosphorus": 14.2, "potassium": 22.5}},
{"days_ago": 3, "payload": {"soil_moisture": 48.2, "soil_temperature": 22.0, "soil_ph": 6.6, "electrical_conductivity": 1.2, "nitrogen": 28.9, "phosphorus": 15.1, "potassium": 23.3}},
{"days_ago": 2, "payload": {"soil_moisture": 49.6, "soil_temperature": 22.4, "soil_ph": 6.6, "electrical_conductivity": 1.2, "nitrogen": 29.7, "phosphorus": 15.7, "potassium": 24.1}},
{"days_ago": 1, "payload": {"soil_moisture": 50.9, "soil_temperature": 22.8, "soil_ph": 6.7, "electrical_conductivity": 1.3, "nitrogen": 30.8, "phosphorus": 16.2, "potassium": 24.8}},
{"days_ago": 0, "payload": {"soil_moisture": 52.4, "soil_temperature": 23.1, "soil_ph": 6.8, "electrical_conductivity": 1.3, "nitrogen": 32.0, "phosphorus": 16.8, "potassium": 25.6}},
]
def seed_sensor_7_in_1_catalog():
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"],
"sample_payload": SENSOR_7_IN_1_LOG_SERIES[-1]["payload"],
"is_active": True,
},
)
return sensor_catalog, created
@transaction.atomic
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 = 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"}},
)
SensorExternalRequestLog.objects.filter(farm_uuid=farm.farm_uuid, physical_device_uuid=sensor.physical_device_uuid).delete()
base_time = timezone.now().replace(hour=12, minute=0, second=0, microsecond=0)
created_logs = []
for item in SENSOR_7_IN_1_LOG_SERIES:
log = SensorExternalRequestLog.objects.create(farm_uuid=farm.farm_uuid, sensor_catalog_uuid=sensor_catalog.uuid, physical_device_uuid=sensor.physical_device_uuid, payload=item["payload"])
created_at = base_time - timedelta(days=item["days_ago"])
SensorExternalRequestLog.objects.filter(id=log.id).update(created_at=created_at)
log.created_at = created_at
created_logs.append(log)
return {"farm": farm, "sensor_catalog": sensor_catalog, "sensor": sensor, "catalog_created": catalog_created, "sensor_created": sensor_created, "log_count": len(created_logs)}
@@ -0,0 +1,9 @@
from django.urls import path
from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
urlpatterns = [
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
path("radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"),
path("comparison-chart/", Sensor7In1ComparisonChartView.as_view(), name="sensor-7-in-1-comparison-chart"),
]
@@ -0,0 +1,7 @@
from django.urls import path
from .views import DeviceCatalogListView
urlpatterns = [
path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"),
]
@@ -0,0 +1,9 @@
from django.urls import path
from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView
urlpatterns = [
path("", SensorExternalAPIView.as_view(), name="sensor-external-api"),
path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
]
@@ -0,0 +1,111 @@
from rest_framework import serializers
from soil.serializers import SoilAnomalyDetectionSerializer, SoilComparisonChartSerializer, SoilKpiSerializer, SoilMoistureHeatmapSerializer, SoilRadarChartSerializer
class Sensor7In1MetaSerializer(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 Sensor7In1ValueSerializer(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 Sensor7In1ValuesListSerializer(serializers.Serializer):
sensor = Sensor7In1MetaSerializer(required=False)
sensors = Sensor7In1ValueSerializer(many=True, required=False)
class SensorComparisonChartQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
range = serializers.ChoiceField(choices=["7d", "30d"], required=False, default="7d")
class SensorValuesListQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
range = serializers.ChoiceField(choices=["1h", "24h", "7d"], required=False, default="7d")
class SensorRadarChartQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
range = serializers.ChoiceField(choices=["today", "7d", "30d"], required=False, default="7d")
class SensorComparisonChartSeriesSerializer(serializers.Serializer):
name = serializers.CharField()
data = serializers.ListField(child=serializers.FloatField())
class SensorComparisonChartResponseSerializer(serializers.Serializer):
series = SensorComparisonChartSeriesSerializer(many=True)
categories = serializers.ListField(child=serializers.CharField())
currentValue = serializers.FloatField()
vsLastWeek = serializers.CharField()
class SensorValuesListItemSerializer(serializers.Serializer):
title = serializers.CharField()
subtitle = serializers.CharField()
trendNumber = serializers.FloatField()
trend = serializers.ChoiceField(choices=["positive", "negative"])
unit = serializers.CharField()
class SensorValuesListResponseSerializer(serializers.Serializer):
sensors = SensorValuesListItemSerializer(many=True)
class SensorRadarChartResponseSerializer(serializers.Serializer):
labels = serializers.ListField(child=serializers.CharField())
series = SensorComparisonChartSeriesSerializer(many=True)
class Sensor7In1SummarySerializer(serializers.Serializer):
sensor = Sensor7In1MetaSerializer(required=False)
sensorValuesList = Sensor7In1ValuesListSerializer(required=False)
avgSoilMoisture = SoilKpiSerializer(required=False)
sensorRadarChart = SoilRadarChartSerializer(required=False)
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
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)
+196
View File
@@ -0,0 +1,196 @@
from rest_framework import serializers
from .models import DeviceCatalog, FarmDevice, SensorExternalRequestLog
class DeviceCatalogSerializer(serializers.ModelSerializer):
class Meta:
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",
]
read_only_fields = fields
class SensorExternalRequestSerializer(serializers.Serializer):
uuid = serializers.UUIDField()
payload = serializers.JSONField(required=False, default=dict)
class SensorExternalRequestLogQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
page = serializers.IntegerField(min_value=1)
page_size = serializers.IntegerField(min_value=1, max_value=100)
physical_device_uuid = serializers.UUIDField(required=False)
sensor_type = serializers.CharField(required=False, allow_blank=False)
date_from = serializers.DateField(required=False)
date_to = serializers.DateField(required=False)
def validate(self, attrs):
date_from = attrs.get("date_from")
date_to = attrs.get("date_to")
if date_from and date_to and date_from > date_to:
raise serializers.ValidationError({"date_to": "date_to must be greater than or equal to date_from."})
return attrs
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 = FarmDevice
fields = [
"uuid",
"sensor_catalog_uuid",
"device_catalogs",
"physical_device_uuid",
"name",
"sensor_type",
"is_active",
"specifications",
"power_source",
"created_at",
"updated_at",
]
class DeviceCatalogLogSerializer(serializers.ModelSerializer):
class Meta:
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",
"updated_at",
]
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 DeviceCodeListResponseSerializer(serializers.Serializer):
physical_device_uuid = serializers.UUIDField()
device_codes = serializers.ListField(child=serializers.CharField())
class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
farm_device = serializers.SerializerMethodField()
sensor_catalog = serializers.SerializerMethodField()
class Meta:
model = SensorExternalRequestLog
fields = [
"id",
"farm_uuid",
"sensor_catalog_uuid",
"physical_device_uuid",
"farm_device",
"sensor_catalog",
"payload",
"created_at",
]
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_device_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
if farm_device is None:
return None
return FarmDeviceLogSerializer(farm_device).data
def get_sensor_catalog(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_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 DeviceCatalogLogSerializer(farm_device.sensor_catalog).data
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
AVG_SOIL_MOISTURE_TEMPLATE = {
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "سنسور 7 در 1 خاک",
"stats": None,
"avatarColor": "secondary",
"avatarIcon": "tabler-droplet",
"chipText": "بدون داده",
"chipColor": "secondary",
}
SENSOR_META_TEMPLATE = {
"name": "سنسور 7 در 1 خاک",
"physicalDeviceUuid": None,
"sensorCatalogCode": "sensor-7-in-1",
"updatedAt": None,
}
SOIL_MOISTURE_HEATMAP_TEMPLATE = {
"zones": [],
"hours": [],
"series": [],
}
+170
View File
@@ -0,0 +1,170 @@
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 .services import DeviceDataUnavailableError, build_device_anomaly_detection_card
from .views import DeviceCodeListView, 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_code_list_view_returns_attached_device_codes(self):
secondary_catalog = DeviceCatalog.objects.create(
code="air_sensor_v1",
name="Air Sensor V1",
device_communication_type=DeviceCatalog.OUTPUT_ONLY,
)
self.device.device_catalogs.add(self.catalog, secondary_catalog)
request = self.factory.get(
f"/api/device-hub/devices/{self.device.physical_device_uuid}/device-codes/",
)
force_authenticate(request, user=self.user)
response = DeviceCodeListView.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_codes"], [self.catalog.code, secondary_catalog.code])
def test_device_code_list_view_returns_primary_catalog_when_no_m2m_catalogs_exist(self):
request = self.factory.get(
f"/api/device-hub/devices/{self.device.physical_device_uuid}/device-codes/",
)
force_authenticate(request, user=self.user)
response = DeviceCodeListView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["device_codes"], [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_device_summary_view_returns_validation_error_when_history_missing(self):
SensorExternalRequestLog.objects.all().delete()
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, 400)
self.assertIn("no device history found", response.data["device_code"][0].lower())
def test_build_device_anomaly_detection_card_returns_explicit_empty_success(self):
payload = build_device_anomaly_detection_card(self.device)
self.assertEqual(payload["status"], "success")
self.assertEqual(payload["source"], "db")
self.assertEqual(payload["anomalies"], [])
self.assertTrue(payload["warnings"])
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)
+20
View File
@@ -0,0 +1,20 @@
from django.urls import path
from .views import DeviceCatalogListView, DeviceCodeListView, 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>/device-codes/", DeviceCodeListView.as_view(), name="device-code-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("", 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"),
]
+329
View File
@@ -0,0 +1,329 @@
from rest_framework import serializers, status
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes, extend_schema
from config.swagger import code_response, farm_uuid_query_param
from farm_hub.models import FarmHub
from notifications.serializers import FarmNotificationSerializer
from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer
from .authentication import SensorExternalAPIKeyAuthentication
from .sensor_serializers import DeviceSummarySerializer, Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer
from .serializers import DeviceCatalogSerializer, DeviceCodeListResponseSerializer, DeviceCodeQuerySerializer, DeviceCommandRequestSerializer, DeviceCommandResponseSerializer, DeviceDetailSerializer, DeviceLatestPayloadSerializer, DeviceRangeQuerySerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer
from .services import DeviceDataUnavailableError, 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 DeviceCatalogListView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(tags=["Sensor Catalog"], responses={200: code_response("DeviceCatalogListResponse", data=DeviceCatalogSerializer(many=True))})
def get(self, request):
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 DeviceCodeListView(DeviceBaseView):
@extend_schema(tags=["Device Hub"], responses={200: code_response("DeviceCodeListResponse", data=DeviceCodeListResponseSerializer())})
def get(self, request, physical_device_uuid):
farm_device = self.get_farm_device(request, physical_device_uuid)
device_codes = [catalog.code for catalog in farm_device.get_device_catalogs() if getattr(catalog, "code", "")]
return Response(
{
"code": 200,
"msg": "success",
"data": {
"physical_device_uuid": farm_device.physical_device_uuid,
"device_codes": device_codes,
},
},
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 SensorExternalRequestLogPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
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):
permission_classes = [IsAuthenticated]
required_feature_code = "sensor-7-in-1"
@staticmethod
def _get_farm(request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
try:
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
except FarmHub.DoesNotExist as exc:
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
@staticmethod
def _get_primary_sensor(*, farm):
sensor = get_primary_soil_sensor(farm=farm)
if sensor is None:
raise serializers.ValidationError({"farm_uuid": ["No sensor found for this farm."]})
return sensor
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.")], responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())})
def get(self, request):
farm = self._get_farm(request)
try:
data = get_sensor_7_in_1_summary_data(farm)
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK)
class Sensor7In1RadarChartView(Sensor7In1SummaryView):
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.")], responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())})
def get(self, request):
farm = self._get_farm(request)
try:
data = get_sensor_7_in_1_radar_chart_data(farm)
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK)
class Sensor7In1ComparisonChartView(Sensor7In1SummaryView):
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.")], responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())})
def get(self, request):
farm = self._get_farm(request)
try:
data = get_sensor_7_in_1_comparison_chart_data(farm)
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK)
class SensorComparisonChartView(Sensor7In1SummaryView):
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), 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):
serializer = SensorComparisonChartQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request)
sensor = self._get_primary_sensor(farm=farm)
try:
data = get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"])
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response(data, status=status.HTTP_200_OK)
class SensorValuesListView(Sensor7In1SummaryView):
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), 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):
serializer = SensorValuesListQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request)
sensor = self._get_primary_sensor(farm=farm)
try:
data = get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"])
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response(data, status=status.HTTP_200_OK)
class SensorRadarChartView(Sensor7In1SummaryView):
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), 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):
serializer = SensorRadarChartQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request)
sensor = self._get_primary_sensor(farm=farm)
try:
data = get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"])
except DeviceDataUnavailableError as exc:
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
return Response(data, status=status.HTTP_200_OK)
class SensorExternalAPIView(APIView):
authentication_classes = [SensorExternalAPIKeyAuthentication]
permission_classes = [AllowAny]
@extend_schema(tags=["Sensor External API"], request=SensorExternalRequestSerializer, examples=[OpenApiExample("Sensor External API Request", value={"uuid": "22222222-2222-2222-2222-222222222222", "payload": {"moisture_percent": 32.5, "temperature_c": 21.3, "ph": 6.7, "ec_ds_m": 1.1, "nitrogen_mg_kg": 42, "phosphorus_mg_kg": 18, "potassium_mg_kg": 210}}, request_only=True)], parameters=[OpenApiParameter(name="X-API-Key", type=OpenApiTypes.STR, location=OpenApiParameter.HEADER, required=True, default="12345", description="API key for sensor external API.")], responses={201: code_response("SensorExternalAPIResponse", data=FarmNotificationSerializer()), 401: code_response("SensorExternalAPIUnauthorizedResponse"), 404: code_response("SensorExternalAPIDeviceNotFoundResponse"), 503: code_response("SensorExternalAPIUnavailableResponse")})
def post(self, request):
serializer = SensorExternalRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
notification = create_sensor_external_notification(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
forward_sensor_payload_to_farm_data(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
except ValueError as exc:
if "not migrated" in str(exc):
return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
except FarmDataForwardError as exc:
return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response({"code": 201, "msg": "success", "data": FarmNotificationSerializer(notification).data}, status=status.HTTP_201_CREATED)
class SensorExternalRequestLogListAPIView(APIView):
permission_classes = [IsAuthenticated]
pagination_class = SensorExternalRequestLogPagination
@extend_schema(tags=["Sensor External API"], parameters=[OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="physical_device_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="sensor_type", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="date_from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="date_to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False)], responses={200: code_response("SensorExternalRequestLogListResponse", data=SensorExternalRequestLogSerializer(many=True), extra_fields={"count": serializers.IntegerField(), "next": serializers.CharField(allow_null=True), "previous": serializers.CharField(allow_null=True)}), 401: code_response("SensorExternalRequestLogListUnauthorizedResponse"), 503: code_response("SensorExternalRequestLogListUnavailableResponse")})
def get(self, request):
serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
try:
queryset = get_sensor_external_request_logs_for_farm(farm_uuid=serializer.validated_data["farm_uuid"], physical_device_uuid=serializer.validated_data.get("physical_device_uuid"), sensor_type=serializer.validated_data.get("sensor_type"), date_from=serializer.validated_data.get("date_from"), date_to=serializer.validated_data.get("date_to"))
except ValueError:
return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
paginator = self.pagination_class()
paginator.page_size = serializer.validated_data["page_size"]
page = paginator.paginate_queryset(queryset, request, view=self)
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)