Kemp.rest / Examples / Code / AIS

KEMP.REST / EXAMPLES / CODE

AIS positions from AISStream

Written by Kevin Kemp in PHP for Offing: Following Seas 1.5.1. The AISStream key stays in the plugin settings, not in this code.

View the code

Offing shows a boat to the people watching from shore. The positions are AIS, from AISStream’s live WebSocket. PHP has no WebSocket client, and the plugin ships with no Composer packages, so the client speaks RFC 6455 itself. The collector holds two of the three connections the free key allows.

Type of code

A WordPress plugin class for the socket, and a collector a cron job, WP-CLI, or a REST URL can run. A minute-by-minute cron line boots WordPress from collect.php and listens for the configured number of seconds.

Technology

PHP 7.4, WordPress, OpenSSL for the TLS socket, zlib for permessage-deflate when inflate_init() exists, JSON, and non-blocking stream_socket_client() / stream_select(). The feed is wss://stream.aisstream.io/v0/stream. The API key is read from the settings option when the subscription is built.

Timing

What it does

Problem it solved

A family ashore needed the boat’s real AIS position on an ordinary WordPress page, from a feed that only speaks WebSocket, on hosting that will not run Composer.

Code

Back to summary · Following Seas 1.5.1 · AISStream

// —— includes/class-pcaisr-websocket.php ——

<?php
/**
 * Minimal RFC 6455 WebSocket client used to read the AISStream feed.
 *
 * @package FollowingSeas
 */

defined('ABSPATH') || exit;
/*
 * Minimal RFC 6455 WebSocket client for the AISStream feed.
 * - TLS (wss://) or plain (ws://)
 * - permessage-deflate (RFC 7692) receive side, with context takeover
 * - fragmented messages, ping/pong, close
 * Pure PHP: needs only the openssl and zlib extensions.
 */
class PCAISR_WebSocket
{
    public $sock = null;
    public bool $closed = false;
    public string $closeReason = '';
    public float $lastRx = 0;
    public bool $deflate = false;

    private string $buf = '';
    private $inflater = null;
    private ?int $fragOp = null;
    private string $fragData = '';
    private bool $fragCompressed = false;

    private string $scheme;
    private string $host;
    private int $port;
    private string $path;

    public function __construct(string $url)
    {
        $u = parse_url($url);
        if (!$u || empty($u['host'])) {
            throw new InvalidArgumentException("Bad WebSocket URL: $url");
        }
        $this->scheme = strtolower($u['scheme'] ?? 'wss');
        $this->host   = $u['host'];
        $this->port   = (int)($u['port'] ?? ($this->scheme === 'ws' ? 80 : 443));
        $this->path   = ($u['path'] ?? '/') . (isset($u['query']) ? '?' . $u['query'] : '');
    }

    public function connect(float $timeout = 10.0): void
    {
        $transport = $this->scheme === 'ws' ? 'tcp' : 'tls';
        $ctx = stream_context_create(['ssl' => [
            'verify_peer'      => true,
            'verify_peer_name' => true,
            'SNI_enabled'      => true,
            'peer_name'        => $this->host,
        ]]);
        $errno = 0;
        $errstr = '';
        $s = @stream_socket_client("$transport://{$this->host}:{$this->port}", $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
        if (!$s) {
            throw new RuntimeException("Connect to {$this->host}:{$this->port} failed: $errstr ($errno)");
        }
        stream_set_timeout($s, (int)ceil($timeout));

        $key = base64_encode(random_bytes(16));
        $hostHdr = $this->host . ((($this->scheme === 'ws' && $this->port !== 80) || ($this->scheme !== 'ws' && $this->port !== 443)) ? ':' . $this->port : '');
        $can_inflate = function_exists('inflate_init');
        $req = "GET {$this->path} HTTP/1.1\r\n"
             . "Host: $hostHdr\r\n"
             . "Upgrade: websocket\r\n"
             . "Connection: Upgrade\r\n"
             . "Sec-WebSocket-Key: $key\r\n"
             . "Sec-WebSocket-Version: 13\r\n"
             . "Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n"
             . "User-Agent: PressCare-AIS-Radar/1.0 (WordPress)\r\n\r\n";
        $this->sock = $s;
        $this->writeAll($req, $timeout);

        $resp = '';
        $deadline = microtime(true) + $timeout;
        while (strpos($resp, "\r\n\r\n") === false) {
            if (microtime(true) > $deadline) {
                throw new RuntimeException('WebSocket handshake timed out');
            }
            $chunk = fread($s, 8192);
            if ($chunk === false || ($chunk === '' && feof($s))) {
                throw new RuntimeException('Connection closed during WebSocket handshake');
            }
            $resp .= $chunk;
            if (strlen($resp) > 65536) {
                throw new RuntimeException('Oversized handshake response');
            }
        }
        [$head, $rest] = explode("\r\n\r\n", $resp, 2);
        $lines = explode("\r\n", $head);
        if (!preg_match('#^HTTP/1\.[01] 101#', $lines[0])) {
            throw new RuntimeException('WebSocket upgrade refused: ' . trim($lines[0]));
        }
        $hdr = [];
        foreach (array_slice($lines, 1) as $ln) {
            $p = strpos($ln, ':');
            if ($p !== false) {
                $hdr[strtolower(trim(substr($ln, 0, $p)))] = trim(substr($ln, $p + 1));
            }
        }
        $expect = base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
        if (($hdr['sec-websocket-accept'] ?? '') !== $expect) {
            throw new RuntimeException('Bad Sec-WebSocket-Accept from server');
        }
        if ($can_inflate && stripos($hdr['sec-websocket-extensions'] ?? '', 'permessage-deflate') !== false) {
            $this->deflate  = true;
            $this->inflater = inflate_init(ZLIB_ENCODING_RAW, ['window' => 15]);
        }
        $this->buf = $rest;
        stream_set_blocking($s, false);
        $this->lastRx = microtime(true);
    }

    /* Reads whatever is available and returns complete text/binary messages. */
    public function poll(): array
    {
        $out = [];
        if (!$this->sock || $this->closed) {
            return $out;
        }
        for ($i = 0; $i < 256; $i++) {
            $c = @fread($this->sock, 65536);
            if ($c === false || $c === '') {
                break;
            }
            $this->buf .= $c;
            $this->lastRx = microtime(true);
        }
        $this->parseFrames($out);
        if (!$this->closed && feof($this->sock)) {
            $this->closed = true;
            $this->closeReason = $this->closeReason ?: 'connection closed by server';
        }
        return $out;
    }

    private function parseFrames(array &$out): void
    {
        while (true) {
            $len = strlen($this->buf);
            if ($len < 2) {
                return;
            }
            $b0 = ord($this->buf[0]);
            $b1 = ord($this->buf[1]);
            $fin  = ($b0 & 0x80) !== 0;
            $rsv1 = ($b0 & 0x40) !== 0;
            $op   = $b0 & 0x0F;
            $masked = ($b1 & 0x80) !== 0;
            $pl  = $b1 & 0x7F;
            $off = 2;
            if ($pl === 126) {
                if ($len < 4) return;
                $pl = unpack('n', substr($this->buf, 2, 2))[1];
                $off = 4;
            } elseif ($pl === 127) {
                if ($len < 10) return;
                $pl = unpack('J', substr($this->buf, 2, 8))[1];
                $off = 10;
            }
            $mask = '';
            if ($masked) {
                if ($len < $off + 4) return;
                $mask = substr($this->buf, $off, 4);
                $off += 4;
            }
            if ($len < $off + $pl) {
                return;
            }
            $payload = (string)substr($this->buf, $off, $pl);
            $this->buf = (string)substr($this->buf, $off + $pl);
            if ($masked && $pl > 0) {
                $payload ^= substr(str_repeat($mask, intdiv($pl + 3, 4)), 0, $pl);
            }

            switch ($op) {
                case 0x0: // continuation
                    if ($this->fragOp === null) break;
                    $this->fragData .= $payload;
                    if ($fin) {
                        $out[] = $this->finish($this->fragData, $this->fragCompressed);
                        $this->fragOp = null;
                        $this->fragData = '';
                    }
                    break;
                case 0x1:
                case 0x2:
                    if ($fin) {
                        $out[] = $this->finish($payload, $rsv1);
                    } else {
                        $this->fragOp = $op;
                        $this->fragData = $payload;
                        $this->fragCompressed = $rsv1;
                    }
                    break;
                case 0x8:
                    $code = strlen($payload) >= 2 ? unpack('n', substr($payload, 0, 2))[1] : 1005;
                    $this->closeReason = "server closed ($code" . (strlen($payload) > 2 ? ': ' . substr($payload, 2) : '') . ')';
                    @$this->sendFrame(0x8, substr($payload, 0, 2));
                    $this->closed = true;
                    return;
                case 0x9:
                    $this->sendFrame(0xA, $payload);
                    break;
                case 0xA:
                    break;
            }
        }
    }

    private function finish(string $data, bool $compressed): string
    {
        if ($compressed && $this->inflater) {
            $r = inflate_add($this->inflater, $data . "\x00\x00\xff\xff", ZLIB_SYNC_FLUSH);
            return $r === false ? '' : $r;
        }
        return $data;
    }

    public function sendText(string $text): void
    {
        $this->sendFrame(0x1, $text);
    }

    public function ping(): void
    {
        $this->sendFrame(0x9, 'k');
    }

    private function sendFrame(int $op, string $data): void
    {
        $len = strlen($data);
        $h = chr(0x80 | $op);
        if ($len < 126) {
            $h .= chr(0x80 | $len);
        } elseif ($len < 65536) {
            $h .= chr(0x80 | 126) . pack('n', $len);
        } else {
            $h .= chr(0x80 | 127) . pack('J', $len);
        }
        $mask = random_bytes(4);
        $h .= $mask;
        if ($len > 0) {
            $data ^= substr(str_repeat($mask, intdiv($len + 3, 4)), 0, $len);
        }
        $this->writeAll($h . $data, 5.0);
    }

    private function writeAll(string $bytes, float $timeout): void
    {
        $deadline = microtime(true) + $timeout;
        while ($bytes !== '') {
            $n = @fwrite($this->sock, $bytes);
            if ($n === false) {
                throw new RuntimeException('Socket write failed');
            }
            if ($n > 0) {
                $bytes = (string)substr($bytes, $n);
                continue;
            }
            if (microtime(true) > $deadline) {
                throw new RuntimeException('Socket write timed out');
            }
            usleep(10000);
        }
    }

    public function close(): void
    {
        if ($this->sock) {
            if (!$this->closed) {
                try {
                    $this->sendFrame(0x8, pack('n', 1000));
                } catch (Throwable $e) {
                }
            }
            @fclose($this->sock);
        }
        $this->sock = null;
        $this->closed = true;
    }
}

// —— includes/class-pcaisr-collector.php ——

<?php
/**
 * Listens to AISStream and merges every report into the state file.
 * Two connections cover any number of tracked vessels (AISStream allows 3 per key, 200 MMSIs per filter):
 *   "target":  all tracked MMSIs, whole world.
 *   "traffic": every station in a box of radius_nm around each tracked vessel's last fix.
 *
 * @package FollowingSeas
 */

defined('ABSPATH') || exit;

final class PCAISR_Collector
{
    const TYPES = [
        'PositionReport', 'StandardClassBPositionReport', 'ExtendedClassBPositionReport',
        'ShipStaticData', 'StaticDataReport', 'AidsToNavigationReport',
        'StandardSearchAndRescueAircraftReport', 'LongRangeAisBroadcastMessage',
    ];

