BROOKO icon
BROOKO UK NETWORK
Where code meets creativity & adventure
File viewer

trutrak_api.php

Type
php
Size
29.16 KB
Modified
15 May
trutrak_api.php 29.16 KB
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__ . '/../lib/trutrak_soap.php';

header('Content-Type: application/json; charset=utf-8');

// Never leak PHP warnings/notices into JSON (breaks fetch().json()).
@ini_set('display_errors', '0');
@ini_set('html_errors', '0');
error_reporting(E_ALL);

$__tt_warnings = [];
set_error_handler(function ($severity, $message, $file, $line) use (&$__tt_warnings) {
    // Collect warnings/notices and keep output clean.
    $__tt_warnings[] = ['severity' => $severity, 'message' => $message, 'file' => basename($file), 'line' => $line];
    return true; // handled
});

/**
 * Ensure this endpoint ALWAYS returns JSON (even on fatal errors).
 */
register_shutdown_function(function () {
    $err = error_get_last();
    if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
        if (!headers_sent()) {
            http_response_code(500);
            header('Content-Type: application/json; charset=utf-8');
        }
        echo json_encode(['ok' => false, 'error' => 'Server error', 'detail' => $err['message'] ?? 'fatal'], JSON_UNESCAPED_SLASHES);
        exit;
    }
});

$action = $_GET['action'] ?? 'overview';

// Permission gates
if (in_array($action, ['config', 'save_config', 'test', 'clear_token', 'geofence_meta_save'], true)) {
    requirePermission('trutrak.manage');
} elseif (in_array($action, ['history', 'history_csv', 'trail', 'journeys', 'dailytrail'], true)) {
    // heavy endpoints
    if (hasPermission('trutrak.history')) {
        // ok
    } else {
        requirePermission('trutrak.view');
        http_response_code(403);
        echo json_encode(['ok' => false, 'error' => 'Missing permission: trutrak.history']);
        exit;
    }
} else {
    requirePermission('trutrak.view');
}

if (!function_exists('curl_init')) {
    http_response_code(500);
    echo json_encode(['ok' => false, 'error' => 'Server missing cURL (curl_init). Enable PHP cURL extension.']);
    exit;
}
if (!class_exists('DOMDocument')) {
    http_response_code(500);
    echo json_encode(['ok' => false, 'error' => 'Server missing DOMDocument. Enable PHP DOM/XML extension.']);
    exit;
}

function tt_json($arr, int $code = 200): void {
    http_response_code($code);
    global $__tt_warnings;
    if (!empty($__tt_warnings) && is_array($arr)) {
        // Only attach warnings for easier debugging; safe to ignore client-side.
        $arr['_warnings'] = $__tt_warnings;
    }
    echo json_encode($arr, JSON_UNESCAPED_SLASHES);
    exit;
}

function tt_bad(string $msg, int $code = 400, array $extra = []): void {
    tt_json(array_merge(['ok' => false, 'error' => $msg], $extra), $code);
}

function tt_csv(array $rows, string $filename = 'trutrak_export.csv'): void {
    header('Content-Type: text/csv; charset=utf-8');
    header('Content-Disposition: attachment; filename="' . $filename . '"');
    $out = fopen('php://output', 'w');
    if (!$out) exit;
    if (empty($rows)) {
        fclose($out);
        exit;
    }
    $headers = array_keys($rows[0]);
    fputcsv($out, $headers);
    foreach ($rows as $r) {
        $line = [];
        foreach ($headers as $h) $line[] = $r[$h] ?? '';
        fputcsv($out, $line);
    }
    fclose($out);
    exit;
}

// ─────────────────────────────────────────────────────────────────────────────
// DB cache helpers (keeps last known locations loaded without page refresh)
// ─────────────────────────────────────────────────────────────────────────────

// ─────────────────────────────────────────────────────────────────────────────
// Geofence meta (category + icon) helpers
// ─────────────────────────────────────────────────────────────────────────────

