update iot panel will now listen to push from panel
This commit is contained in:
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}"
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user