Merge PR #15: FIX-M20 alarm cross-feed + stale-IMEI recovery
Cross-feed alarm lat/lng into live_positions; schedule stale-IMEI rescue every 10 min. See 06_live_location/260521_timescale_location_upgrade_major.md for the plan and 260521_timescale_location_upgrade_verification.md for post-deploy checks.
This commit is contained in:
commit
cbae345d43
6 changed files with 391 additions and 66 deletions
|
|
@ -55,6 +55,7 @@ from ts_shared_rev import (
|
||||||
get_active_imeis,
|
get_active_imeis,
|
||||||
get_active_imeis_by_target,
|
get_active_imeis_by_target,
|
||||||
get_conn,
|
get_conn,
|
||||||
|
get_stale_imeis,
|
||||||
get_token,
|
get_token,
|
||||||
is_valid_fix,
|
is_valid_fix,
|
||||||
log_ingestion,
|
log_ingestion,
|
||||||
|
|
@ -63,8 +64,10 @@ from ts_shared_rev import (
|
||||||
clean_int,
|
clean_int,
|
||||||
clean_ts,
|
clean_ts,
|
||||||
get_logger,
|
get_logger,
|
||||||
|
ensure_device,
|
||||||
safe_task,
|
safe_task,
|
||||||
setup_shutdown,
|
setup_shutdown,
|
||||||
|
upsert_live_position,
|
||||||
)
|
)
|
||||||
|
|
||||||
log = get_logger("movement")
|
log = get_logger("movement")
|
||||||
|
|
@ -217,30 +220,30 @@ def poll_live_positions():
|
||||||
gps_num = clean_int(p.get("gpsNum"))
|
gps_num = clean_int(p.get("gpsNum"))
|
||||||
current_mileage = clean_num(p.get("currentMileage"))
|
current_mileage = clean_num(p.get("currentMileage"))
|
||||||
|
|
||||||
cur.execute("""
|
# [FIX-M20] Time-guarded upsert via shared helper so the
|
||||||
INSERT INTO tracksolid.live_positions (
|
# 60s sweep, the alarm cross-feed, and get_device_locations
|
||||||
imei, geom, lat, lng, pos_type, confidence, gps_time, hb_time,
|
# all agree about freshness ordering. The sweep is normally
|
||||||
speed, direction, acc_status, gps_signal, gps_num,
|
# the freshest source so the guard rarely rejects its writes.
|
||||||
elec_quantity, power_value, battery_power_val, tracker_oil,
|
upserted += upsert_live_position(
|
||||||
temperature, current_mileage, device_status, loc_desc, recorded_at
|
cur, imei, lat, lng, gps_time,
|
||||||
) VALUES (
|
speed=speed, direction=direction,
|
||||||
%s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s, %s, %s, %s, %s, %s,
|
acc_status=acc_status,
|
||||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
|
current_mileage=current_mileage,
|
||||||
)
|
extras={
|
||||||
ON CONFLICT (imei) DO UPDATE SET
|
"pos_type": clean(p.get("posType")),
|
||||||
geom=EXCLUDED.geom, lat=EXCLUDED.lat, lng=EXCLUDED.lng,
|
"confidence": clean_int(p.get("confidence")),
|
||||||
gps_time=EXCLUDED.gps_time, speed=EXCLUDED.speed, direction=EXCLUDED.direction,
|
"hb_time": clean_ts(p.get("hbTime")),
|
||||||
acc_status=EXCLUDED.acc_status, current_mileage=EXCLUDED.current_mileage,
|
"gps_signal": clean_int(p.get("gpsSignal")),
|
||||||
updated_at=NOW()
|
"gps_num": gps_num,
|
||||||
""", (
|
"elec_quantity": clean_num(p.get("electQuantity")),
|
||||||
imei, lng, lat, lat, lng, clean(p.get("posType")), clean_int(p.get("confidence")),
|
"power_value": clean_num(p.get("powerValue")),
|
||||||
gps_time, clean_ts(p.get("hbTime")), speed,
|
"battery_power_val": clean_num(p.get("batteryPowerVal")),
|
||||||
direction, acc_status, clean_int(p.get("gpsSignal")),
|
"tracker_oil": clean(p.get("trackerOil")),
|
||||||
gps_num, clean_num(p.get("electQuantity")), clean_num(p.get("powerValue")),
|
"temperature": clean_num(p.get("temperature")),
|
||||||
clean_num(p.get("batteryPowerVal")), clean(p.get("trackerOil")), clean_num(p.get("temperature")),
|
"device_status": clean(p.get("status")),
|
||||||
current_mileage, clean(p.get("status")), clean(p.get("locDesc"))
|
"loc_desc": clean(p.get("locDesc")),
|
||||||
))
|
},
|
||||||
upserted += cur.rowcount
|
)
|
||||||
|
|
||||||
# History (Hypertable Source)
|
# History (Hypertable Source)
|
||||||
if gps_time:
|
if gps_time:
|
||||||
|
|
@ -510,39 +513,44 @@ def get_device_locations(imeis: list) -> int:
|
||||||
if not imei or not is_valid_fix(lat, lng):
|
if not imei or not is_valid_fix(lat, lng):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cur.execute("""
|
# [FIX-M20] FK guard — this path can see IMEIs the daily
|
||||||
INSERT INTO tracksolid.live_positions (
|
# sync_devices hasn't picked up yet (especially when used
|
||||||
imei, geom, lat, lng, speed, direction,
|
# as the stale-IMEI rescue path).
|
||||||
gps_time, acc_status, current_mileage, recorded_at
|
ensure_device(cur, imei, clean(p.get("deviceName")))
|
||||||
) VALUES (
|
|
||||||
%s, ST_SetSRID(ST_MakePoint(%s, %s), 4326),
|
upserted += upsert_live_position(
|
||||||
%s, %s, %s, %s, %s, %s, %s, NOW()
|
cur, imei, lat, lng,
|
||||||
)
|
|
||||||
ON CONFLICT (imei) DO UPDATE SET
|
|
||||||
geom = EXCLUDED.geom,
|
|
||||||
lat = EXCLUDED.lat,
|
|
||||||
lng = EXCLUDED.lng,
|
|
||||||
speed = EXCLUDED.speed,
|
|
||||||
direction = EXCLUDED.direction,
|
|
||||||
gps_time = EXCLUDED.gps_time,
|
|
||||||
acc_status = EXCLUDED.acc_status,
|
|
||||||
current_mileage = EXCLUDED.current_mileage,
|
|
||||||
updated_at = NOW()
|
|
||||||
""", (
|
|
||||||
imei, lng, lat, lat, lng,
|
|
||||||
clean_num(p.get("speed")),
|
|
||||||
clean_num(p.get("direction")),
|
|
||||||
clean_ts(p.get("gpsTime")),
|
clean_ts(p.get("gpsTime")),
|
||||||
clean(p.get("accStatus")),
|
speed=clean_num(p.get("speed")),
|
||||||
clean_num(p.get("currentMileage")),
|
direction=clean_num(p.get("direction")),
|
||||||
))
|
acc_status=clean(p.get("accStatus")),
|
||||||
upserted += 1
|
current_mileage=clean_num(p.get("currentMileage")),
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
log.info("get_device_locations: %d positions refreshed.", upserted)
|
log.info("get_device_locations: %d positions refreshed.", upserted)
|
||||||
return upserted
|
return upserted
|
||||||
|
|
||||||
|
|
||||||
|
# ── 7. Stale-IMEI Recovery — POLL-04 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def poll_stale_locations():
|
||||||
|
"""[FIX-M20] Refresh live_positions for IMEIs whose stored gps_time is
|
||||||
|
missing or older than 30 minutes.
|
||||||
|
|
||||||
|
Complements poll_live_positions (the 60s sweep), which silently omits
|
||||||
|
devices Jimi's location.list endpoint doesn't return. jimi.device.location.get
|
||||||
|
returns *last-known* fix per IMEI, so this path can re-warm devices
|
||||||
|
the sweep has dropped.
|
||||||
|
"""
|
||||||
|
stale = get_stale_imeis(stale_minutes=30)
|
||||||
|
if not stale:
|
||||||
|
log.info("poll_stale_locations: no stale IMEIs.")
|
||||||
|
return
|
||||||
|
log.info("poll_stale_locations: refreshing %d stale IMEI(s).", len(stale))
|
||||||
|
get_device_locations(stale)
|
||||||
|
|
||||||
|
|
||||||
# ── Main Loop ─────────────────────────────────────────────────────────────────
|
# ── Main Loop ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -554,12 +562,14 @@ def main():
|
||||||
safe_task(poll_trips, log)()
|
safe_task(poll_trips, log)()
|
||||||
safe_task(poll_parking, log)()
|
safe_task(poll_parking, log)()
|
||||||
safe_task(poll_track_list, log)()
|
safe_task(poll_track_list, log)()
|
||||||
|
safe_task(poll_stale_locations, log)()
|
||||||
|
|
||||||
# Schedule
|
# Schedule
|
||||||
schedule.every(60).seconds.do(safe_task(poll_live_positions, log))
|
schedule.every(60).seconds.do(safe_task(poll_live_positions, log))
|
||||||
schedule.every(15).minutes.do(safe_task(poll_trips, log))
|
schedule.every(15).minutes.do(safe_task(poll_trips, log))
|
||||||
schedule.every(15).minutes.do(safe_task(poll_parking, log))
|
schedule.every(15).minutes.do(safe_task(poll_parking, log))
|
||||||
schedule.every(30).minutes.do(safe_task(poll_track_list, log)) # [FIX-M14]
|
schedule.every(30).minutes.do(safe_task(poll_track_list, log)) # [FIX-M14]
|
||||||
|
schedule.every(10).minutes.do(safe_task(poll_stale_locations, log)) # [FIX-M20]
|
||||||
schedule.every().day.at("02:00").do(safe_task(sync_devices, log))
|
schedule.every().day.at("02:00").do(safe_task(sync_devices, log))
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
|
|
||||||
24
tests/fixtures/api_responses.py
vendored
24
tests/fixtures/api_responses.py
vendored
|
|
@ -107,3 +107,27 @@ WEBHOOK_ALARM_NULL_TYPE = {
|
||||||
"alarmType": None,
|
"alarmType": None,
|
||||||
"gateTime": "2024-04-12 07:30:00",
|
"gateTime": "2024-04-12 07:30:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Alarm with no lat/lng — cross-feed (FIX-M20) must skip live_positions
|
||||||
|
# but still write the alarm row.
|
||||||
|
WEBHOOK_ALARM_NO_POSITION = {
|
||||||
|
"deviceImei": "123456789012345",
|
||||||
|
"alarmType": "4",
|
||||||
|
"alarmName": "Speeding",
|
||||||
|
"gateTime": "2024-04-12 07:30:00",
|
||||||
|
"lat": None,
|
||||||
|
"lng": None,
|
||||||
|
"speed": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alarm with Zero-Island (0, 0) coordinates — is_valid_fix must reject;
|
||||||
|
# alarm row still writes, live_positions cross-feed must NOT fire.
|
||||||
|
WEBHOOK_ALARM_ZERO_ISLAND = {
|
||||||
|
"deviceImei": "123456789012345",
|
||||||
|
"alarmType": "4",
|
||||||
|
"alarmName": "Speeding",
|
||||||
|
"gateTime": "2024-04-12 07:30:00",
|
||||||
|
"lat": 0,
|
||||||
|
"lng": 0,
|
||||||
|
"speed": 0.0,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import webhook_receiver_rev
|
||||||
from tests.fixtures.api_responses import (
|
from tests.fixtures.api_responses import (
|
||||||
WEBHOOK_ALARM_PAYLOAD,
|
WEBHOOK_ALARM_PAYLOAD,
|
||||||
WEBHOOK_ALARM_NULL_TYPE,
|
WEBHOOK_ALARM_NULL_TYPE,
|
||||||
|
WEBHOOK_ALARM_NO_POSITION,
|
||||||
|
WEBHOOK_ALARM_ZERO_ISLAND,
|
||||||
WEBHOOK_TRIP_BCD_PAYLOAD,
|
WEBHOOK_TRIP_BCD_PAYLOAD,
|
||||||
WEBHOOK_TRIP_ISO_PAYLOAD,
|
WEBHOOK_TRIP_ISO_PAYLOAD,
|
||||||
WEBHOOK_OBD_PAYLOAD,
|
WEBHOOK_OBD_PAYLOAD,
|
||||||
|
|
@ -96,6 +98,50 @@ class TestPushAlarm:
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["code"] == 0
|
assert response.json()["code"] == 0
|
||||||
|
|
||||||
|
def test_alarm_cross_feeds_live_position(self, client, mock_db):
|
||||||
|
"""FIX-M20: a valid alarm must additionally upsert live_positions."""
|
||||||
|
mock_conn, mock_cur = mock_db
|
||||||
|
data_list = json.dumps([WEBHOOK_ALARM_PAYLOAD])
|
||||||
|
response = client.post("/pushalarm", data={"token": "", "data_list": data_list})
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Exactly one INSERT INTO live_positions should have fired.
|
||||||
|
lp_inserts = [
|
||||||
|
c for c in mock_cur.execute.call_args_list
|
||||||
|
if "tracksolid.live_positions" in str(c) and "INSERT" in str(c)
|
||||||
|
]
|
||||||
|
assert len(lp_inserts) == 1, "Cross-feed must upsert live_positions exactly once"
|
||||||
|
|
||||||
|
def test_alarm_without_lat_lng_skips_cross_feed(self, client, mock_db):
|
||||||
|
"""FIX-M20: an alarm without lat/lng must NOT touch live_positions
|
||||||
|
(but must still insert the alarm row)."""
|
||||||
|
mock_conn, mock_cur = mock_db
|
||||||
|
data_list = json.dumps([WEBHOOK_ALARM_NO_POSITION])
|
||||||
|
response = client.post("/pushalarm", data={"token": "", "data_list": data_list})
|
||||||
|
assert response.status_code == 200
|
||||||
|
lp_inserts = [
|
||||||
|
c for c in mock_cur.execute.call_args_list
|
||||||
|
if "tracksolid.live_positions" in str(c) and "INSERT" in str(c)
|
||||||
|
]
|
||||||
|
assert len(lp_inserts) == 0, "No live_positions write without a valid fix"
|
||||||
|
alarm_inserts = [
|
||||||
|
c for c in mock_cur.execute.call_args_list
|
||||||
|
if "tracksolid.alarms" in str(c) and "INSERT" in str(c)
|
||||||
|
]
|
||||||
|
assert len(alarm_inserts) == 1, "Alarm row must still write"
|
||||||
|
|
||||||
|
def test_alarm_with_zero_island_skips_cross_feed(self, client, mock_db):
|
||||||
|
"""FIX-M20: a (0, 0) fix must NOT propagate to live_positions —
|
||||||
|
is_valid_fix in the shared helper guards Zero Island."""
|
||||||
|
mock_conn, mock_cur = mock_db
|
||||||
|
data_list = json.dumps([WEBHOOK_ALARM_ZERO_ISLAND])
|
||||||
|
response = client.post("/pushalarm", data={"token": "", "data_list": data_list})
|
||||||
|
assert response.status_code == 200
|
||||||
|
lp_inserts = [
|
||||||
|
c for c in mock_cur.execute.call_args_list
|
||||||
|
if "tracksolid.live_positions" in str(c) and "INSERT" in str(c)
|
||||||
|
]
|
||||||
|
assert len(lp_inserts) == 0, "Zero-Island fix must not reach live_positions"
|
||||||
|
|
||||||
|
|
||||||
class TestPushTripReport:
|
class TestPushTripReport:
|
||||||
def test_bcd_timestamp_parsed(self, client, mock_db):
|
def test_bcd_timestamp_parsed(self, client, mock_db):
|
||||||
|
|
|
||||||
112
tests/unit/test_stale_imeis.py
Normal file
112
tests/unit/test_stale_imeis.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Unit tests for the FIX-M20 stale-IMEI recovery helpers.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- ts_shared_rev.get_stale_imeis — the SQL selector
|
||||||
|
- ingest_movement_rev.poll_stale_locations — the scheduler wrapper
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||||
|
|
||||||
|
os.environ.setdefault("TRACKSOLID_APP_KEY", "test_key")
|
||||||
|
os.environ.setdefault("TRACKSOLID_APP_SECRET", "test_secret")
|
||||||
|
os.environ.setdefault("TRACKSOLID_USER_ID", "test_user")
|
||||||
|
os.environ.setdefault("TRACKSOLID_PWD_MD5", "test_md5")
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_conn(rows=None):
|
||||||
|
"""Cursor/connection double that returns `rows` from fetchall()."""
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
mock_cur.fetchall.return_value = rows or []
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.cursor.return_value.__enter__ = lambda s: mock_cur
|
||||||
|
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
return mock_conn, mock_cur
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _conn_ctx(mock_conn):
|
||||||
|
yield mock_conn
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetStaleImeis:
|
||||||
|
def test_returns_devices_with_old_gps_time(self):
|
||||||
|
from ts_shared_rev import get_stale_imeis
|
||||||
|
mock_conn, mock_cur = _make_mock_conn(rows=[("imei_a",), ("imei_b",)])
|
||||||
|
with patch("ts_shared_rev.get_conn") as mock_get_conn:
|
||||||
|
mock_get_conn.return_value = _conn_ctx(mock_conn)
|
||||||
|
result = get_stale_imeis(stale_minutes=30)
|
||||||
|
|
||||||
|
assert result == ["imei_a", "imei_b"]
|
||||||
|
sql = str(mock_cur.execute.call_args)
|
||||||
|
assert "enabled_flag = 1" in sql
|
||||||
|
assert "INTERVAL" in sql.upper() or "interval" in sql
|
||||||
|
assert "NULLS FIRST" in sql
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_stale(self):
|
||||||
|
from ts_shared_rev import get_stale_imeis
|
||||||
|
mock_conn, _ = _make_mock_conn(rows=[])
|
||||||
|
with patch("ts_shared_rev.get_conn") as mock_get_conn:
|
||||||
|
mock_get_conn.return_value = _conn_ctx(mock_conn)
|
||||||
|
assert get_stale_imeis() == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollStaleLocations:
|
||||||
|
def test_noop_when_empty(self):
|
||||||
|
"""If no IMEIs are stale, get_device_locations must NOT be called."""
|
||||||
|
import ingest_movement_rev as mod
|
||||||
|
with patch.object(mod, "get_stale_imeis", return_value=[]), \
|
||||||
|
patch.object(mod, "get_device_locations") as mock_refresh:
|
||||||
|
mod.poll_stale_locations()
|
||||||
|
mock_refresh.assert_not_called()
|
||||||
|
|
||||||
|
def test_invokes_refresh_with_stale_list(self):
|
||||||
|
"""When stale IMEIs are present, get_device_locations is called once
|
||||||
|
with the exact list returned by get_stale_imeis."""
|
||||||
|
import ingest_movement_rev as mod
|
||||||
|
stale = ["imei_a", "imei_b", "imei_c"]
|
||||||
|
with patch.object(mod, "get_stale_imeis", return_value=stale), \
|
||||||
|
patch.object(mod, "get_device_locations") as mock_refresh:
|
||||||
|
mod.poll_stale_locations()
|
||||||
|
mock_refresh.assert_called_once_with(stale)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpsertLivePositionGuards:
|
||||||
|
"""Spot-checks on the time-guard contract — covers what the dashboard
|
||||||
|
relies on (no rewinding the marker on stale alarm arrivals)."""
|
||||||
|
|
||||||
|
def test_skips_invalid_fix(self):
|
||||||
|
from ts_shared_rev import upsert_live_position
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
# Zero-Island
|
||||||
|
assert upsert_live_position(mock_cur, "imei_x", 0, 0, "2026-05-21 10:00:00") == 0
|
||||||
|
mock_cur.execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_missing_gps_time(self):
|
||||||
|
from ts_shared_rev import upsert_live_position
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
assert upsert_live_position(mock_cur, "imei_x", -1.29, 36.82, None) == 0
|
||||||
|
mock_cur.execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_missing_imei(self):
|
||||||
|
from ts_shared_rev import upsert_live_position
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
assert upsert_live_position(mock_cur, None, -1.29, 36.82, "2026-05-21 10:00:00") == 0
|
||||||
|
mock_cur.execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_executes_upsert_for_valid_fix(self):
|
||||||
|
from ts_shared_rev import upsert_live_position
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
mock_cur.rowcount = 1
|
||||||
|
n = upsert_live_position(mock_cur, "imei_x", -1.29, 36.82, "2026-05-21 10:00:00",
|
||||||
|
speed=42.5)
|
||||||
|
assert n == 1
|
||||||
|
mock_cur.execute.assert_called_once()
|
||||||
|
sql = str(mock_cur.execute.call_args)
|
||||||
|
# The time-guard is the load-bearing detail — verify it's present.
|
||||||
|
assert "EXCLUDED.gps_time > tracksolid.live_positions.gps_time" in sql
|
||||||
132
ts_shared_rev.py
132
ts_shared_rev.py
|
|
@ -235,6 +235,138 @@ def get_active_imeis() -> list[str]:
|
||||||
cur.execute("SELECT imei FROM tracksolid.devices WHERE enabled_flag = 1")
|
cur.execute("SELECT imei FROM tracksolid.devices WHERE enabled_flag = 1")
|
||||||
return [r[0] for r in cur.fetchall()]
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_stale_imeis(stale_minutes: int = 30) -> list[str]:
|
||||||
|
"""[FIX-M20] IMEIs whose live_positions fix is missing or older than N minutes.
|
||||||
|
|
||||||
|
Used by poll_stale_locations() to feed get_device_locations() with the
|
||||||
|
set the 60s sweep silently dropped. Ordered oldest-first (NULLs first)
|
||||||
|
so worst-offenders get the first seats in each 50-IMEI batch.
|
||||||
|
"""
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT d.imei
|
||||||
|
FROM tracksolid.devices d
|
||||||
|
LEFT JOIN tracksolid.live_positions lp USING (imei)
|
||||||
|
WHERE d.enabled_flag = 1
|
||||||
|
AND (lp.gps_time IS NULL
|
||||||
|
OR lp.gps_time < NOW() - (%s || ' minutes')::interval)
|
||||||
|
ORDER BY lp.gps_time ASC NULLS FIRST
|
||||||
|
""", (str(stale_minutes),))
|
||||||
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def ensure_device(cur, imei: str, device_name: Optional[str] = None) -> None:
|
||||||
|
"""[FIX-M20] Upsert a stub row into tracksolid.devices so FK-constrained
|
||||||
|
inserts don't fail when ingest paths see an IMEI before sync_devices does.
|
||||||
|
|
||||||
|
Lifted out of webhook_receiver_rev.py to be shareable by every writer
|
||||||
|
of live_positions / alarms / position_history. Idempotent.
|
||||||
|
"""
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tracksolid.devices (imei, device_name, status, created_at, updated_at)
|
||||||
|
VALUES (%s, %s, 'unknown', NOW(), NOW())
|
||||||
|
ON CONFLICT (imei) DO NOTHING
|
||||||
|
""",
|
||||||
|
(imei, device_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert_live_position(
|
||||||
|
cur,
|
||||||
|
imei: str,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
gps_time,
|
||||||
|
speed=None,
|
||||||
|
direction=None,
|
||||||
|
acc_status=None,
|
||||||
|
current_mileage=None,
|
||||||
|
extras: Optional[dict] = None,
|
||||||
|
) -> int:
|
||||||
|
"""[FIX-M20] Time-guarded upsert into tracksolid.live_positions.
|
||||||
|
|
||||||
|
Only overwrites the stored row when the incoming gps_time is strictly
|
||||||
|
newer than what's already there. NULL stored gps_time always loses
|
||||||
|
(any fix beats no fix). Returns 1 if a row was written/updated, else 0.
|
||||||
|
|
||||||
|
`extras` carries the columns only the 60s sweep emits
|
||||||
|
(pos_type, confidence, hb_time, gps_signal, gps_num, elec_quantity,
|
||||||
|
power_value, battery_power_val, tracker_oil, temperature,
|
||||||
|
device_status, loc_desc). When omitted, those columns are left alone
|
||||||
|
on update via COALESCE so a sparse caller (e.g. alarm cross-feed)
|
||||||
|
doesn't blank them out.
|
||||||
|
"""
|
||||||
|
if not imei or not gps_time or not is_valid_fix(lat, lng):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
extras = extras or {}
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tracksolid.live_positions (
|
||||||
|
imei, geom, lat, lng, gps_time, speed, direction,
|
||||||
|
acc_status, current_mileage,
|
||||||
|
pos_type, confidence, hb_time, gps_signal, gps_num,
|
||||||
|
elec_quantity, power_value, battery_power_val,
|
||||||
|
tracker_oil, temperature, device_status, loc_desc,
|
||||||
|
recorded_at
|
||||||
|
) VALUES (
|
||||||
|
%(imei)s,
|
||||||
|
ST_SetSRID(ST_MakePoint(%(lng)s, %(lat)s), 4326),
|
||||||
|
%(lat)s, %(lng)s, %(gps_time)s, %(speed)s, %(direction)s,
|
||||||
|
%(acc_status)s, %(current_mileage)s,
|
||||||
|
%(pos_type)s, %(confidence)s, %(hb_time)s, %(gps_signal)s, %(gps_num)s,
|
||||||
|
%(elec_quantity)s, %(power_value)s, %(battery_power_val)s,
|
||||||
|
%(tracker_oil)s, %(temperature)s, %(device_status)s, %(loc_desc)s,
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (imei) DO UPDATE SET
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
lat = EXCLUDED.lat,
|
||||||
|
lng = EXCLUDED.lng,
|
||||||
|
gps_time = EXCLUDED.gps_time,
|
||||||
|
speed = COALESCE(EXCLUDED.speed, tracksolid.live_positions.speed),
|
||||||
|
direction = COALESCE(EXCLUDED.direction, tracksolid.live_positions.direction),
|
||||||
|
acc_status = COALESCE(EXCLUDED.acc_status, tracksolid.live_positions.acc_status),
|
||||||
|
current_mileage = COALESCE(EXCLUDED.current_mileage, tracksolid.live_positions.current_mileage),
|
||||||
|
pos_type = COALESCE(EXCLUDED.pos_type, tracksolid.live_positions.pos_type),
|
||||||
|
confidence = COALESCE(EXCLUDED.confidence, tracksolid.live_positions.confidence),
|
||||||
|
hb_time = COALESCE(EXCLUDED.hb_time, tracksolid.live_positions.hb_time),
|
||||||
|
gps_signal = COALESCE(EXCLUDED.gps_signal, tracksolid.live_positions.gps_signal),
|
||||||
|
gps_num = COALESCE(EXCLUDED.gps_num, tracksolid.live_positions.gps_num),
|
||||||
|
elec_quantity = COALESCE(EXCLUDED.elec_quantity, tracksolid.live_positions.elec_quantity),
|
||||||
|
power_value = COALESCE(EXCLUDED.power_value, tracksolid.live_positions.power_value),
|
||||||
|
battery_power_val = COALESCE(EXCLUDED.battery_power_val, tracksolid.live_positions.battery_power_val),
|
||||||
|
tracker_oil = COALESCE(EXCLUDED.tracker_oil, tracksolid.live_positions.tracker_oil),
|
||||||
|
temperature = COALESCE(EXCLUDED.temperature, tracksolid.live_positions.temperature),
|
||||||
|
device_status = COALESCE(EXCLUDED.device_status, tracksolid.live_positions.device_status),
|
||||||
|
loc_desc = COALESCE(EXCLUDED.loc_desc, tracksolid.live_positions.loc_desc),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE EXCLUDED.gps_time IS NOT NULL
|
||||||
|
AND (tracksolid.live_positions.gps_time IS NULL
|
||||||
|
OR EXCLUDED.gps_time > tracksolid.live_positions.gps_time)
|
||||||
|
""", {
|
||||||
|
"imei": imei,
|
||||||
|
"lat": lat,
|
||||||
|
"lng": lng,
|
||||||
|
"gps_time": gps_time,
|
||||||
|
"speed": speed,
|
||||||
|
"direction": direction,
|
||||||
|
"acc_status": acc_status,
|
||||||
|
"current_mileage": current_mileage,
|
||||||
|
"pos_type": extras.get("pos_type"),
|
||||||
|
"confidence": extras.get("confidence"),
|
||||||
|
"hb_time": extras.get("hb_time"),
|
||||||
|
"gps_signal": extras.get("gps_signal"),
|
||||||
|
"gps_num": extras.get("gps_num"),
|
||||||
|
"elec_quantity": extras.get("elec_quantity"),
|
||||||
|
"power_value": extras.get("power_value"),
|
||||||
|
"battery_power_val": extras.get("battery_power_val"),
|
||||||
|
"tracker_oil": extras.get("tracker_oil"),
|
||||||
|
"temperature": extras.get("temperature"),
|
||||||
|
"device_status": extras.get("device_status"),
|
||||||
|
"loc_desc": extras.get("loc_desc"),
|
||||||
|
})
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
def get_active_imeis_by_target() -> dict[str, list[str]]:
|
def get_active_imeis_by_target() -> dict[str, list[str]]:
|
||||||
"""[FIX-M19] Group active IMEIs by their Tracksolid sub-account so
|
"""[FIX-M19] Group active IMEIs by their Tracksolid sub-account so
|
||||||
endpoints that require an `account`/`target` param (e.g. parking) can
|
endpoints that require an `account`/`target` param (e.g. parking) can
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ from ts_shared_rev import (
|
||||||
clean_ts,
|
clean_ts,
|
||||||
is_valid_fix,
|
is_valid_fix,
|
||||||
get_logger,
|
get_logger,
|
||||||
|
ensure_device,
|
||||||
|
upsert_live_position,
|
||||||
)
|
)
|
||||||
|
|
||||||
log = get_logger("webhook")
|
log = get_logger("webhook")
|
||||||
|
|
@ -178,22 +180,11 @@ def _make_geom_params(lat, lng):
|
||||||
return (lng, lat, lng, lat)
|
return (lng, lat, lng, lat)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_device(cur, imei: str, device_name: Optional[str] = None) -> None:
|
# Backwards-compat shim. The implementation was relocated to ts_shared_rev
|
||||||
"""Upsert a stub row into tracksolid.devices so FK-constrained inserts don't fail.
|
# (as `ensure_device`) so ingest_movement_rev and any future writer can share
|
||||||
|
# the FK-guard without re-defining it. Existing call sites in this file
|
||||||
Jimi pushes alarms/GPS for devices that may not yet be in our devices table
|
# continue to use the underscore-prefixed name.
|
||||||
(neither API-sync'd nor in the onboarding CSV). Rather than drop the event,
|
_ensure_device = ensure_device
|
||||||
register the IMEI with whatever context the push carried; the nightly
|
|
||||||
`sync_devices()` and the CSV import fill in the remaining fields later.
|
|
||||||
"""
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO tracksolid.devices (imei, device_name, status, created_at, updated_at)
|
|
||||||
VALUES (%s, %s, 'unknown', NOW(), NOW())
|
|
||||||
ON CONFLICT (imei) DO NOTHING
|
|
||||||
""",
|
|
||||||
(imei, device_name),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -392,6 +383,16 @@ async def push_alarm(request: Request):
|
||||||
lat, lng,
|
lat, lng,
|
||||||
clean_num(item.get("speed")),
|
clean_num(item.get("speed")),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# [FIX-M20] Cross-feed: every Jimi alarm carries lat/lng.
|
||||||
|
# Refresh live_positions so dashboard markers don't have to
|
||||||
|
# wait up to 60s for the next polled sweep. Time-guarded
|
||||||
|
# inside the helper — alarms older than the current fix lose.
|
||||||
|
upsert_live_position(
|
||||||
|
cur, imei, lat, lng, alarm_time,
|
||||||
|
speed=clean_num(item.get("speed")),
|
||||||
|
)
|
||||||
|
|
||||||
cur.execute("RELEASE SAVEPOINT sp")
|
cur.execute("RELEASE SAVEPOINT sp")
|
||||||
inserted += 1
|
inserted += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue