From 520c4bcaad5f701f1a80869ce78e945aa1ef0a7a Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Mon, 21 Sep 2026 14:38:17 +0700 Subject: [PATCH] update iot panel will now listen to push from panel --- .../management/commands/seed_iot_10min.py | 239 ---------------- .../management/commands/sync_iot_from_api.py | 23 -- backend/apps/operations/services/iot_api.py | 264 +++--------------- backend/apps/operations/tests_iot_sync.py | 171 ++++-------- backend/apps/operations/urls.py | 2 + backend/apps/operations/views.py | 49 ++-- backend/config/settings.py | 7 +- components/IotPanelPage.tsx | 89 ++---- components/SettingsPage.tsx | 66 ++--- services/apiClient.ts | 2 - 10 files changed, 158 insertions(+), 754 deletions(-) delete mode 100644 backend/apps/jobs/management/commands/seed_iot_10min.py delete mode 100644 backend/apps/jobs/management/commands/sync_iot_from_api.py diff --git a/backend/apps/jobs/management/commands/seed_iot_10min.py b/backend/apps/jobs/management/commands/seed_iot_10min.py deleted file mode 100644 index a0cdf6c..0000000 --- a/backend/apps/jobs/management/commands/seed_iot_10min.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/backend/apps/jobs/management/commands/sync_iot_from_api.py b/backend/apps/jobs/management/commands/sync_iot_from_api.py deleted file mode 100644 index 1f2ff46..0000000 --- a/backend/apps/jobs/management/commands/sync_iot_from_api.py +++ /dev/null @@ -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}" - ) - ) diff --git a/backend/apps/operations/services/iot_api.py b/backend/apps/operations/services/iot_api.py index 714cff1..9eefd23 100644 --- a/backend/apps/operations/services/iot_api.py +++ b/backend/apps/operations/services/iot_api.py @@ -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 diff --git a/backend/apps/operations/tests_iot_sync.py b/backend/apps/operations/tests_iot_sync.py index 112851c..fab1de6 100644 --- a/backend/apps/operations/tests_iot_sync.py +++ b/backend/apps/operations/tests_iot_sync.py @@ -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) diff --git a/backend/apps/operations/urls.py b/backend/apps/operations/urls.py index 56a65cb..177be11 100644 --- a/backend/apps/operations/urls.py +++ b/backend/apps/operations/urls.py @@ -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//", IotPanelPushView.as_view(), name="iot-panel-push"), *router.urls, ] diff --git a/backend/apps/operations/views.py b/backend/apps/operations/views.py index 8151f28..737c164 100644 --- a/backend/apps/operations/views.py +++ b/backend/apps/operations/views.py @@ -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): diff --git a/backend/config/settings.py b/backend/config/settings.py index 1e41d84..1148a7a 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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 = ( diff --git a/components/IotPanelPage.tsx b/components/IotPanelPage.tsx index 9ee9ce0..f2722bd 100644 --- a/components/IotPanelPage.tsx +++ b/components/IotPanelPage.tsx @@ -65,16 +65,11 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const [selectedDate, setSelectedDate] = useState(''); const [dayRows, setDayRows] = useState([]); const [loading, setLoading] = useState(false); - const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(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(() => { 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 ; 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={} actions={ -
- - -
+ } /> {error && {error}} - {selectedFlockId != null && !hasIotUrl && ( - - Flock ini belum punya URL API Panel IoT. Setel di Pengaturan → Flock. - - )} - {selectedFlockId == null ? (

Pilih flock untuk melihat data panel IoT.

diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 6490bc1..48982d5 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -57,7 +57,6 @@ const SettingsPage: React.FC = () => { const [kandangName, setKandangName] = useState(''); const [feedInButtonUrls, setFeedInButtonUrls] = useState([emptyFeedInButtonUrl()]); const [flockName, setFlockName] = useState(''); - const [flockIotUrls, setFlockIotUrls] = useState>({}); const [editingCycleId, setEditingCycleId] = useState(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 = {}; - for (const flock of flocks) { - next[flock.id] = flock.iot_api_url ?? ''; - } - setFlockIotUrls(next); - }, [flocks]); - const wrap = async (fn: () => Promise) => { setSaving(true); setError(null); @@ -516,7 +507,9 @@ const SettingsPage: React.FC = () => {

Pilih kandang terlebih dahulu.

) : (
    - {flocks.map((flock) => ( + {flocks.map((flock) => { + const webhookUrl = `${window.location.origin}/api/v1/panel-iot/push/${flock.id}/`; + return (
  • {flock.flock_name} @@ -536,42 +529,27 @@ const SettingsPage: React.FC = () => { )}
    - - {isSuperAdmin ? ( -
    - - setFlockIotUrls((prev) => ({ ...prev, [flock.id]: e.target.value })) - } - /> - -
    - ) : ( -

    - {(flock.iot_api_url || '').trim() || 'Belum diset (admin).'} -

    - )} + +
    + {webhookUrl} + +
    +

    + Konfigurasikan URL ini sebagai tujuan push di Panel IoT. +

  • - ))} + ); + })}
)} diff --git a/services/apiClient.ts b/services/apiClient.ts index b87002a..9d1c92d 100644 --- a/services/apiClient.ts +++ b/services/apiClient.ts @@ -252,8 +252,6 @@ export const api = { list: async (query?: CycleFilter) => unwrapList(await get | PanelIoT[]>('/panel-iot/', query)), dates: async (query?: CycleFilter) => get('/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) =>