    private array $cfg;
    private array $st;
    private array $tracked = [];            // mmsi => name
    private array $conn = ['target' => null, 'traffic' => null];
    private array $retryAt = ['target' => 0, 'traffic' => 0];
    private array $backoff = ['target' => 2, 'traffic' => 2];
    private array $targetSubKey = [];
    private array $boxCenters = [];         // mmsi => [lat, lon] used for current traffic subscription
    private float $lastSub = 0;
    private float $lastPing = 0;
    private float $lastReload = 0;
    private $logger;

    public function __construct(?callable $logger = null)
    {
        $this->logger = $logger;
    }

    private function log(string $m): void
    {
        if ($this->logger) call_user_func($this->logger, $m);
    }

    private function reload_config(): void
    {
        wp_cache_delete(PCAISR::OPT_SETTINGS, 'options');
        wp_cache_delete(PCAISR::OPT_VESSELS, 'options');
        wp_cache_delete('alloptions', 'options');
        $this->cfg = PCAISR::settings();
        $this->tracked = PCAISR::vessels();
    }

    /** $seconds = 0 runs until killed (VPS daemon). */
    public function run(int $seconds, string $mode): void
    {
        $this->reload_config();
        $this->st = PCAISR_Store::load();
        $this->st['feed']['mode'] = $mode;

        if (trim((string)$this->cfg['api_key']) === '') {
            $this->error(__('No AISStream API key saved on the Offing settings page.', 'following-seas'));
            $this->flush();
            return;
        }
        if (!$this->tracked) {
            $this->error(__('No vessels added on the Offing settings page.', 'following-seas'));
            $this->flush();
            return;
        }

        $deadline  = $seconds > 0 ? microtime(true) + $seconds : INF;
        $lastFlush = 0;
        $this->lastReload = microtime(true);

        while (microtime(true) < $deadline) {
            $now = microtime(true);
            if ($now - $this->lastReload > 60) {          // pick up vessels added/removed in admin
                $this->reload_config();
                $this->lastReload = $now;
            }
            $this->ensure_target($now);
            $this->ensure_traffic($now);

            $socks = [];
            foreach ($this->conn as $c) {
                if ($c && $c->sock) $socks[] = $c->sock;
            }
            if ($socks) {
                $w = $e = null;
                @stream_select($socks, $w, $e, 0, 200000);
            } else {
                usleep(200000);
            }

            foreach ($this->conn as $name => $c) {
                if (!$c) continue;
                foreach ($c->poll() as $raw) {
                    $this->handle($raw);
                }
                if ($c->closed) {
                    $this->log("$name closed: {$c->closeReason}");
                    if ($c->closeReason) $this->error("$name stream: {$c->closeReason}");
                    $c->close();
                    $this->drop($name);
                } elseif ($now - $c->lastRx > 90) {
                    $this->error("$name stream: no data for 90 s, reconnecting");
                    $c->close();
                    $this->drop($name);
                }
            }

            if ($now - $this->lastPing > 25) {
                foreach ($this->conn as $c) {
                    if ($c) { try { $c->ping(); } catch (Throwable $e) {} }
                }
                $this->lastPing = $now;
            }
            if ($now - $lastFlush >= 2) {
                $this->flush();
                $lastFlush = $now;
            }
        }

        foreach (array_keys($this->conn) as $n) {
            if ($this->conn[$n]) $this->conn[$n]->close();
            $this->conn[$n] = null;
        }
        $this->st['feed']['connected'] = ['target' => false, 'traffic' => false];
        $this->flush();
    }

