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 codeOffing 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
- The target socket subscribes to the tracked MMSIs across the whole world, at most 200. A changed vessel list is sent again on the same socket, at most once every 2 seconds.
- The traffic socket opens only after a vessel has a stored fix. Its box is the traffic radius in nautical miles. It subscribes again when a vessel gains a first fix, is removed, or moves more than 2 NM, and never more often than once every 5 seconds.
- The state file is flushed every 2 seconds. A ping goes out every 25 seconds. A socket with no data for 90 seconds is dropped. Backoff starts at 2 seconds and doubles to 60. One connection failing leaves the other running.
What it does
- The handshake sends
Upgrade: websocket, a 16-byte key, and version 13, then checksSec-WebSocket-Acceptagainst the RFC magic GUID. - Frames cover the 7-bit, 16-bit, and 64-bit lengths, continuation frames, client masking, ping answered with pong, and close codes kept for the status line.
poll()does up to 256 non-blocking reads and returns complete messages. An empty read does not throw.- Each AIS type keeps only the fields it actually carries. Positions that are not numeric, out of range, exactly 0,0, or older than the stored fix are dropped. Speed 102.2 knots and course 360 are the not-available values and are discarded. Names lose the
@padding AIS uses.
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);
}