function tt_geofence_meta_init(PDO $pdo): void {
    $pdo->exec("CREATE TABLE IF NOT EXISTS trutrak_geofence_meta (\n"
        . "  geofence_key VARCHAR(64) NOT NULL PRIMARY KEY,\n"
        . "  category VARCHAR(32) NOT NULL DEFAULT 'other',\n"
        . "  icon VARCHAR(32) NOT NULL DEFAULT '📍',\n"
        . "  label VARCHAR(255) NULL,\n"
        . "  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n"
        . ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}

function tt_geofence_key(array $row): string {
    $id = (string)($row['ID'] ?? $row['id'] ?? $row['WAYPOINT_ID'] ?? $row['waypoint_id'] ?? $row['GEOFENCE_ID'] ?? $row['geofence_id'] ?? '');
    if ($id !== '') {
        // keep it compact
        return 'id:' . substr($id, 0, 58);
    }
    $wkt = (string)($row['GEO_COORDINATES'] ?? $row['geo_coordinates'] ?? $row['Geo_Coordinates'] ?? $row['GEO_COORDINATE'] ?? $row['geo'] ?? '');
    $wkt = trim($wkt);
    if ($wkt !== '') {
        return 'w:' . sha1($wkt);
    }
    // fallback to address-based key
    $addr = implode('|', [
        (string)($row['ADDRESS1'] ?? $row['address1'] ?? ''),
        (string)($row['ADDRESS2'] ?? $row['address2'] ?? ''),
        (string)($row['TOWN'] ?? $row['town'] ?? ''),
        (string)($row['POSTCODE'] ?? $row['postcode'] ?? ''),
        (string)($row['COUNTY'] ?? $row['county'] ?? ''),
        (string)($row['COUNTRY'] ?? $row['country'] ?? ''),
    ]);
    return 'a:' . sha1(trim($addr));
}

function tt_geofence_meta_fetch(PDO $pdo, array $keys): array {
    if (empty($keys)) return [];
    tt_geofence_meta_init($pdo);
    $keys = array_values(array_unique(array_filter($keys, fn($x) => (string)$x !== '')));
    if (empty($keys)) return [];
    $in = implode(',', array_fill(0, count($keys), '?'));
    $st = $pdo->prepare("SELECT geofence_key, category, icon, label FROM trutrak_geofence_meta WHERE geofence_key IN ($in)");
    $st->execute($keys);
    $out = [];
    foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
        $out[(string)$r['geofence_key']] = $r;
    }
    return $out;
}