    private function drop(string $name): void
    {
        $this->conn[$name] = null;
        $this->st['feed']['connected'][$name] = false;
        $this->retryAt[$name] = microtime(true) + $this->backoff[$name] + mt_rand(0, 1000) / 1000;
        $this->backoff[$name] = min(60, $this->backoff[$name] * 2);
        if ($name === 'traffic') $this->boxCenters = [];
    }

    private function subscription(array $boxes, ?array $mmsis): string
    {
        $sub = [
            'APIKey'             => trim((string)$this->cfg['api_key']),
            'BoundingBoxes'      => $boxes,
            'FilterMessageTypes' => self::TYPES,
        ];
        if ($mmsis) $sub['FiltersShipMMSI'] = array_values($mmsis);
        return wp_json_encode($sub);
    }

    private function open(string $name, string $sub): PCAISR_WebSocket
    {
        $ws = new PCAISR_WebSocket(PCAISR::ws_url());
        $ws->connect(10);
        $ws->sendText($sub);
        $this->log("$name connected (deflate=" . ($ws->deflate ? 'on' : 'off') . ')');
        return $ws;
    }

    private function ensure_target(float $now): void
    {
        $mmsis = array_map('strval', array_slice(array_keys($this->tracked), 0, 200));
        sort($mmsis);
        if ($this->conn['target']) {
            if ($mmsis !== $this->targetSubKey && $now - $this->lastSub > 2) {   // vessel list changed
                try {
                    $this->conn['target']->sendText($this->subscription([[[-90, -180], [90, 180]]], $mmsis));
                    $this->targetSubKey = $mmsis;
                    $this->lastSub = $now;
                } catch (Throwable $e) {
                    $this->conn['target']->close();
                    $this->drop('target');
                }
            }
            return;
        }
        if ($now < $this->retryAt['target']) return;
        try {
            $this->conn['target'] = $this->open('target', $this->subscription([[[-90, -180], [90, 180]]], $mmsis));
            $this->targetSubKey = $mmsis;
            $this->st['feed']['connected']['target'] = true;
            $this->backoff['target'] = 2;
        } catch (Throwable $e) {
            $this->error('target stream: ' . $e->getMessage());
            $this->drop('target');
        }
    }

