lossik / device-communication
Transport-independent contract for RouterOS clients: commands, queries, results, a client API and typed exceptions
Requires
- php: ^8.2
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The transport-independent contract of the RouterOS clients lossik/mikrotik-api
and lossik/mikrotik-ssh: a command (Command, Query, Result), the
transport interfaces (Transport, StreamingTransport), the user API (Client, Menu) and typed exceptions. It
does not talk to any device on its own.
3.0 is in development (branch feature/3.0, unreleased). The 3.0 transports of the clients are not released
yet; see the changelog. The released line is 2.x (maintenance only).
The contract
The contract is a RouterOS command, not a socket: a menu, a verb, attributes, an optional query and an optional property list. A transport turns it into whatever its protocol needs and returns the rows. That level also fits a transport that has no socket of its own: a REST transport is planned as a separate package on top of this one; it is not part of this package.
| class | role |
|---|---|
Command(string $menu, string $verb, array $attributes = [], ?Query $query = null, array $proplist = []) |
a value object. The menu is normalised ('ip address' and '/ip/address' are both '/ip/address'); the ID of an item goes to attributes['.id'] |
Query |
a tree of conditions, built with where, has, hasNot, less, greater, and, or, not; the node kind is QueryOperator. Query compiles nothing: each transport translates the tree |
Result(array $rows, ?string $ret = null) |
rows: a list of string => string rows; ret: the value of =ret=, the ID of an added item |
Transport |
execute(Command): Result, close() (idempotent), isOpen() |
StreamingTransport extends Transport |
adds stream(Command, callable $onRow, ?float $idleTimeout = null) for long-running commands (listen, monitor); $onRow returns false to cancel |
Client, Menu |
the user API over any Transport: $client->menu('/ip/address')->print(...) |
The calls below are an overview, not a script: $transport is any Transport (the quick start of
lossik/mikrotik-api runs them over its ApiTransport); a complete transport with a runnable example follows in
Writing a transport.
use Lossik\DeviceCommunication\Client; use Lossik\DeviceCommunication\Query; $client = new Client($transport); // any Transport $rows = $client->menu('/ip/address')->print(Query::where('interface', 'ether1'), proplist: ['.id', 'address']); $row = $client->menu('/queue/tree')->first(Query::where('name', 'q1')); // ?array, null = nothing matches $id = $client->menu('/ip/arp')->add(['address' => '192.0.2.2', 'interface' => 'ether1']); // ?string: the new ID $n = $client->menu('/ip/firewall/nat')->update(Query::where('comment', 'x'), ['disabled' => 'yes'], onlyFirst: true); $n = $client->menu('/ppp/secret')->delete(Query::where('name', 'u1')); // int: the number of items $client->menu('/system/identity')->run('set', ['name' => 'R1']); // any verb, returns the Result $version = $client->version(); // ?string $client->menu('/interface')->listen(fn (array $row): bool => true, idleTimeout: 120.0); // StreamingTransport only
update() and delete() first print the .id of the matching items and then send one set or remove with
all of them (.id=*1,*2); with onlyFirst: true only the first one. When nothing matches, nothing is sent and
they return 0. listen() on a transport that is not a StreamingTransport throws UnsupportedOperationException
before anything is sent.
Query
| node | RouterOS API form (as mikrotik-api sends it) |
|---|---|
where('k', 'v'), has('k'), hasNot('k') |
?k=v, ?k, ?-k |
less('k', 'v'), greater('k', 'v') |
?<k=v, ?>k=v |
and(a, b, ...) |
the operands, then ?#& for each operand after the first |
or(a, b, ...) |
the operands, then ?#| for each operand after the first |
not(a) |
the operand, then ?#! |
There is no prefix or regular-expression condition (the API cannot express one): filter the rows in PHP. A
transport that cannot translate a node throws UnsupportedOperationException.
Exceptions
CommunicationException (interface; every runtime error of a transport)
|- ConnectionException the transport is closed after it
| |- TimeoutException ->limit: which limit ran out, named as the transport's option
| |- FatalException ->reason (the device ended the session)
| '- ProtocolException the device sent something the protocol does not allow
|- AuthenticationException wrong user name or password
'- TrapException the device refused the command: ->category (?int), ->traps()
\UnsupportedOperationException a \LogicException, NOT a CommunicationException
Every CommunicationException extends \RuntimeException, so catch (\Exception) catches them too. The numeric
error codes of 2.x are gone; tell the errors apart by class. UnsupportedOperationException is a programming error
(an operation the transport cannot do, such as listen over a transport that cannot stream); it is thrown before
anything is sent and does not close the transport.
Writing a transport
Implement Transport (and StreamingTransport if the protocol can stream). The rules the clients rely on:
- After a
ConnectionExceptionor a subclass the transport is closed, and every further call throwsConnectionExceptionwithout any I/O. ATrapExceptiondoes not close it: read the response to its end first.close()is idempotent;isOpen()tells the state. - For type-valid input
execute()throws no\Error. Input the transport cannot encode (a value its charset cannot represent) may be an\InvalidArgumentExceptionbefore any I/O; every other failure is aCommunicationException. - A
Querynode the transport cannot translate is anUnsupportedOperationException, never a wrong query. stream()calls$onRowfor every row and cancels the command when it returnsfalse; it ends withTimeoutException(limit'idleTimeout') when the device is silent for$idleTimeoutseconds (null= no limit).- Later needs get a new optional interface (as
StreamingTransportdid), not a change ofTransport.
A complete transport that keeps its tables in memory (it answers print with a plain where):
use Lossik\DeviceCommunication\Client; use Lossik\DeviceCommunication\Command; use Lossik\DeviceCommunication\ConnectionException; use Lossik\DeviceCommunication\Query; use Lossik\DeviceCommunication\QueryOperator; use Lossik\DeviceCommunication\Result; use Lossik\DeviceCommunication\Transport; use Lossik\DeviceCommunication\UnsupportedOperationException; final class MemoryTransport implements Transport { private bool $open = true; /** @param array<string, list<array<string, string>>> $tables menu => rows */ public function __construct(private array $tables) {} public function execute(Command $command): Result { $q = $command->query; if (!$this->open) { throw new ConnectionException('The transport is closed'); } if ($command->verb !== 'print' || ($q !== null && $q->operator !== QueryOperator::Where)) { throw new UnsupportedOperationException('Only print with a plain where is supported'); } $rows = array_filter($this->tables[$command->menu] ?? [], fn (array $r): bool => $q === null || ($r[$q->key ?? ''] ?? null) === $q->value); return new Result(array_values($rows)); } public function close(): void { $this->open = false; } public function isOpen(): bool { return $this->open; } } $transport = new MemoryTransport(['/ip/address' => [ ['.id' => '*1', 'address' => '192.0.2.1/24', 'interface' => 'ether1'], ['.id' => '*2', 'address' => '198.51.100.1/24', 'interface' => 'ether2'], ]]); $client = new Client($transport); $rows = $client->menu('ip address')->print(Query::where('interface', 'ether2')); // the second row only $none = $client->menu('/ip/address')->update(Query::where('interface', 'ether9'), ['disabled' => 'yes']); // 0 try { $client->menu('/interface')->listen(fn (array $row): bool => false); } catch (UnsupportedOperationException) { $canStream = false; // MemoryTransport is not a StreamingTransport: nothing was sent } $transport->close(); try { $client->version(); } catch (ConnectionException) { $closed = !$transport->isOpen(); // the transport is closed: no I/O, ConnectionException }
tests/FakeTransport.php is a scripted test double of this package's own suite; it is not part of the installed
package.
Supported PHP
Only patched PHP releases are supported: 8.2.35+, 8.3.36+, 8.4.27+ and 8.5.12+ (CVE-2026-109618 and
CVE-2026-109619, "PHP2Shell"). composer.json requires "php": "^8.2", so Composer also installs the package on
an unpatched PHP: Linux distributions backport the fixes without changing the version number, and Composer cannot
tell the two apart. Only the CI of this project enforces the patched minima. The PHP minimum is raised only in a
minor release, announced in the changelog.
License
MIT, see LICENSE.