update iot panel will now listen to push from panel

This commit is contained in:
Alberto-Audrix committed 2026-09-21 14:38:17 +07:00
1 parent 20d04acb3e
commit 520c4bcaad
10 files changed
+158 -754

No files matched your search

@@ -1,239 +0,0 @@
"""Seed 10-minute IoT panel readings (144/day) for every flock.
Kandang 2 Lantai 1 is seeded from real sensor snapshots pulled from
``dashboard.cpsp.id/api/iot/flocks/`` (cached in ``scripts/iot_panel_10min_extract.json``);
every other flock/floor is generated from that data with a deterministic per-house
jitter so each cycle day holds a full 00:00..23:50 run of 144 readings.
Run ``scripts/extract_iot_10min.py`` to refresh the cache against the live API.
"""
from __future__ import annotations
import json
import random
from datetime import date as date_cls
from datetime import datetime, time, timedelta
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from apps.farms.models import Cycle, Flock
SLOTS_PER_DAY = 144 # 24h * 6
CHILL_FACTOR_POINTS = (
(0, 8.0),
(1, 8.0),
(7, 7.0),
(14, 6.0),
(21, 4.5),
(28, 3.5),
(35, 3.5),
(42, 3.0),
)
def chicken_chill_factor(age: float) -> float:
if age <= CHILL_FACTOR_POINTS[0][0]:
return CHILL_FACTOR_POINTS[0][1]
for idx in range(1, len(CHILL_FACTOR_POINTS)):
prev_age, prev_factor = CHILL_FACTOR_POINTS[idx - 1]
next_age, next_factor = CHILL_FACTOR_POINTS[idx]
if age > next_age:
continue
span = next_age - prev_age
if span <= 0:
return next_factor
progress = (age - prev_age) / span
return prev_factor + (next_factor - prev_factor) * progress
return CHILL_FACTOR_POINTS[-1][1]
def experience_temperature(
*, average_temperature: float, wind_speed: float, humidity_pct: float, age_days: float
) -> float:
chill_factor = chicken_chill_factor(age_days)
rh_adjustment = (humidity_pct - 70.0) / 5.0
chill_effect = wind_speed * chill_factor - rh_adjustment
return average_temperature - chill_effect
def lerp(a: float, b: float, frac: float) -> float:
return a + (b - a) * frac
def slot_timestamp(day: date_cls, slot: int) -> datetime:
naive = datetime.combine(day, time(0, 0)) + timedelta(minutes=10 * slot)
return timezone.make_aware(naive, timezone.get_current_timezone())
def gap_fill_day(day_rows: dict[int, dict]) -> dict[int, dict]:
"""Return 144 slots for a day, interpolating any missing 10-minute windows."""
present = sorted(day_rows.items())
filled: dict[int, dict] = {}
for slot in range(SLOTS_PER_DAY):
if slot in day_rows:
filled[slot] = dict(day_rows[slot])
continue
prev = next = None
for s, row in present:
if s < slot:
prev = (s, row)
elif s > slot and next is None:
next = (s, row)
break
if prev is None and next is not None:
filled[slot] = dict(next[1])
elif next is None and prev is not None:
filled[slot] = dict(prev[1])
elif prev is not None and next is not None:
ps, prow = prev
ns, nrow = next
frac = (slot - ps) / (ns - ps)
filled[slot] = {
"wind_speed": lerp(prow["wind_speed"], nrow["wind_speed"], frac),
"humidity": lerp(prow["humidity"], nrow["humidity"], frac),
"water_total": lerp(prow["water_total"], nrow["water_total"], frac),
"average_temperature": lerp(
prow["average_temperature"], nrow["average_temperature"], frac
),
}
return filled
class Command(BaseCommand):
help = "Seed 10-minute IoT panel readings (144/day) for all flocks"
def add_arguments(self, parser):
parser.add_argument("--iot-json", default="")
parser.add_argument("--cycle-start", default="2026-05-22")
parser.add_argument("--no-reset", action="store_true")
parser.add_argument("--jitter-seed", type=int, default=7)
def load_real_rows(self, path: Path) -> dict[str, dict[int, dict]]:
if not path.is_file():
raise CommandError(f"IoT 10-min extract not found: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
by_date: dict[str, dict[int, dict]] = {}
for row in data["rows"]:
ts = datetime.fromisoformat(row["timestamp"])
slot = (ts.hour * 60 + ts.minute) // 10
by_date.setdefault(row["date"], {})[slot] = row
return by_date
@transaction.atomic
def handle(self, *args, **options):
iot_json = options["iot_json"] or str(
Path(settings.BASE_DIR).resolve().parent / "scripts" / "iot_panel_10min_extract.json"
)
cycle_start = date_cls.fromisoformat(options["cycle_start"])
real_by_date = self.load_real_rows(Path(iot_json))
cycles = Cycle.objects.filter(
start_date=cycle_start, kandang__flocks__isnull=False
).distinct()
if not cycles:
raise CommandError(f"No cycles starting {cycle_start} with flocks")
seeded = 0
for cycle in cycles:
kandang_name = cycle.kandang.kandang_name
for flock in cycle.kandang.flocks.order_by("flock_id"):
profile = self.profile_for(kandang_name, flock.flock_name)
rng = random.Random(options["jitter_seed"] + flock.pk)
rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng)
if not options["no_reset"]:
flock.iot_panels.all().delete()
for chunk in range(0, len(rows), 500):
flock.iot_panels.bulk_create(rows[chunk : chunk + 500])
seeded += len(rows)
self.stdout.write(
f" {kandang_name} / {flock.flock_name}: {len(rows)} readings"
)
self.stdout.write(self.style.SUCCESS(f"Seeded {seeded} IoT panel readings"))
def profile_for(self, kandang_name: str, flock_name: str) -> dict:
"""Per-house transform applied to the real Kandang 2 Lantai 1 readings."""
is_lantai1 = flock_name == "Lantai 1"
if kandang_name == "Kandang 2":
if is_lantai1:
return {"real": True}
return {
"wind_delta": -0.05,
"humidity_delta": 1.2,
"temperature_delta": -0.3,
"water_factor": 0.96,
}
# Kandang 1: deterministic jitter (same approach as seed_sukawarna_kandang4).
return {
"temperature_jitter": (-0.4, 0.4),
"humidity_jitter": (-3.0, 3.0),
"wind_jitter": (-0.1, 0.1),
"water_factor": 0.9,
"water_jitter_abs": (-120.0, 120.0),
"humidity_delta": 1.2 if not is_lantai1 else 0.0,
"temperature_delta": -0.3 if not is_lantai1 else 0.0,
}
def build_flock_rows(self, flock: Flock, cycle: Cycle, real: dict, profile: dict, rng: random.Random) -> list:
from apps.operations.models import IotPanel
day = cycle.start_date
rows: list[IotPanel] = []
while day <= cycle.end_date:
iso = day.isoformat()
age_days = (day - cycle.start_date).days + 1
if profile.get("real"):
source = gap_fill_day(real.get(iso, {}))
else:
source = gap_fill_day(real.get(iso, {}))
if not source:
day += timedelta(days=1)
continue
real_total = source.get(SLOTS_PER_DAY - 1, {}).get("water_total", 0.0) or 0.0
target_total = real_total * profile.get("water_factor", 1.0)
if profile.get("water_jitter_abs"):
target_total += rng.uniform(*profile["water_jitter_abs"])
target_total = max(0.0, target_total)
water_scale = target_total / real_total if real_total else 0.0
for slot in range(SLOTS_PER_DAY):
base = source[slot]
avg_temp = base.get("average_temperature", 0.0) + profile.get("temperature_delta", 0.0)
if profile.get("temperature_jitter"):
avg_temp += rng.uniform(*profile["temperature_jitter"])
humidity = base.get("humidity", 0.0) + profile.get("humidity_delta", 0.0)
if profile.get("humidity_jitter"):
humidity += rng.uniform(*profile["humidity_jitter"])
humidity = min(100.0, max(0.0, humidity))
wind = base.get("wind_speed", 0.0) + profile.get("wind_delta", 0.0)
if profile.get("wind_jitter"):
wind += rng.uniform(*profile["wind_jitter"])
wind = max(0.0, wind)
water = base.get("water_total", 0.0) * water_scale
exp_temp = experience_temperature(
average_temperature=avg_temp,
wind_speed=wind,
humidity_pct=humidity,
age_days=age_days,
)
rows.append(
IotPanel(
flock=flock,
date=day,
timestamp=slot_timestamp(day, slot),
wind_speed=round(wind, 2),
humidity=round(humidity, 1),
water_total=round(water, 1),
average_temperature=round(avg_temp, 2),
experience_temperature=round(exp_temp, 2),
)
)
day += timedelta(days=1)
return rows
@@ -1,23 +0,0 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.operations.services.iot_api import IotApiError, sync_iot_panels
class Command(BaseCommand):
help = "Sync IoT panel readings from the external IoT flocks API (10-minute snapshots)"
def handle(self, *args, **options):
if not settings.IOT_SYNC_ENABLED:
self.stdout.write("IOT_SYNC_ENABLED=false; skipping")
return
try:
summary = sync_iot_panels()
except IotApiError as exc:
self.stderr.write(str(exc))
return
self.stdout.write(
self.style.SUCCESS(
f"IoT sync OK: upserted={summary.upserted} skipped={summary.skipped} pages={summary.pages}"
)
)
+44 -220
View File
@@ -1,14 +1,10 @@
"""Sync IoT panel readings from dashboard.cpsp.id/api/iot/flocks/ (10-minute snapshots)."""
"""Handle IoT panel push readings (webhook from panel IoT devices)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone as dt_timezone
from typing import Any
from datetime import date, datetime, timezone as dt_timezone
from zoneinfo import ZoneInfo
import httpx
from django.conf import settings
from django.utils import timezone
from apps.farms.models import Cycle, Flock
@@ -28,65 +24,15 @@ CHILL_FACTOR_POINTS = (
)
class IotApiError(Exception):
class IotPushError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def _sensor_celsius(sensor: dict | float | int | None) -> float | None:
if sensor is None:
return None
if isinstance(sensor, (int, float)):
return float(sensor)
raw = sensor.get("value")
if raw is None:
return None
cal = float(sensor.get("calibration") or 0)
return (float(raw) + cal) / 10.0
def _sensor_humidity_pct(sensor: dict | float | int | None, fallback: float | None) -> float:
if isinstance(sensor, dict):
raw = sensor.get("value")
if raw is not None:
cal = float(sensor.get("calibration") or 0)
return (float(raw) + cal) / 10.0
if isinstance(sensor, (int, float)):
return float(sensor)
if fallback is None:
return 70.0
value = float(fallback)
return value / 10.0 if value > 100 else value
def _wind_ms(payload_wind: float | int | None, sensors_wind: dict | float | int | None) -> float:
if isinstance(sensors_wind, dict) and sensors_wind.get("value") is not None:
return float(sensors_wind["value"]) / 10.0
if isinstance(sensors_wind, (int, float)):
return float(sensors_wind)
if payload_wind is None:
return 0.0
return float(payload_wind) / 10.0
def _average_inside_temp_c(sensors: dict | None, actual_temperature: float | int | None) -> float:
if sensors:
readings = [
v
for v in (
_sensor_celsius(sensors.get("temperature1")),
_sensor_celsius(sensors.get("temperature2")),
_sensor_celsius(sensors.get("temperature3")),
)
if v is not None
]
if readings:
return sum(readings) / len(readings)
if actual_temperature is None:
return 0.0
value = float(actual_temperature)
return value / 10.0 if value > 100 else value
def _to_float(value) -> float:
raw = float(value)
return raw / 10.0 if raw > 100 else raw
def _chicken_chill_factor(age: float) -> float:
@@ -118,174 +64,52 @@ def _experience_temperature(
return average_temperature - chill_effect
def panel_fields_from_payload(data: dict, age_days: int) -> dict[str, float]:
sensors = data.get("sensors") or {}
humidity = _sensor_humidity_pct(sensors.get("humidity"), data.get("humidity"))
wind_speed = _wind_ms(data.get("wind"), sensors.get("wind"))
avg_temp = _average_inside_temp_c(sensors, data.get("actualTemperature"))
water = data.get("water")
if water is None and isinstance(sensors.get("water"), dict):
water = sensors["water"].get("value")
return {
"wind_speed": round(wind_speed, 4),
"humidity": round(humidity, 4),
"water_total": float(water or 0),
"average_temperature": round(avg_temp, 4),
"experience_temperature": round(
_experience_temperature(
average_temperature=avg_temp,
wind_speed=wind_speed,
humidity_pct=humidity,
age_days=age_days,
),
4,
),
}
def parse_fetched_at(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if timezone.is_naive(parsed):
return timezone.make_aware(parsed, dt_timezone.utc)
return parsed
def cycle_age_for_date(cycle_start: date, snapshot_date: date) -> int:
if snapshot_date < cycle_start:
return 1
return (snapshot_date - cycle_start).days + 1
@dataclass
class SyncSummary:
upserted: int
skipped: int
pages: int
def handle_iot_push(flock: Flock, payload: dict) -> IotPanel:
now = timezone.now()
snapshot_date = now.astimezone(WIB).date()
avg_temp = _to_float(payload.get("suhu", 0))
humidity = _to_float(payload.get("kelembapan", 0))
wind_speed = _to_float(payload.get("wind", 0))
water_total = float(payload.get("flow", 0) or 0)
class IotApiClient:
def __init__(self, base_url: str, timeout: float | None = None):
self.base_url = (base_url or "").strip()
if not self.base_url:
raise IotApiError("IoT API URL is empty")
self.timeout = timeout or settings.IOT_API_SYNC_TIMEOUT_SECONDS
def fetch_page(self, page: int) -> dict[str, Any]:
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
parts = urlsplit(self.base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["page"] = str(page)
url = urlunsplit(
(parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)
cycle = (
Cycle.objects.filter(
kandang_id=flock.kandang_id,
start_date__lte=snapshot_date,
end_date__gte=snapshot_date,
)
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise IotApiError(f"IoT API request failed: {exc}") from exc
if response.status_code >= 400:
raise IotApiError(
f"IoT API HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
return response.json()
.order_by("-start_date")
.first()
)
age = cycle_age_for_date(cycle.start_date, snapshot_date) if cycle else 1
experience_temp = round(
_experience_temperature(
average_temperature=avg_temp,
wind_speed=wind_speed,
humidity_pct=humidity,
age_days=age,
),
4,
)
def _sync_flock_pages(
flock: Flock,
*,
lookback_minutes: int,
max_pages: int,
cutoff,
) -> SyncSummary:
client = IotApiClient(flock.iot_api_url)
upserted = 0
skipped = 0
pages_read = 0
for page in range(1, max_pages + 1):
payload = client.fetch_page(page)
pages_read += 1
results = payload.get("results") or []
if not results:
break
page_has_recent = False
for record in results:
fetched_raw = record.get("fetched_at")
payload_data = (record.get("payload") or {}).get("data")
if not fetched_raw or not payload_data:
skipped += 1
continue
fetched_at = parse_fetched_at(fetched_raw)
if fetched_at >= cutoff:
page_has_recent = True
elif page > 1:
continue
snapshot_date = fetched_at.astimezone(WIB).date()
cycle = (
Cycle.objects.filter(
kandang_id=flock.kandang_id,
start_date__lte=snapshot_date,
end_date__gte=snapshot_date,
)
.order_by("-start_date")
.first()
)
age = cycle_age_for_date(cycle.start_date, snapshot_date) if cycle else 1
fields = panel_fields_from_payload(payload_data, age)
IotPanel.objects.update_or_create(
flock=flock,
timestamp=fetched_at,
defaults={
"date": snapshot_date,
**fields,
},
)
upserted += 1
if not page_has_recent and page > 1:
break
return SyncSummary(upserted=upserted, skipped=skipped, pages=pages_read)
def sync_iot_panels(
*,
flock_id: int | None = None,
lookback_minutes: int | None = None,
max_pages: int | None = None,
) -> SyncSummary:
lookback = lookback_minutes if lookback_minutes is not None else settings.IOT_SYNC_LOOKBACK_MINUTES
page_limit = max_pages if max_pages is not None else settings.IOT_SYNC_MAX_PAGES
cutoff = timezone.now() - timedelta(minutes=lookback)
flocks = Flock.objects.select_related("kandang").exclude(iot_api_url="")
if flock_id is not None:
flocks = flocks.filter(pk=flock_id)
flock_list = list(flocks)
if not flock_list:
return SyncSummary(upserted=0, skipped=0, pages=0)
upserted = 0
skipped = 0
pages_read = 0
for flock in flock_list:
if not (flock.iot_api_url or "").strip():
continue
summary = _sync_flock_pages(
flock,
lookback_minutes=lookback,
max_pages=page_limit,
cutoff=cutoff,
)
upserted += summary.upserted
skipped += summary.skipped
pages_read += summary.pages
return SyncSummary(upserted=upserted, skipped=skipped, pages=pages_read)
panel, _created = IotPanel.objects.update_or_create(
flock=flock,
timestamp=now,
defaults={
"date": snapshot_date,
"wind_speed": round(wind_speed, 4),
"humidity": round(humidity, 4),
"water_total": water_total,
"average_temperature": round(avg_temp, 4),
"experience_temperature": experience_temp,
},
)
return panel
+49 -122
View File
@@ -1,5 +1,4 @@
from datetime import timedelta
from unittest.mock import patch
from django.test import Client, TestCase
from django.utils import timezone
@@ -7,42 +6,26 @@ from django.utils import timezone
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import IotPanel
from apps.operations.services.iot_api import IotApiClient, sync_iot_panels
SAMPLE_PAYLOAD = {
"results": [
{
"flock_id": "other-external-id",
"fetched_at": timezone.now().isoformat().replace("+00:00", "Z"),
"payload": {
"data": {
"actualTemperature": 300,
"humidity": 700,
"wind": 5,
"water": 12,
"sensors": {},
}
},
}
]
SAMPLE_PUSH_PAYLOAD = {
"suhu": "30.0",
"kelembapan": "70.0",
"wind": "5.0",
"flow": "12.0",
"total": "0.00",
}
class SyncIotPanelsPerFlockTests(TestCase):
class IotPanelPushTests(TestCase):
def setUp(self):
owner = User.objects.create_user(user_name="owner", password="secret")
self.site = Site.objects.create(site_name="Site A", user=owner)
self.owner = User.objects.create_user(user_name="owner", password="secret")
self.site = Site.objects.create(site_name="Site A", user=self.owner)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
self.flock_a = Flock.objects.create(
self.flock = Flock.objects.create(
flock_name="Lantai 1",
kandang=self.kandang,
iot_api_url="https://example.test/iot/a?token=1",
)
self.flock_b = Flock.objects.create(
flock_name="Lantai 2",
kandang=self.kandang,
iot_api_url="",
iot_api_url="http://192.168.1.100:1123/",
)
today = timezone.localdate()
Cycle.objects.create(
@@ -52,108 +35,52 @@ class SyncIotPanelsPerFlockTests(TestCase):
doc_in_weight=40,
doc_in_count=10000,
)
self.client = Client()
@patch.object(IotApiClient, "fetch_page")
def test_upserts_on_flock_with_url_and_ignores_upstream_flock_id(self, mock_fetch):
mock_fetch.return_value = SAMPLE_PAYLOAD
summary = sync_iot_panels(max_pages=1)
self.assertEqual(summary.upserted, 1)
self.assertEqual(IotPanel.objects.filter(flock=self.flock_a).count(), 1)
self.assertEqual(IotPanel.objects.filter(flock=self.flock_b).count(), 0)
mock_fetch.assert_called()
@patch.object(IotApiClient, "fetch_page")
def test_blank_url_flock_never_fetched(self, mock_fetch):
mock_fetch.return_value = SAMPLE_PAYLOAD
summary = sync_iot_panels(flock_id=self.flock_b.pk, max_pages=1)
self.assertEqual(summary.upserted, 0)
self.assertEqual(summary.pages, 0)
mock_fetch.assert_not_called()
@patch.object(IotApiClient, "fetch_page")
def test_flock_id_filter_only_syncs_that_flock(self, mock_fetch):
mock_fetch.return_value = SAMPLE_PAYLOAD
flock_c = Flock.objects.create(
flock_name="Lantai 3",
kandang=self.kandang,
iot_api_url="https://example.test/iot/c",
)
summary = sync_iot_panels(flock_id=flock_c.pk, max_pages=1)
self.assertEqual(summary.upserted, 1)
self.assertEqual(IotPanel.objects.filter(flock=flock_c).count(), 1)
self.assertEqual(IotPanel.objects.filter(flock=self.flock_a).count(), 0)
def test_fetch_page_merges_existing_query_string(self):
client = IotApiClient("https://example.test/iot/a?token=1")
with patch("apps.operations.services.iot_api.httpx.Client") as mock_client_cls:
mock_client = mock_client_cls.return_value.__enter__.return_value
mock_response = mock_client.get.return_value
mock_response.status_code = 200
mock_response.json.return_value = {"results": []}
client.fetch_page(2)
called_url = mock_client.get.call_args.args[0]
self.assertIn("token=1", called_url)
self.assertIn("page=2", called_url)
class PanelIotSyncActionTests(TestCase):
def setUp(self):
self.owner = User.objects.create_user(user_name="owner", password="secret")
self.staff = User.objects.create_user(
user_name="staff",
password="staff123",
status=User.STATUS_ACTIVE,
is_staff=True,
)
self.site = Site.objects.create(site_name="Site A", user=self.owner)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
self.flock = Flock.objects.create(
flock_name="Lantai 1",
kandang=self.kandang,
iot_api_url="https://example.test/iot/a",
)
self.blank = Flock.objects.create(
flock_name="Lantai 2",
kandang=self.kandang,
iot_api_url="",
)
def _csrf_headers(self, client: Client) -> dict[str, str]:
token = client.cookies["csrftoken"].value
return {"HTTP_X_CSRFTOKEN": token}
def _login(self, client: Client) -> None:
client.get("/api/v1/auth/csrf/")
response = client.post(
"/api/v1/auth/login/",
{"user_name": "staff", "password": "staff123"},
def test_push_creates_panel(self):
response = self.client.post(
f"/api/v1/panel-iot/push/{self.flock.pk}/",
SAMPLE_PUSH_PAYLOAD,
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
data = response.json()
self.assertTrue(data["ok"])
self.assertIsNotNone(data["panel_id"])
panel = IotPanel.objects.get(pk=data["panel_id"])
self.assertEqual(panel.flock_id, self.flock.pk)
self.assertAlmostEqual(panel.average_temperature, 30.0)
self.assertAlmostEqual(panel.humidity, 70.0)
self.assertAlmostEqual(panel.wind_speed, 5.0)
self.assertAlmostEqual(panel.water_total, 12.0)
@patch("apps.operations.views.sync_iot_panels")
def test_sync_action_ok_for_staff(self, mock_sync):
mock_sync.return_value = type("S", (), {"upserted": 2, "skipped": 0, "pages": 1})()
client = Client(enforce_csrf_checks=True)
self._login(client)
response = client.post(
"/api/v1/panel-iot/sync/",
{"flock_id": self.flock.pk},
def test_push_divides_by_10_when_over_100(self):
response = self.client.post(
f"/api/v1/panel-iot/push/{self.flock.pk}/",
{"suhu": "300", "kelembapan": "700", "wind": "50", "flow": "12.0", "total": "0"},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["upserted"], 2)
mock_sync.assert_called_once_with(flock_id=self.flock.pk)
panel = IotPanel.objects.get(pk=response.json()["panel_id"])
self.assertAlmostEqual(panel.average_temperature, 30.0)
self.assertAlmostEqual(panel.humidity, 70.0)
self.assertAlmostEqual(panel.wind_speed, 50.0)
def test_sync_action_rejects_blank_url(self):
client = Client(enforce_csrf_checks=True)
self._login(client)
response = client.post(
"/api/v1/panel-iot/sync/",
{"flock_id": self.blank.pk},
def test_push_nonexistent_flock_returns_404(self):
response = self.client.post(
"/api/v1/panel-iot/push/99999/",
SAMPLE_PUSH_PAYLOAD,
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 400, response.content)
self.assertEqual(response.status_code, 404)
def test_push_computes_experience_temperature(self):
response = self.client.post(
f"/api/v1/panel-iot/push/{self.flock.pk}/",
SAMPLE_PUSH_PAYLOAD,
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
panel = IotPanel.objects.get(pk=response.json()["panel_id"])
self.assertIsNotNone(panel.experience_temperature)
self.assertIsInstance(panel.experience_temperature, float)
+2
View File
@@ -8,6 +8,7 @@ from apps.operations.views import (
FeedSacksViewSet,
HealthView,
IotPanelViewSet,
IotPanelPushView,
KPIViewSet,
ManualInputViewSet,
)
@@ -25,5 +26,6 @@ router.register("ai-insights", AIInsightViewSet, basename="ai-insight")
urlpatterns = [
path("health/", HealthView.as_view(), name="health"),
path("panel-iot/push/<int:flock_id>/", IotPanelPushView.as_view(), name="iot-panel-push"),
*router.urls,
]
+23 -26
View File
@@ -33,7 +33,7 @@ from apps.operations.services.chicken_counting_edge import (
sync_chicken_counting_for_cycle,
sync_chicken_weight_for_cycle,
)
from apps.operations.services.iot_api import IotApiError, sync_iot_panels
from apps.operations.services.iot_api import IotPushError, handle_iot_push
from apps.operations.services.visibility import dashboard_publish_time, visible_through_date
READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates", "cached"})
@@ -251,38 +251,35 @@ class IotPanelViewSet(viewsets.ModelViewSet):
)
return Response(dates)
@action(detail=False, methods=["post"], url_path="sync")
def sync_from_api(self, request):
flock_id = request.data.get("flock_id")
if not flock_id:
class IotPanelPushView(APIView):
"""Webhook endpoint for IoT panels to push sensor data."""
permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, flock_id: int):
try:
flock = Flock.objects.get(pk=flock_id)
except Flock.DoesNotExist:
return Response(
{"detail": "flock_id is required."},
status=status.HTTP_400_BAD_REQUEST,
{"detail": "Flock not found."},
status=status.HTTP_404_NOT_FOUND,
)
try:
flock = Flock.objects.get(pk=int(flock_id))
except (Flock.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Flock not found."}, status=status.HTTP_404_NOT_FOUND)
if not (flock.iot_api_url or "").strip():
return Response(
{"detail": "Flock has no IoT API URL configured."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
summary = sync_iot_panels(flock_id=flock.pk)
except IotApiError as exc:
panel = handle_iot_push(flock, request.data)
except IotPushError as exc:
code = getattr(exc, "status_code", None)
return Response(
{"detail": str(exc)},
status=code if code and code >= 400 else status.HTTP_502_BAD_GATEWAY,
status=code if code and code >= 400 else status.HTTP_400_BAD_REQUEST,
)
return Response(
{
"upserted": summary.upserted,
"skipped": summary.skipped,
"pages": summary.pages,
}
)
except Exception as exc:
return Response(
{"detail": f"Push failed: {exc}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"ok": True, "panel_id": panel.pk})
class AIInsightViewSet(CycleScopedViewSet):
+1 -6
View File
@@ -194,12 +194,7 @@ SITE_API_BASE_URL = (env("SITE_API_BASE_URL", "") or "").rstrip("/")
DASHBOARD_PUBLISH_HOUR = int(env("DASHBOARD_PUBLISH_HOUR", "17") or "17")
DASHBOARD_PUBLISH_MINUTE = int(env("DASHBOARD_PUBLISH_MINUTE", "0") or "0")
# External IoT flocks API — panel sync every 10 minutes (real-time, not gated by publish cutoff).
# Per-flock source URLs live on Flock.iot_api_url (Settings); no global base URL / ID map.
IOT_API_SYNC_TIMEOUT_SECONDS = float(env("IOT_API_SYNC_TIMEOUT_SECONDS", "60") or "60")
IOT_SYNC_ENABLED = env_bool("IOT_SYNC_ENABLED", True)
IOT_SYNC_LOOKBACK_MINUTES = int(env("IOT_SYNC_LOOKBACK_MINUTES", "20") or "20")
IOT_SYNC_MAX_PAGES = int(env("IOT_SYNC_MAX_PAGES", "10") or "10")
# External IoT flocks API — panel data is pushed via webhook (no pull sync).
# Chicken counting edge vision API (headcount + mortality) — not gated by publish cutoff.
CHICKEN_COUNTING_EDGE_BASE_URL = (
+17 -72
View File
@@ -65,16 +65,11 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const [selectedDate, setSelectedDate] = useState('');
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState(0);
const [selectedChartKey, setSelectedChartKey] = useState<'humidity' | 'wind' | 'water' | null>(
null
);
const selectedFlock = flocks.find((flock) => flock.id === selectedFlockId) ?? null;
const hasIotUrl = Boolean((selectedFlock?.iot_api_url || '').trim());
useEffect(() => {
if (!selectedChartKey) return;
const onKey = (event: KeyboardEvent) => {
@@ -140,7 +135,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
? previous
: (sortedDates[sortedDates.length - 1] ?? fallbackDate);
}
// No IoT dates yet — keep a navigable date inside the cycle window.
if (previous && previous >= cycleMin && previous <= cycleMax) return previous;
return fallbackDate;
});
@@ -153,7 +147,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return () => {
cancelled = true;
};
}, [selectedCycle, selectedFlockId, refreshToken]);
}, [selectedCycle, selectedFlockId]);
useEffect(() => {
if (!selectedCycle || selectedFlockId == null || !selectedDate) {
@@ -185,7 +179,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return () => {
cancelled = true;
};
}, [selectedCycle, selectedFlockId, selectedDate, refreshToken]);
}, [selectedCycle, selectedFlockId, selectedDate]);
const summary = useMemo<DaySummary | null>(() => {
if (dayRows.length === 0) return null;
@@ -258,40 +252,9 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
});
}, [selectedKandang, selectedCycle, summary, dayAge, selectedDate]);
// Always allow navigating the cycle window — never hide empty days behind missing IoT dates.
const datePickerMin = selectedCycle?.start_date ?? undefined;
const datePickerMax = selectedCycle ? resolveCycleBoundDate(selectedCycle) : undefined;
const pullFromApi = async () => {
if (!selectedFlockId || !hasIotUrl) return;
setRefreshing(true);
setError(null);
try {
await api.panelIot.sync({ flock_id: selectedFlockId });
setRefreshToken((token) => token + 1);
} catch (err) {
setError(errorMessage(err));
} finally {
setRefreshing(false);
}
};
useEffect(() => {
if (!selectedFlockId || !hasIotUrl) return;
const id = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
void (async () => {
try {
await api.panelIot.sync({ flock_id: selectedFlockId });
setRefreshToken((token) => token + 1);
} catch {
// Live follow stays quiet; manual pull surfaces errors.
}
})();
}, 60_000);
return () => window.clearInterval(id);
}, [selectedFlockId, hasIotUrl]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
@@ -301,44 +264,26 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
icon={<i className="fa-solid fa-table-columns" />}
actions={
<div className="flex items-center gap-2">
<select
value={selectedFlockId ?? ''}
onChange={(event) =>
setSelectedFlockId(event.target.value ? Number(event.target.value) : null)
}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
aria-label="Pilih flock"
>
{flocks.length === 0 && <option value="">Tidak ada flock</option>}
{flocks.map((flock) => (
<option key={flock.id} value={flock.id}>
{flock.flock_name}
</option>
))}
</select>
<button
type="button"
onClick={() => void pullFromApi()}
disabled={refreshing || !hasIotUrl}
className="rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 disabled:opacity-60"
title={hasIotUrl ? 'Tarik data dari API IoT' : 'Setel URL IoT di Pengaturan'}
>
<i className={`fa-solid fa-rotate-right mr-1 ${refreshing ? 'fa-spin' : ''}`} />
{refreshing ? 'Sinkron…' : 'Tarik API'}
</button>
</div>
<select
value={selectedFlockId ?? ''}
onChange={(event) =>
setSelectedFlockId(event.target.value ? Number(event.target.value) : null)
}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
aria-label="Pilih flock"
>
{flocks.length === 0 && <option value="">Tidak ada flock</option>}
{flocks.map((flock) => (
<option key={flock.id} value={flock.id}>
{flock.flock_name}
</option>
))}
</select>
}
/>
{error && <Banner kind="error">{error}</Banner>}
{selectedFlockId != null && !hasIotUrl && (
<Banner kind="error">
Flock ini belum punya URL API Panel IoT. Setel di Pengaturan → Flock.
</Banner>
)}
{selectedFlockId == null ? (
<div className={cardClass}>
<p className="py-8 text-center text-slate-400">Pilih flock untuk melihat data panel IoT.</p>
+22 -44
View File
@@ -57,7 +57,6 @@ const SettingsPage: React.FC = () => {
const [kandangName, setKandangName] = useState('');
const [feedInButtonUrls, setFeedInButtonUrls] = useState<FeedInButtonUrl[]>([emptyFeedInButtonUrl()]);
const [flockName, setFlockName] = useState('');
const [flockIotUrls, setFlockIotUrls] = useState<Record<number, string>>({});
const [editingCycleId, setEditingCycleId] = useState<number | null>(null);
const [startDate, setStartDate] = useState(defaultCycleForm().startDate);
const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount);
@@ -139,14 +138,6 @@ const SettingsPage: React.FC = () => {
setFeedInButtonUrls(urls.length > 0 ? urls.map((entry) => ({ ...entry })) : [emptyFeedInButtonUrl()]);
}, [selectedKandang?.id, selectedKandang?.feed_in_button_urls]);
React.useEffect(() => {
const next: Record<number, string> = {};
for (const flock of flocks) {
next[flock.id] = flock.iot_api_url ?? '';
}
setFlockIotUrls(next);
}, [flocks]);
const wrap = async (fn: () => Promise<void>) => {
setSaving(true);
setError(null);
@@ -516,7 +507,9 @@ const SettingsPage: React.FC = () => {
<p className="text-xs text-gray-500">Pilih kandang terlebih dahulu.</p>
) : (
<ul className="divide-y">
{flocks.map((flock) => (
{flocks.map((flock) => {
const webhookUrl = `${window.location.origin}/api/v1/panel-iot/push/${flock.id}/`;
return (
<li key={flock.id} className="py-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-700">{flock.flock_name}</span>
@@ -536,42 +529,27 @@ const SettingsPage: React.FC = () => {
)}
</div>
<div className="space-y-1">
<label className="block text-xs text-gray-500">API Panel IoT</label>
{isSuperAdmin ? (
<div className="flex flex-wrap gap-2">
<input
className={inputClass}
type="text"
placeholder="https://…/api/iot/…"
value={flockIotUrls[flock.id] ?? ''}
onChange={(e) =>
setFlockIotUrls((prev) => ({ ...prev, [flock.id]: e.target.value }))
}
/>
<button
type="button"
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving}
onClick={() =>
void wrap(async () => {
await api.flocks.update(flock.id, {
iot_api_url: (flockIotUrls[flock.id] ?? '').trim(),
});
flash(`URL IoT ${flock.flock_name} disimpan.`);
})
}
>
Simpan URL IoT
</button>
</div>
) : (
<p className="break-all text-xs text-gray-600">
{(flock.iot_api_url || '').trim() || 'Belum diset (admin).'}
</p>
)}
<label className="block text-xs text-gray-500">Webhook URL (IoT Panel → Dashboard)</label>
<div className="flex items-center gap-2 rounded-lg bg-gray-50 border border-gray-200 px-3 py-2">
<code className="flex-1 text-xs text-gray-700 break-all font-mono">{webhookUrl}</code>
<button
type="button"
className="shrink-0 text-xs text-red-600 hover:underline font-semibold"
onClick={() => {
void navigator.clipboard.writeText(webhookUrl);
flash('Webhook URL disalin.');
}}
>
Salin
</button>
</div>
<p className="text-[11px] text-gray-400">
Konfigurasikan URL ini sebagai tujuan push di Panel IoT.
</p>
</div>
</li>
))}
);
})}
</ul>
)}
</section>
-2
View File
@@ -252,8 +252,6 @@ export const api = {
list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<PanelIoT> | PanelIoT[]>('/panel-iot/', query)),
dates: async (query?: CycleFilter) => get<string[]>('/panel-iot/dates/', query),
sync: (body: { flock_id: number }) =>
post<{ upserted: number; skipped: number; pages: number }>('/panel-iot/sync/', body),
},
insights: {
list: async (query?: CycleFilter) =>