    private function box(float $lat, float $lon): array
    {
        $r    = max(1, (float)$this->cfg['radius_nm']);
        $dLat = $r / 60;
        $dLon = $r / (60 * max(0.05, cos(deg2rad($lat))));
        return [[max(-90, $lat - $dLat), max(-180, $lon - $dLon)], [min(90, $lat + $dLat), min(180, $lon + $dLon)]];
    }

    private function ensure_traffic(float $now): void
    {
        $centers = [];
        foreach ($this->tracked as $m => $_) {
            $v = $this->st['vessels'][$m] ?? null;
            if ($v && isset($v['lat'])) $centers[$m] = [$v['lat'], $v['lon']];
        }
        if (!$centers) return;                                // need at least one fix first

        $boxes = function () use ($centers) {
            $b = [];
            foreach ($centers as $c) $b[] = $this->box($c[0], $c[1]);
            return $b;
        };

        if (!$this->conn['traffic']) {
            if ($now < $this->retryAt['traffic']) return;
            try {
                $this->conn['traffic'] = $this->open('traffic', $this->subscription($boxes(), null));
                $this->boxCenters = $centers;
                $this->lastSub = $now;
                $this->st['feed']['connected']['traffic'] = true;
                $this->backoff['traffic'] = 2;
            } catch (Throwable $e) {
                $this->error('traffic stream: ' . $e->getMessage());
                $this->drop('traffic');
            }
            return;
        }

        // Re-subscribe when a vessel gained its first fix, was removed, or moved more than 2 NM.
        $stale = array_keys($centers) != array_keys($this->boxCenters);
        if (!$stale) {
            foreach ($centers as $m => $c) {
                $o = $this->boxCenters[$m];
                if (PCAISR_Store::dist_nm($c[0], $c[1], $o[0], $o[1]) > 2) { $stale = true; break; }
            }
        }
        if ($stale && $now - $this->lastSub > 5) {
            try {
                $this->conn['traffic']->sendText($this->subscription($boxes(), null));
                $this->boxCenters = $centers;
                $this->lastSub = $now;
            } catch (Throwable $e) {
                $this->conn['traffic']->close();
                $this->drop('traffic');
            }
        }
    }

    private function error(string $m): void
    {
        $this->st['feed']['lastError']   = mb_substr($m, 0, 300);
        $this->st['feed']['lastErrorAt'] = time();
    }

    private static function clean($s): string
    {
        $s = strtoupper(trim(str_replace('@', ' ', (string)$s)));
        $s = preg_replace('/[^\x20-\x7E]/', '', $s);
        return trim(preg_replace('/\s+/', ' ', $s));
    }

    private static function report_time(array $meta): int
    {
        $t = $meta['time_utc'] ?? ($meta['TimeUtc'] ?? null);
        if (is_string($t) && preg_match('/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/', $t, $m)) {
            $ts = strtotime($m[1] . ' ' . $m[2] . ' UTC');
            if ($ts && abs($ts - time()) < 3600) return $ts;
        }
        return time();
    }

