#!/usr/bin/env php ${NODE},1,Ringing() * (snip) * same => n,Set(CALLSIGN=${CALLERID(name)}) * same => n,Set(RPT_NODE=${CALLERID(name)}-P) * same => n,Set(GLOBAL(${RPT_NODE}_CHANNEL)=${CHANNEL}) * (snip) * * An event for node 1234 is added to rpt.conf. For example: * [events1234] * /etc/asterisk/custom/mute3mins.php = s|t|RPT_TXKEYED * * Additional behaviours: * - Single-instance enforcement via a PID lock file. A second copy will * detect the running instance and exit immediately. * - The script exits cleanly as soon as RPT_TXKEYED transitions from 1 -> 0 * (i.e. the node is no longer TX-keyed). * * Usage: * php mute3mins.php [--host HOST] [--port PORT] * [--user USER] [--pass PASS] * [--node NODE] [--interval SECONDS] * [--lockfile PATH] * * Defaults: * --host 127.0.0.1 * --port 5038 * --user admin * --pass secret * --node 1234 * --interval 180 (3 minutes) * --lockfile /tmp/mute3mins.pid */ declare(strict_types=1); // --------------------------------------------- // Configuration (override via CLI flags) // --------------------------------------------- $config = [ 'host' => '127.0.0.1', 'port' => 5038, 'user' => 'admin', 'pass' => 'secret', 'node' => '1234', 'interval' => 180, // seconds between mute cycles 'mute_duration' => 2, // seconds to stay muted 'connect_timeout' => 10, // socket connect timeout 'read_timeout' => 5, // seconds to wait for an AMI response line 'lockfile' => '/tmp/mute3mins.pid', ]; // Parse CLI arguments $opts = getopt('', [ 'host:', 'port:', 'user:', 'pass:', 'node:', 'interval:', 'mute-duration:', 'lockfile:', ]); if (isset($opts['host'])) $config['host'] = $opts['host']; if (isset($opts['port'])) $config['port'] = (int)$opts['port']; if (isset($opts['user'])) $config['user'] = $opts['user']; if (isset($opts['pass'])) $config['pass'] = $opts['pass']; if (isset($opts['node'])) $config['node'] = $opts['node']; if (isset($opts['interval'])) $config['interval'] = (int)$opts['interval']; if (isset($opts['mute-duration'])) $config['mute_duration'] = (int)$opts['mute-duration']; if (isset($opts['lockfile'])) $config['lockfile'] = $opts['lockfile']; // --------------------------------------------- // Logging helpers // --------------------------------------------- function log_info(string $msg): void { echo '[' . date('Y-m-d H:i:s') . '] INFO ' . $msg . PHP_EOL; } function log_warn(string $msg): void { echo '[' . date('Y-m-d H:i:s') . '] WARN ' . $msg . PHP_EOL; } function log_error(string $msg): void { fwrite(STDERR, '[' . date('Y-m-d H:i:s') . '] ERROR ' . $msg . PHP_EOL); } // --------------------------------------------- // Single-instance enforcement (PID lock file) // --------------------------------------------- /** * Acquire an exclusive lock file containing this process's PID. * * Uses flock() so the lock is automatically released if the PHP process * dies unexpectedly, even if the PID file is not removed. * * Returns the open file handle (must be kept open for the life of the * process), or exits with an error message if another instance is running. */ function acquire_lock(string $lockFile): mixed { $fh = fopen($lockFile, 'c+'); // open/create without truncating if ($fh === false) { log_error("Cannot open lock file '{$lockFile}'. Check permissions."); exit(1); } // Non-blocking exclusive lock if (!flock($fh, LOCK_EX | LOCK_NB)) { // Another process holds the lock - read its PID for the message $existingPid = trim((string)fread($fh, 32)); log_error( "Another instance is already running (PID {$existingPid}). " . "Lock file: {$lockFile}" ); fclose($fh); exit(1); } // We own the lock - write our PID ftruncate($fh, 0); fseek($fh, 0); fwrite($fh, (string)getmypid() . PHP_EOL); fflush($fh); log_info("Lock acquired: {$lockFile} (PID " . getmypid() . ')'); return $fh; } /** * Release the lock and remove the PID file. * Safe to call multiple times. */ function release_lock(mixed &$fh, string $lockFile): void { if (is_resource($fh)) { flock($fh, LOCK_UN); fclose($fh); $fh = null; } if (file_exists($lockFile)) { @unlink($lockFile); } log_info("Lock released: {$lockFile}"); } class AmiSocket { private $sock = null; private float $readTimeout; private int $actionSeq = 1; public function __construct( private string $host, private int $port, private int $connectTimeout, float $readTimeout ) { $this->readTimeout = $readTimeout; } /** Open TCP connection and consume the AMI banner line. */ public function connect(): void { $errNo = 0; $errStr = ''; $this->sock = @fsockopen( $this->host, $this->port, $errNo, $errStr, $this->connectTimeout ); if ($this->sock === false) { throw new RuntimeException( "Cannot connect to {$this->host}:{$this->port} - {$errStr} ({$errNo})" ); } stream_set_timeout($this->sock, (int)$this->readTimeout); // Read and discard the AMI greeting line, e.g. "Asterisk Call Manager/..." $banner = fgets($this->sock, 4096); log_info('AMI banner: ' . trim((string)$banner)); } /** Send a raw AMI action and return the key->value response map. */ public function action(array $fields): array { if (!isset($fields['ActionID'])) { $fields['ActionID'] = 'seq' . ($this->actionSeq++); } $packet = ''; foreach ($fields as $k => $v) { $packet .= "{$k}: {$v}\r\n"; } $packet .= "\r\n"; if (fwrite($this->sock, $packet) === false) { throw new RuntimeException('Write to AMI socket failed.'); } return $this->readResponse(); } /** * Read lines until a blank line (end-of-response). * Returns a key->value map of the first response block. */ public function readResponse(): array { $result = []; while (true) { $line = fgets($this->sock, 4096); if ($line === false) { // Timeout or disconnect break; } $line = rtrim($line, "\r\n"); if ($line === '') { break; // end of this response block } $colon = strpos($line, ':'); if ($colon !== false) { $key = trim(substr($line, 0, $colon)); $val = trim(substr($line, $colon + 1)); $result[$key] = $val; } } return $result; } /** * Drain any unsolicited events sitting in the buffer. * Uses a very short non-blocking peek. */ public function drainEvents(): void { stream_set_timeout($this->sock, 0, 50_000); // 50 ms while (true) { $line = fgets($this->sock, 4096); if ($line === false || $line === '') break; } stream_set_timeout($this->sock, (int)$this->readTimeout); } /** Expose the raw socket for multi-line Command responses. */ public function getSock(): mixed { return $this->sock; } public function isConnected(): bool { return is_resource($this->sock) && !feof($this->sock); } public function close(): void { if (is_resource($this->sock)) { fclose($this->sock); $this->sock = null; } } } // --------------------------------------------- // AMI helper functions // --------------------------------------------- /** Authenticate with AMI. Throws on failure. */ function ami_login(AmiSocket $ami, string $user, string $pass): void { $resp = $ami->action([ 'Action' => 'Login', 'Username' => $user, 'Secret' => $pass, ]); if (strtolower($resp['Response'] ?? '') !== 'success') { throw new RuntimeException( 'AMI login failed: ' . ($resp['Message'] ?? 'unknown error') ); } log_info('AMI login successful.'); } /** * Run an Asterisk CLI command via the AMI Command action. * Returns the raw multi-line output string, or null on failure. * * The AMI Command response looks like: * Response: Success * Output: * Output: * ... * (blank line) * * We collect every "Output:" line and join them with newlines. */ function ami_cli_command(AmiSocket $ami, string $command): ?string { $ami->drainEvents(); // flush any queued events before sending the command $actionId = 'cmd' . mt_rand(1000, 9999); $packet = "Action: Command\r\nActionID: {$actionId}\r\nCommand: {$command}\r\n\r\n"; // Write directly - bypass action() so we can read multi-line Output blocks // (action() stops at the first blank line, missing subsequent Output lines) if (@fwrite($ami->getSock(), $packet) === false) { log_warn("ami_cli_command: write failed for '{$command}'"); return null; } // Read lines until a blank line terminates the response block. // Lines may be: // Response: Success // ActionID: cmdXXXX // Output: ... // (blank) $outputLines = []; $response = ''; $deadline = microtime(true) + 5.0; while (microtime(true) < $deadline) { $line = fgets($ami->getSock(), 4096); if ($line === false) break; // timeout / disconnect $line = rtrim($line, "\r\n"); if ($line === '') break; // end of response block if (str_starts_with($line, 'Response:')) { $response = trim(substr($line, 9)); } elseif (str_starts_with($line, 'Output:')) { $outputLines[] = trim(substr($line, 7)); } } $ami->drainEvents(); if (strtolower($response) !== 'success') { log_warn("ami_cli_command('{$command}') response: '{$response}'"); return null; } return implode("\n", $outputLines); } /** * Parse the output of "dialplan show globals" and return the value of * the named variable, or null if not found. * * Typical output lines look like: * [globals] * VARNAME=value * ANOTHER_VAR=something */ function ami_get_global_var(AmiSocket $ami, string $varName): ?string { $output = ami_cli_command($ami, 'dialplan show globals'); if ($output === null) { log_warn("ami_get_global_var: CLI command returned no output."); return null; } // Search for "VARNAME=value" (case-insensitive key match) foreach (explode("\n", $output) as $line) { $line = trim($line); if ($line === '' || str_starts_with($line, '[')) continue; $eq = strpos($line, '='); if ($eq === false) continue; $key = trim(substr($line, 0, $eq)); $val = trim(substr($line, $eq + 1)); if (strcasecmp($key, $varName) === 0) { log_info("Global {$varName} = '{$val}'"); return $val; } } log_warn("Global variable '{$varName}' not found in dialplan show globals output."); return null; } /** * Read the live RPT_TXKEYED value for a node using "rpt show variables ". * * app_rpt exposes runtime node state via this CLI command. Output looks like: * RPT_TXKEYED=1 * RPT_RXKEYED=0 * ... * * Returns the string value ("0" or "1"), or null if unreadable. */ function ami_get_rpt_variable(AmiSocket $ami, string $node, string $varName): ?string { $output = ami_cli_command($ami, "rpt show variables {$node}"); if ($output === null) { log_warn("ami_get_rpt_variable: 'rpt show variables {$node}' returned no output."); return null; } foreach (explode("\n", $output) as $line) { $line = trim($line); if ($line === '') continue; $eq = strpos($line, '='); if ($eq === false) continue; $key = trim(substr($line, 0, $eq)); $val = trim(substr($line, $eq + 1)); if (strcasecmp($key, $varName) === 0) { log_info("rpt show variables {$node}: {$varName} = '{$val}'"); return $val; } } log_warn("Variable '{$varName}' not found in 'rpt show variables {$node}' output."); return null; } /** * Returns: * true - RPT_TXKEYED is explicitly "1" (TX keyed, run mute cycle) * false - RPT_TXKEYED is explicitly "0" (TX not keyed -> exit trigger) * null - value could not be determined (keep running, skip cycle) * * Uses "rpt show variables " to read the live RPT_TXKEYED state * directly from app_rpt at runtime. */ function get_tx_keyed(AmiSocket $ami, string $node): ?bool { $val = ami_get_rpt_variable($ami, $node, 'RPT_TXKEYED'); if ($val === null) { log_warn("Could not read RPT_TXKEYED for node {$node}; skipping cycle."); return null; } if ($val === '1') return true; if ($val === '0') return false; log_warn("Unexpected RPT_TXKEYED value '{$val}' for node {$node}; skipping cycle."); return null; } /** * Mute or unmute a channel using the MuteAudio AMI action. * Direction: 'in', 'out', or 'all' * State: 'on' or 'off' */ function ami_mute_audio(AmiSocket $ami, string $channel, string $direction, string $state): bool { $resp = $ami->action([ 'Action' => 'MuteAudio', 'Channel' => $channel, 'Direction' => $direction, 'State' => $state, ]); $ami->drainEvents(); $ok = strtolower($resp['Response'] ?? '') === 'success'; if (!$ok) { log_warn("MuteAudio({$channel}, dir={$direction}, state={$state}) failed: " . ($resp['Message'] ?? '?')); } return $ok; } // --------------------------------------------- // Signal handling (Ctrl-C / SIGTERM) // --------------------------------------------- $running = true; $lockFh = null; // set after lock is acquired; used by signal handler if (function_exists('pcntl_signal')) { pcntl_signal(SIGINT, function () use (&$running) { log_info('Caught SIGINT - shutting down …'); $running = false; }); pcntl_signal(SIGTERM, function () use (&$running) { log_info('Caught SIGTERM - shutting down …'); $running = false; }); } function check_signals(): void { if (function_exists('pcntl_signal_dispatch')) { pcntl_signal_dispatch(); } } // --------------------------------------------- // Main // --------------------------------------------- log_info('Starting mute3mins.php'); log_info("Target node : {$config['node']}"); log_info("Interval : {$config['interval']}s"); log_info("Mute duration: {$config['mute_duration']}s"); log_info("Lock file : {$config['lockfile']}"); // -- Single-instance guard ----------------------------------------------------- $lockFh = acquire_lock($config['lockfile']); // Register a shutdown function so the lock/PID file is always cleaned up, // even on fatal errors or unexpected exits. register_shutdown_function(function () use (&$lockFh, $config) { release_lock($lockFh, $config['lockfile']); }); $ami = new AmiSocket( $config['host'], $config['port'], $config['connect_timeout'], $config['read_timeout'] ); try { $ami->connect(); ami_login($ami, $config['user'], $config['pass']); } catch (RuntimeException $e) { log_error($e->getMessage()); exit(1); } // -- Step 1: Read SPACE-P_CHANNEL global variable ----------------------------- log_info("Reading global variable SPACE-P_CHANNEL …"); $channelName = ami_get_global_var($ami, 'SPACE-P_CHANNEL'); if ($channelName === null || $channelName === '') { log_error('Global variable SPACE-P_CHANNEL is empty or unset. Aborting.'); $ami->close(); exit(1); } log_info("CHANNEL = '{$channelName}'"); // -- Main loop ----------------------------------------------------------------- // Every second: check RPT_TXKEYED. // - If 0 -> exit immediately. // - If null -> unreadable, keep waiting. // - If 1 -> node is keyed; mute/unmute only when the interval has elapsed. $nextMuteAt = time() + $config['interval']; // first mute after one full interval while ($running) { check_signals(); if (!$running) break; if (!$ami->isConnected()) { log_warn('AMI socket disconnected. Attempting reconnect …'); try { $ami->connect(); ami_login($ami, $config['user'], $config['pass']); } catch (RuntimeException $e) { log_error('Reconnect failed: ' . $e->getMessage() . ' - retrying in 15s'); sleep(15); continue; } } // -- Check RPT_TXKEYED every second --------------------------------------- // get_tx_keyed() returns: true=keyed(1), false=unkeyed(0), null=unknown try { $txKeyed = get_tx_keyed($ami, $config['node']); } catch (RuntimeException $e) { log_warn('Error reading RPT_TXKEYED: ' . $e->getMessage()); $txKeyed = null; } if ($txKeyed === false) { // RPT_TXKEYED is explicitly 0 - exit as instructed. log_info( "Node {$config['node']} RPT_TXKEYED = 0. " . "Node is no longer TX-keyed. Exiting." ); $running = false; break; } if ($txKeyed === null) { // Value unreadable - wait and try again next second. log_warn("RPT_TXKEYED unreadable for node {$config['node']}; retrying in 1s."); sleep(1); continue; } // -- Node is TX-keyed (RPT_TXKEYED = 1) ----------------------------------- $now = time(); if ($now < $nextMuteAt) { // Interval not yet reached - wait 1 second then re-check. sleep(1); continue; } // Interval elapsed - run the mute/unmute cycle. $nextMuteAt = $now + $config['interval']; log_info("Node {$config['node']} IS TX-keyed. Starting mute cycle on '{$channelName}' …"); // -- Mute ----------------------------------------------------------------- if (ami_mute_audio($ami, $channelName, 'in', 'on')) { log_info("Channel '{$channelName}' MUTED."); } // Wait mute_duration seconds (interruptible) $muteUntil = time() + $config['mute_duration']; while (time() < $muteUntil && $running) { check_signals(); sleep(1); } // -- Unmute --------------------------------------------------------------- if (ami_mute_audio($ami, $channelName, 'in', 'off')) { log_info("Channel '{$channelName}' UNMUTED."); } log_info("Mute cycle complete. Next cycle in {$config['interval']}s."); } // --------------------------------------------- // Shutdown // --------------------------------------------- log_info('Shutting down - sending AMI Logoff …'); try { $ami->action(['Action' => 'Logoff']); } catch (Throwable) { /* ignore */ } $ami->close(); log_info('Done.'); exit(0);