UPDATE
This commit is contained in:
@@ -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`
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
+42
@@ -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 @@
|
||||
|
||||
@@ -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}]}],
|
||||
}
|
||||
|
||||
@@ -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()}"
|
||||
@@ -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)
|
||||
@@ -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
@@ -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": [],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user