function tt_cache_init(PDO $pdo): void {
    // Safe to call even if migration hasn't run yet.
    $pdo->exec("CREATE TABLE IF NOT EXISTS trutrak_device_cache (\n"
        . "  device_id VARCHAR(64) NOT NULL PRIMARY KEY,\n"
        . "  unique_id VARCHAR(64) NULL,\n"
        . "  name VARCHAR(255) NULL,\n"
        . "  reg VARCHAR(64) NULL,\n"
        . "  latitude DECIMAL(10,7) NULL,\n"
        . "  longitude DECIMAL(10,7) NULL,\n"
        . "  speed VARCHAR(32) NULL,\n"
        . "  heading VARCHAR(32) NULL,\n"
        . "  status VARCHAR(64) NULL,\n"
        . "  address TEXT NULL,\n"
        . "  last_seen DATETIME NULL,\n"
        . "  raw_json LONGTEXT NULL,\n"
        . "  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n"
        . ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}

function tt_cache_write(PDO $pdo, array $items): void {
    tt_cache_init($pdo);
    $sql = "INSERT INTO trutrak_device_cache (device_id, unique_id, name, reg, latitude, longitude, speed, heading, status, address, last_seen, raw_json)\n"
        . "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)\n"
        . "ON DUPLICATE KEY UPDATE unique_id=VALUES(unique_id), name=VALUES(name), reg=VALUES(reg), latitude=VALUES(latitude), longitude=VALUES(longitude),\n"
        . "speed=VALUES(speed), heading=VALUES(heading), status=VALUES(status), address=VALUES(address), last_seen=VALUES(last_seen), raw_json=VALUES(raw_json), updated_at=CURRENT_TIMESTAMP";
    $st = $pdo->prepare($sql);
    foreach ($items as $r) {
        $deviceId = (string)($r['deviceId'] ?? $r['device_id'] ?? '');
        if ($deviceId === '') continue;

        // TruTrak payloads vary by account/endpoint. Some use uppercase keys.
        $regVal = (string)($r['reg'] ?? $r['REG'] ?? $r['Reg'] ?? '');
        $nameVal = (string)($r['name'] ?? $r['NAME'] ?? $r['Name'] ?? '');
        $uniqueVal = (string)($r['uniqueId'] ?? $r['UNIQUE_ID'] ?? $r['UniqueId'] ?? '');
        $last = $r['deviceTime'] ?? $r['fixTime'] ?? $r['time'] ?? $r['timestamp'] ?? $r['serverTime'] ?? null;
        $lastSeen = null;
        if ($last) {
            try {
                $ts = strtotime((string)$last);
                if ($ts !== false) $lastSeen = date('Y-m-d H:i:s', $ts);
            } catch (Throwable $e) {}
        }
        $st->execute([
            $deviceId,
            $uniqueVal,
            $nameVal,
            $regVal,
            (isset($r['latitude']) ? (float)$r['latitude'] : null),
            (isset($r['longitude']) ? (float)$r['longitude'] : null),
            (string)($r['speed'] ?? $r['spd'] ?? ''),
            (string)($r['heading'] ?? $r['hdg'] ?? ''),
            (string)($r['status'] ?? ''),
            (string)($r['address'] ?? ''),
            $lastSeen,
            json_encode($r, JSON_UNESCAPED_SLASHES),
        ]);
    }
    // Track last cache refresh
    tt_sys_set('trutrak_cache_updated_at', (string)time());
}

function tt_cache_read(PDO $pdo): array {
    try {
        tt_cache_init($pdo);
        $rows = $pdo->query(
            "SELECT device_id AS deviceId, unique_id AS uniqueId, name, reg, latitude, longitude, speed, heading, status, address, last_seen AS deviceTime, updated_at
"
            . "FROM trutrak_device_cache ORDER BY COALESCE(NULLIF(name,''), NULLIF(reg,''), device_id)"
        )->fetchAll(PDO::FETCH_ASSOC);

        // Default behaviour: only show devices that are linked to an ACTIVE vehicle.
        // Admins (trutrak.manage) can bypass with ?show_all=1 for debugging.
        $showAll = ((string)($_GET['show_all'] ?? '0') === '1' || (string)($_GET['include_unlinked'] ?? '0') === '1') && hasPermission('trutrak.manage');

        $stats = [
            'total' => count($rows),
            'shown' => count($rows),
            'hidden_unlinked' => 0,
            'hidden_inactive' => 0,
        ];

        // Load mapping + vehicle meta
        $mapRows = null;
        try {
            $mapRows = $pdo->query(
                "SELECT m.trutrak_device_id AS deviceId, v.id AS vehicleId, v.plate_full AS plateFull, v.plate_short AS plateShort, v.make AS make, v.model AS model, v.tag_color AS tagColor, v.is_active AS isActive
"
                . "FROM vehicle_trutrak_map m JOIN vans v ON v.id = m.vehicle_id"
            )->fetchAll(PDO::FETCH_ASSOC);
        } catch (Throwable $e) {
            $mapRows = null;
        }

        $activeByDevice = [];
        $anyByDevice = [];
        foreach ($mapRows as $m) {
            $did = (string)($m['deviceId'] ?? '');
            if ($did === '') continue;
            $anyByDevice[$did] = $m;
            if ((int)($m['isActive'] ?? 0) === 1) $activeByDevice[$did] = $m;
        }

        // If mapping tables are missing (older installs), fail open and show everything.
        if ($mapRows === null) {
            $stats['shown'] = count($rows);
            $updated = (int)tt_sys_get('trutrak_cache_updated_at', '0');
            return ['ok' => true, 'updated_at' => $updated, 'devices' => $rows, 'stats' => $stats, 'show_all' => $showAll];
        }

        $out = [];
        if ($showAll) {
            foreach ($rows as $r) {
                $dev = (string)($r['deviceId'] ?? '');
                if ($dev !== '' && isset($anyByDevice[$dev])) {
                    $m = $anyByDevice[$dev];
                    $r['vehicleId'] = $m['vehicleId'];
                    $r['plateFull'] = $m['plateFull'];
                    $r['plateShort'] = $m['plateShort'];
                $r['make'] = $m['make'] ?? '';
                $r['model'] = $m['model'] ?? '';
                    $r['tagColor'] = $m['tagColor'] ?? '#ff8c1a';
                    $r['vehicleActive'] = (int)($m['isActive'] ?? 0);
                }
                $out[] = $r;
            }
        } else {
            foreach ($rows as $r) {
                $dev = (string)($r['deviceId'] ?? '');
                if ($dev !== '' && isset($activeByDevice[$dev])) {
                    $m = $activeByDevice[$dev];
                    $r['vehicleId'] = $m['vehicleId'];
                    $r['plateFull'] = $m['plateFull'];
                    $r['plateShort'] = $m['plateShort'];
                $r['make'] = $m['make'] ?? '';
                $r['model'] = $m['model'] ?? '';
                    $r['tagColor'] = $m['tagColor'] ?? '#ff8c1a';
                    $r['vehicleActive'] = 1;
                    $out[] = $r;
                } else {
                    if ($dev !== '' && isset($anyByDevice[$dev]) && (int)($anyByDevice[$dev]['isActive'] ?? 0) === 0) {
                        $stats['hidden_inactive']++;
                    } else {
                        $stats['hidden_unlinked']++;
                    }
                }
            }
        }

        $stats['shown'] = count($out);
        $updated = (int)tt_sys_get('trutrak_cache_updated_at', '0');
        return ['ok' => true, 'updated_at' => $updated, 'devices' => $out, 'stats' => $stats, 'show_all' => $showAll];
    } catch (Throwable $e) {
        return ['ok' => true, 'updated_at' => 0, 'devices' => [], 'stats' => ['total' => 0, 'shown' => 0, 'hidden_unlinked' => 0, 'hidden_inactive' => 0]];
    }
}

$cfg = tt_get_cfg();
if (empty($cfg['base_url'])) {
    tt_bad('TruTrak base URL not configured. Go to Administration → TruTrak.', 400);
}

// ─────────────────────────────────────────────────────────────────────────────
// Config endpoints
// ─────────────────────────────────────────────────────────────────────────────

if ($action === 'config') {
    tt_json([
        'ok' => true,
        'config' => [
            'base_url' => $cfg['base_url'],
            'login' => $cfg['login'] ? '***' : '',
            'password' => $cfg['password'] ? '***' : '',
            'skey' => $cfg['skey'] ? '***' : '',
            'auth_mode' => $cfg['auth_mode'],
            'xmltype' => $cfg['xmltype'],
            'token_cached' => ($cfg['token'] !== '' && $cfg['token_exp'] > time() + 300),
        ]
    ]);
}

if ($action === 'save_config') {
    // Accept either JSON or form post
    $in = $_POST;
    if (empty($in) && str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')) {
        $raw = file_get_contents('php://input');
        $j = json_decode($raw ?: '', true);
        if (is_array($j)) $in = $j;
    }

    $base = trim((string)($in['base_url'] ?? $cfg['base_url']));
    if ($base && !preg_match('#^https?://#i', $base)) $base = 'https://' . $base;
    $base = rtrim($base, '/');
    if ($base === '') tt_bad('Base URL is required');

    $auth = strtoupper(trim((string)($in['auth_mode'] ?? $cfg['auth_mode'])));
    if (!in_array($auth, ['C','U'], true)) $auth = 'U';

    $xmltype = (int)($in['xmltype'] ?? $cfg['xmltype']);
    if (!in_array($xmltype, [0,1,2], true)) $xmltype = 0;

    // Only overwrite secrets if user supplied non-empty values
    $login = $cfg['login'];
    if (array_key_exists('login', $in) && trim((string)$in['login']) !== '') $login = trim((string)$in['login']);

    $pass = $cfg['password'];
    if (array_key_exists('password', $in) && trim((string)$in['password']) !== '') $pass = trim((string)$in['password']);

    $skey = $cfg['skey'];
    if (array_key_exists('skey', $in) && trim((string)$in['skey']) !== '') $skey = trim((string)$in['skey']);

    tt_sys_set('trutrak_base_url', $base);
    tt_sys_set('trutrak_auth_mode', $auth);
    tt_sys_set('trutrak_xmltype', (string)$xmltype);
    tt_sys_set('trutrak_login', $login);
    tt_sys_set('trutrak_password', $pass);
    tt_sys_set('trutrak_secret_key', $skey);

    // Clear token if creds changed
    tt_sys_set('trutrak_token', '');
    tt_sys_set('trutrak_token_expires', '0');

    tt_json(['ok' => true]);
}

if ($action === 'clear_token') {
    tt_sys_set('trutrak_token', '');
    tt_sys_set('trutrak_token_expires', '0');
    tt_json(['ok' => true]);
}

if ($action === 'test') {
    // Ensure WSDL loads + token works + assets list returns something
    $wsdl = tt_load_wsdl($cfg['base_url'], true);
    if (!$wsdl['ok']) tt_bad('Failed to load WSDL: ' . ($wsdl['error'] ?? 'Unknown error'), 502);

    $t = tt_get_token($cfg, true);
    if (!$t['ok']) tt_bad('Auth failed: ' . ($t['error'] ?? 'Unknown error'), 502);

    $cfg = tt_get_cfg(); // reload (token now cached)
    $token = $t['token'];

    $assets = tt_call($cfg, 'TTAssetsList', ['token' => $token, 'filter_registration' => '', 'filter_depots' => '']);
    tt_json([
        'ok' => true,
        'wsdl_url' => $wsdl['url'] ?? null,
        'token_cached' => !$t['cached'],
        'assets_sample' => $assets['ok'] ? array_slice($assets['rows'] ?? [], 0, 3) : null,
        'assets_error' => $assets['ok'] ? null : ($assets['error'] ?? 'AssetsList failed'),
    ]);
}

// ─────────────────────────────────────────────────────────────────────────────
// Data endpoints
// ─────────────────────────────────────────────────────────────────────────────

// Cached last-known locations (DB-backed) — returns immediately without hitting TruTrak.
if ($action === 'cache') {
    global $pdo;
    $c = tt_cache_read($pdo);
    tt_json([
        'ok' => true,
        'cached' => true,
        'updated_at' => $c['updated_at'],
        'devices' => $c['devices'],
        'rows' => $c['devices'],
        'items' => $c['devices'],
    ]);
}

// Save geofence meta (category + icon). Does NOT require a TruTrak token.
if ($action === 'geofence_meta_save') {
    global $pdo;
    tt_geofence_meta_init($pdo);

    $raw = file_get_contents('php://input');
    $in = [];
    if ($raw) {
        $j = json_decode($raw, true);
        if (is_array($j)) $in = $j;
    }
    if (empty($in)) $in = $_POST;

    $key = trim((string)($in['key'] ?? ''));
    if ($key === '' || strlen($key) > 64) tt_bad('Invalid geofence key', 400);

    $category = strtolower(trim((string)($in['category'] ?? 'other')));
    $allowedCats = ['office','yard','other'];
    if (!in_array($category, $allowedCats, true)) $category = 'other';

    $icon = trim((string)($in['icon'] ?? '📍'));
    if ($icon === '' || mb_strlen($icon) > 32) $icon = '📍';

    $label = trim((string)($in['label'] ?? ''));
    if ($label !== '' && mb_strlen($label) > 255) $label = mb_substr($label, 0, 255);

    $st = $pdo->prepare("INSERT INTO trutrak_geofence_meta (geofence_key, category, icon, label) VALUES (?,?,?,?)\n"
        . "ON DUPLICATE KEY UPDATE category=VALUES(category), icon=VALUES(icon), label=VALUES(label), updated_at=CURRENT_TIMESTAMP");
    $st->execute([$key, $category, $icon, ($label === '' ? null : $label)]);

    tt_json(['ok' => true]);
}

// Ensure token for most endpoints
$token = '';
if (!in_array($action, ['config','save_config','clear_token','geofence_meta_save'], true)) {
    $tok = tt_get_token($cfg, false);
    if (!$tok['ok']) tt_bad($tok['error'] ?? 'Authentication required', 502);
    $token = $tok['token'];
    $cfg = tt_get_cfg(); // reload cached token
}

// Method helper: pick best available last-location method
function tt_best_last_location_method(array $cfg, bool $withWp = false): array {
    $wsdl = tt_load_wsdl($cfg['base_url'], false);
    if (!$wsdl['ok']) return ['ok' => false, 'error' => $wsdl['error'] ?? 'Failed to load WSDL'];
    $w = $wsdl['raw'];
    $cands = [];
    if ($withWp) {
        $cands = ['TTAssetsLastLocationWithWP_Extended', 'TTAssetsLastLocationWithWP', 'TTAssetsLastLocation_Extended', 'TTAssetsLastLocation'];
    } else {
        $cands = ['TTAssetsLastLocation_Extended', 'TTAssetsLastLocation', 'TTAssetsLastLocationWithWP_Extended', 'TTAssetsLastLocationWithWP'];
    }
    foreach ($cands as $m) {
        if (tt_wsdl_has_method($w, $m)) return ['ok' => true, 'method' => $m];
    }
    return ['ok' => false, 'error' => 'No last-location method found in WSDL'];
}

if ($action === 'overview' || $action === 'live') {
    $withWp = ($_GET['with_wp'] ?? '0') === '1';
    $filterAssets = trim((string)($_GET['filter_assets'] ?? ''));

    $pick = tt_best_last_location_method($cfg, $withWp);
    if (!$pick['ok']) tt_bad($pick['error'] ?? 'No suitable method found', 502);

    $res = tt_call($cfg, $pick['method'], [
        'token' => $token,
        'filter_assets' => $filterAssets,
        'xmltype' => (string)$cfg['xmltype'],
    ]);
    if (!$res['ok']) tt_bad($res['error'] ?? 'API call failed', 502);

    $items = [];
    foreach (($res['rows'] ?? []) as $r) $items[] = tt_norm_location($r);

    // Keep a full copy for DB cache (includes unlinked devices for mapping UI)
    $itemsForCache = $items;

    // Add mapped vehicle metadata (plate + tag colour) and optionally filter.
    // Default: only show mapped devices for ACTIVE vehicles (keeps map clean).
    $showAll = ((string)($_GET['show_all'] ?? '0') === '1' || (string)($_GET['include_unlinked'] ?? '0') === '1') && hasPermission('trutrak.manage');
    $stats = [
        'total' => count($items),
        'shown' => count($items),
        'hidden_unlinked' => 0,
        'hidden_inactive' => 0,
    ];

    try {
        $map = $pdo->query(
            "SELECT m.trutrak_device_id AS deviceId, v.id AS vehicleId, v.plate_full AS plateFull, v.plate_short AS plateShort, v.make AS make, v.model AS model, v.tag_color AS tagColor, v.is_active AS isActive
"
            . "FROM vehicle_trutrak_map m JOIN vans v ON v.id = m.vehicle_id"
        )->fetchAll(PDO::FETCH_ASSOC);

        $activeByDevice = [];
        $anyByDevice = [];
        foreach ($map as $m) {
            $did = (string)($m['deviceId'] ?? '');
            if ($did === '') continue;
            $anyByDevice[$did] = $m;
            if ((int)($m['isActive'] ?? 0) === 1) $activeByDevice[$did] = $m;
        }

        $out = [];
        foreach ($items as $r) {
            $dev = (string)($r['deviceId'] ?? $r['device_id'] ?? '');
            if ($dev !== '' && isset($anyByDevice[$dev])) {
                $m = $anyByDevice[$dev];
                $r['vehicleId'] = $m['vehicleId'];
                $r['plateFull'] = $m['plateFull'];
                $r['plateShort'] = $m['plateShort'];
                $r['make'] = $m['make'] ?? '';
                $r['model'] = $m['model'] ?? '';
                $r['tagColor'] = $m['tagColor'] ?? '#ff8c1a';
                $r['vehicleActive'] = (int)($m['isActive'] ?? 0);
            }

            if ($showAll) {
                $out[] = $r;
                continue;
            }

            // Filter out unlinked + inactive vehicles
            if ($dev !== '' && isset($activeByDevice[$dev])) {
                $out[] = $r;
            } else {
                if ($dev !== '' && isset($anyByDevice[$dev]) && (int)($anyByDevice[$dev]['isActive'] ?? 0) === 0) {
                    $stats['hidden_inactive']++;
                } else {
                    $stats['hidden_unlinked']++;
                }
            }
        }

        $items = $out;
        $stats['shown'] = count($items);
    } catch (Throwable $e) {
        // If mapping tables don't exist yet, fail open (show everything).
    }

    // Persist to DB cache so pages can load immediately without a full refresh.
    try {
        global $pdo;
        tt_cache_write($pdo, $itemsForCache);
    } catch (Throwable $e) {}

    $updated = (int)tt_sys_get('trutrak_cache_updated_at', '0');

    tt_json([
        'ok' => true,
        'method' => $pick['method'],
        'cached' => false,
        'show_all' => $showAll,
        'stats' => $stats,
        'updated_at' => $updated,
        'items' => $items,
        'rows' => $items,
        'devices' => $items,
        'meta' => [
            'base_url' => $cfg['base_url'],
            'xmltype' => $cfg['xmltype'],
        ],
    ]);
}

if ($action === 'assets') {
    $filterReg = trim((string)($_GET['filter_registration'] ?? ''));
    $filterDepots = trim((string)($_GET['filter_depots'] ?? ''));
    $res = tt_call($cfg, 'TTAssetsList', [
        'token' => $token,
        'filter_registration' => $filterReg,
        'filter_depots' => $filterDepots,
        'xmltype' => (string)$cfg['xmltype'],
    ]);
    if (!$res['ok']) tt_bad($res['error'] ?? 'AssetsList failed', 502);
    tt_json(['ok' => true, 'rows' => $res['rows'] ?? []]);
}

if ($action === 'depots') {
    $filterDepot = trim((string)($_GET['filter_depot'] ?? ''));
    $res = tt_call($cfg, 'TTDepotList', [
        'token' => $token,
        'filter_depot' => $filterDepot,
        'xmltype' => (string)$cfg['xmltype'],
    ]);
    if (!$res['ok']) tt_bad($res['error'] ?? 'DepotList failed', 502);
    tt_json(['ok' => true, 'rows' => $res['rows'] ?? []]);
}

if (in_array($action, ['waypoints', 'geofences'], true)) {
    // TruTrak call is still named TTWaypoints, but we present it as Geofences in the UI.
    $filterWp = trim((string)($_GET['filter_geofences'] ?? ($_GET['filter_waypoints'] ?? '')));
    $res = tt_call($cfg, 'TTWaypoints', [
        'token' => $token,
        'filter_waypoints' => $filterWp,
        'xmltype' => (string)$cfg['xmltype'],
    ]);
    if (!$res['ok']) tt_bad($res['error'] ?? 'Geofences failed', 502);

    // Attach local meta (category + icon) so the UI can show proper geofence icons.
    $rows = $res['rows'] ?? [];
    $keys = [];
    foreach ($rows as $r) $keys[] = tt_geofence_key($r);

    $meta = [];
    try {
        global $pdo;
        $meta = tt_geofence_meta_fetch($pdo, $keys);
    } catch (Throwable $e) {
        $meta = [];
    }

    $out = [];
    foreach ($rows as $r) {
        $k = tt_geofence_key($r);
        $r['_key'] = $k;
        if (isset($meta[$k])) {
            $r['_category'] = $meta[$k]['category'] ?? 'other';
            $r['_icon'] = $meta[$k]['icon'] ?? '📍';
            $r['_label'] = $meta[$k]['label'] ?? null;
        }
        $out[] = $r;
    }

    tt_json(['ok' => true, 'rows' => $out]);
}

// Streaming (polling) – returns all locations after a record id
if ($action === 'stream') {
    $after = trim((string)($_GET['after_record'] ?? '0'));

    // Prefer extended if available
    $wsdl = tt_load_wsdl($cfg['base_url'], false);
    if (!$wsdl['ok']) tt_bad($wsdl['error'] ?? 'Failed to load WSDL', 502);
    $method = tt_wsdl_has_method($wsdl['raw'], 'TTAssetsAllLocations_extended') ? 'TTAssetsAllLocations_extended' : 'TTAssetsAllLocations';

    $res = tt_call($cfg, $method, [
        'token' => $token,
        'after_record' => $after,
        'xmltype' => (string)$cfg['xmltype'],
    ]);
    if (!$res['ok']) tt_bad($res['error'] ?? 'Stream call failed', 502);

    $items = [];
    foreach (($res['rows'] ?? []) as $r) $items[] = tt_norm_location($r);
    tt_json(['ok' => true, 'method' => $method, 'items' => $items, 'after_record' => $after]);
}

// History endpoints
function tt_parse_dt(string $s): string {
    // TruTrak typically accepts ISO; keep what the user provides but normalize spacing
    $s = trim($s);
    if ($s === '') return '';
    // allow HTML5 datetime-local (YYYY-MM-DDTHH:MM)
    $s = str_replace('T', ' ', $s);
    return $s;
}

if (in_array($action, ['trail','journeys','dailytrail','history','history_csv'], true)) {
    // Method selection
    $method = $_GET['method'] ?? '';
    if ($action === 'trail') $method = 'TTAssetTrail';
    if ($action === 'journeys') $method = 'TTAssetJourneys';
    if ($action === 'dailytrail') $method = 'TTAssetDailyTrail';

    if ($method === '') {
        // default
        $method = 'TTAssetJourneys';
    }

    $filterAssets = trim((string)($_GET['filter_assets'] ?? ''));
    $start = tt_parse_dt((string)($_GET['start'] ?? ''));
    $end = tt_parse_dt((string)($_GET['end'] ?? ''));
    $date = trim((string)($_GET['date'] ?? ''));

    // DailyTrail: enforce 1 call per 24h (best-effort)
    if ($method === 'TTAssetDailyTrail') {
        $last = (int)$cfg['daily_ts'];
        if ($last > 0 && (time() - $last) < 86400) {
            tt_bad('TTAssetDailyTrail is limited to 1 call every 24H (per TruTrak). Try again later.', 429);
        }
    }

    $res = tt_call($cfg, $method, [
        'token' => $token,
        'filter_assets' => $filterAssets,
        'start' => $start,
        'end' => $end,
        'date' => $date,
        'xmltype' => (string)$cfg['xmltype'],
    ]);
    if (!$res['ok']) tt_bad($res['error'] ?? 'History call failed', 502);

    if ($method === 'TTAssetDailyTrail') {
        tt_sys_set('trutrak_dailytrail_last_ts', (string)time());
    }

    $rows = $res['rows'] ?? [];

    if ($action === 'history_csv') {
        tt_csv($rows, 'trutrak_' . strtolower($method) . '_' . date('Ymd_His') . '.csv');
    }

    // Limit payload for browser
    $max = 500;
    tt_json([
        'ok' => true,
        'method' => $method,
        'count' => count($rows),
        'rows' => array_slice($rows, 0, $max),
        'truncated' => count($rows) > $max,
    ]);
}

tt_bad('Unknown action', 400, ['action' => $action]);