    private function handle(string $raw): void
    {
        $msg = json_decode($raw, true);
        if (!is_array($msg)) return;
        if (isset($msg['error'])) {
            $this->error('AISStream: ' . (is_string($msg['error']) ? $msg['error'] : wp_json_encode($msg['error'])));
            return;
        }
        $type = $msg['MessageType'] ?? '';
        if ($type === '' || $type === 'SubscriptionConfirmation') return;
        $body = $msg['Message'][$type] ?? null;
        $meta = $msg['MetaData'] ?? [];
        if (!is_array($body)) return;

        $id = $body['UserID'] ?? ($meta['MMSI'] ?? null);
        if ($id === null) return;
        $mmsi = str_pad((string)(int)$id, 9, '0', STR_PAD_LEFT);
        $ts   = self::report_time($meta);
        $now  = time();

        $v = $this->st['vessels'][$mmsi] ?? ['mmsi' => $mmsi];
        $v['mmsi'] = $mmsi;
        $v['lastSeen'] = $now;
        $metaName = self::clean($meta['ShipName'] ?? '');
        if ($metaName !== '' && empty($v['name'])) $v['name'] = $metaName;

        $pos = function (array $b, string $cls) use (&$v, $ts, $type) {
            $lat = $b['Latitude'] ?? null;
            $lon = $b['Longitude'] ?? null;
            if (!is_numeric($lat) || !is_numeric($lon) || abs($lat) > 90 || abs($lon) > 180) return false;
            if ($lat == 0 && $lon == 0) return false;
            if (isset($v['posTime']) && $ts < $v['posTime']) return false;
            $v['lat'] = round((float)$lat, 6);
            $v['lon'] = round((float)$lon, 6);
            $v['posTime'] = $ts;
            $v['class']   = $cls;
            $v['posAcc']  = !empty($b['PositionAccuracy']);
            $sog = $b['Sog'] ?? null;
            $v['sog'] = (is_numeric($sog) && $sog < 102.2) ? round((float)$sog, 1) : null;
            $cog = $b['Cog'] ?? null;
            $v['cog'] = (is_numeric($cog) && $cog < 360) ? round((float)$cog, 1) : null;
            $hdg = $b['TrueHeading'] ?? null;
            $v['hdg'] = (is_numeric($hdg) && $hdg >= 0 && $hdg < 360) ? (int)$hdg : null;
            return true;
        };
        $dims = function ($d) use (&$v) {
            if (!is_array($d)) return;
            $a = (int)($d['A'] ?? 0); $b = (int)($d['B'] ?? 0); $c = (int)($d['C'] ?? 0); $e = (int)($d['D'] ?? 0);
            if ($a + $b > 0) $v['length'] = $a + $b;
            if ($c + $e > 0) $v['beam'] = $c + $e;
        };

        $moved = false;
        switch ($type) {
            case 'PositionReport':
                $moved = $pos($body, 'A');
                if ($moved) {
                    $ns = $body['NavigationalStatus'] ?? null;
                    $v['nav'] = is_numeric($ns) ? (int)$ns : null;
                    $rot = $body['RateOfTurn'] ?? null;
                    $v['rot'] = (is_numeric($rot) && $rot > -128 && $rot < 128) ? (int)$rot : null;
                }
                break;
            case 'StandardClassBPositionReport':
                $moved = $pos($body, 'B');
                break;
            case 'ExtendedClassBPositionReport':
                $moved = $pos($body, 'B');
                $n = self::clean($body['Name'] ?? '');
                if ($n !== '') $v['name'] = $n;
                if (!empty($body['Type'])) $v['type'] = (int)$body['Type'];
                $dims($body['Dimension'] ?? null);
                $v['staticTime'] = $ts;
                break;
            case 'LongRangeAisBroadcastMessage':
                $moved = $pos($body, $v['class'] ?? 'A');
                if ($moved) {
                    $ns = $body['NavigationalStatus'] ?? null;
                    $v['nav'] = is_numeric($ns) ? (int)$ns : null;
                }
                break;
            case 'StandardSearchAndRescueAircraftReport':
                $moved = $pos($body, 'SAR');
                $v['altitude'] = isset($body['Altitude']) ? (int)$body['Altitude'] : null;
                break;
            case 'AidsToNavigationReport':
                $moved = $pos($body, 'AtoN');
                $n = self::clean(($body['Name'] ?? '') . ($body['NameExtension'] ?? ''));
                if ($n !== '') $v['name'] = $n;
                $v['atonType'] = (int)($body['Type'] ?? 0);
                $v['virtual']  = !empty($body['VirtualAtoN']);
                $dims($body['Dimension'] ?? null);
                break;
            case 'ShipStaticData':
                $n = self::clean($body['Name'] ?? '');
                if ($n !== '') $v['name'] = $n;
                $cs = self::clean($body['CallSign'] ?? '');
                if ($cs !== '') $v['callsign'] = $cs;
                if (!empty($body['ImoNumber'])) $v['imo'] = (int)$body['ImoNumber'];
                if (!empty($body['Type'])) $v['type'] = (int)$body['Type'];
                $dims($body['Dimension'] ?? null);
                $dr = $body['MaximumStaticDraught'] ?? null;
                if (is_numeric($dr) && $dr > 0) $v['draught'] = round((float)$dr, 1);
                $dest = self::clean($body['Destination'] ?? '');
                if ($dest !== '') $v['destination'] = $dest;
                $eta = $body['Eta'] ?? null;
                if (is_array($eta) && !empty($eta['Month']) && !empty($eta['Day']) && ($eta['Hour'] ?? 24) < 24 && ($eta['Minute'] ?? 60) < 60) {
                    $v['eta'] = sprintf('%02d-%02d %02d:%02d UTC', $eta['Month'], $eta['Day'], $eta['Hour'], $eta['Minute']);
                }
                $v['class'] = $v['class'] ?? 'A';
                $v['staticTime'] = $ts;
                break;
            case 'StaticDataReport':
                if (empty($body['PartNumber'])) {
                    $n = self::clean($body['ReportA']['Name'] ?? '');
                    if ($n !== '') $v['name'] = $n;
                } else {
                    $rb = $body['ReportB'] ?? [];
                    if (!empty($rb['ShipType'])) $v['type'] = (int)$rb['ShipType'];
                    $cs = self::clean($rb['CallSign'] ?? '');
                    if ($cs !== '') $v['callsign'] = $cs;
                    $vend = self::clean($rb['VendorIDName'] ?? '');
                    if ($vend !== '') $v['vendor'] = $vend;
                    $dims($rb['Dimension'] ?? null);
                }
                $v['class'] = $v['class'] ?? 'B';
                $v['staticTime'] = $ts;
                break;
            default:
                return;
        }

        $this->st['vessels'][$mmsi] = $v;
        $this->st['feed']['lastMessage'] = $now;
        $this->st['feed']['messages'] = (int)$this->st['feed']['messages'] + 1;
        if ($moved && isset($this->tracked[$mmsi])) $this->add_track($v);
    }

    private function add_track(array $v): void
    {
        $m = $v['mmsi'];
        $tr = $this->st['tracks'][$m] ?? [];
        $last = end($tr);
        if ($last && ($v['posTime'] - $last[0] < 30) && PCAISR_Store::dist_nm($last[1], $last[2], $v['lat'], $v['lon']) < 0.03) return;
        $tr[] = [$v['posTime'], $v['lat'], $v['lon'], $v['sog']];
        $this->st['tracks'][$m] = array_slice($tr, -720);
    }

    private function flush(): void
    {
        $this->st['feed']['heartbeat'] = time();
        $cut = time() - 60 * max(1, (int)$this->cfg['max_age_min']);
        foreach ($this->st['vessels'] as $m => $v) {
            $m = (string)$m;
            if (!isset($this->tracked[$m]) && (int)($v['lastSeen'] ?? 0) < $cut) unset($this->st['vessels'][$m]);
        }
        foreach (array_keys($this->st['tracks']) as $m) {
            if (!isset($this->tracked[(string)$m])) unset($this->st['tracks'][$m]);
        }
        PCAISR_Store::save($this->st);
    }
}

// —— collect.php ——

<?php
/*
 * Cron entry for hosts without WP-CLI. Run every minute:
 *   * * * * * /usr/bin/php /home/USER/public_html/wp-content/plugins/presscare-ais-radar/collect.php >/dev/null 2>&1
 * Options: first argument = seconds to listen (0 = forever), -v = verbose.
 */
if (PHP_SAPI !== 'cli') {
    http_response_code(403);
    exit;
}

// Locate wp-load.php by walking up from the plugin folder.
$dir = __DIR__;
$wp_load = null;
for ($i = 0; $i < 8; $i++) {
    $dir = dirname($dir);
    if (is_file($dir . '/wp-load.php')) { $wp_load = $dir . '/wp-load.php'; break; }
}
if (!$wp_load) {
    fwrite(STDERR, "wp-load.php not found\n");
    exit(1);
}
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'localhost';
define('DOING_CRON', true);
require $wp_load;

if (!class_exists('PCAISR')) {
    fwrite(STDERR, "PressCare AIS Radar is not active\n");
    exit(1);
}
$verbose = in_array('-v', $argv, true);
$seconds = isset($argv[1]) && ctype_digit($argv[1]) ? (int)$argv[1] : (int)PCAISR::settings()['run_seconds'];

$lock = PCAISR_Store::try_lock();
if (!$lock) {
    if ($verbose) fwrite(STDERR, "Another collector is running; exiting.\n");
    exit(0);
}
@set_time_limit(0);
try {
    (new PCAISR_Collector($verbose ? function ($m) { fwrite(STDERR, gmdate('H:i:s') . " $m\n"); } : null))->run($seconds, 'cron');
} finally {
    PCAISR_Store::unlock($lock);
}