# routeros-py

[![PyPI version](https://badge.fury.io/py/routeros-py.svg)](https://pypi.org/project/routeros-py/)
[![Python](https://img.shields.io/pypi/pyversions/routeros-py)](https://pypi.org/project/routeros-py/)
[![CI](https://github.com/Butterfly-Student/routeros-py/actions/workflows/python-ci.yml/badge.svg)](https://github.com/Butterfly-Student/routeros-py/actions/workflows/python-ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

A pure-Python client library for **Mikrotik RouterOS** devices using the RouterOS API binary protocol.

Python port of the Go library [`github.com/go-routeros/routeros/v3`](https://github.com/go-routeros/routeros).

---

## Features

- **Python type casting** — parse RouterOS string values to `int`, `bool`, `float`, `timedelta`, and more
- **Key/value dictionary** — every sentence exposes a `.map` dict for fast field access
- **Source address / port binding** — bind the client socket to a specific local interface
- **TLS/SSL encryption** — connect via `ssl.SSLContext` (port 8729)
- **Logging support** — integrates with Python's standard `logging` module
- **Synchronous mode** — simple blocking `run()` calls
- **Asynchronous mode** — tagged concurrent requests via a background thread
- **Listener / streaming** — subscribe to real-time device events via a `Queue`
- **Context manager** — use `with dial(...) as c:` for automatic cleanup
- **RouterOS version detection** — auto-detects pre-6.43 (MD5) vs post-6.43 (cleartext) auth
- **API schema discovery** — walk the full RouterOS API tree and save it as a structured JSON schema

---

## Installation

```bash
# With uv (recommended)
uv add routeros-py

# With pip
pip install routeros-py
```

**Requirements:** Python ≥ 3.10

---

## Quick Start

```python
import routeros

# Connect and run a command
with routeros.dial("192.168.1.1:8728", "admin", "") as c:
    reply = c.run("/ip/address/print")
    for sentence in reply.re:
        print(sentence.map["address"], "→", sentence.map["interface"])
```

---

## Usage Examples

### Synchronous (default)

```python
import routeros

with routeros.dial("192.168.1.1:8728", "admin", "secret") as c:
    # Print all IP addresses
    reply = c.run("/ip/address/print")
    for s in reply.re:
        print(s.map)

    # Filter by interface
    reply = c.run("/ip/address/print", "?interface=ether1")
    for s in reply.re:
        print(s.map["address"])
```

### Asynchronous (concurrent requests)

```python
import threading
import routeros

with routeros.dial("192.168.1.1:8728", "admin", "") as c:
    c.async_start()

    results = []

    def fetch(cmd):
        r = c.run(cmd)
        results.append(r)

    t1 = threading.Thread(target=fetch, args=("/ip/address/print",))
    t2 = threading.Thread(target=fetch, args=("/interface/print",))
    t1.start(); t2.start()
    t1.join(); t2.join()

    for r in results:
        for s in r.re:
            print(s.map)
```

### Listener (streaming events)

```python
import routeros

with routeros.dial("192.168.1.1:8728", "admin", "") as c:
    listener = c.listen("/ip/firewall/address-list/listen")

    for sentence in listener:          # blocks until stream ends
        print("Event:", sentence.map)

    # Or cancel after a timeout:
    # listener.cancel()
```

### TLS / SSL

```python
import ssl
import routeros

ctx = ssl.create_default_context()
ctx.load_verify_locations("ca.pem")       # or ctx.check_hostname = False

with routeros.dial_tls("192.168.1.1:8729", "admin", "", tls_context=ctx) as c:
    reply = c.run("/system/identity/print")
    print(reply.re[0].map["name"])
```

### Source Address / Port Binding

```python
import routeros

with routeros.dial(
    "192.168.1.1:8728", "admin", "",
    source_address=("10.0.0.5", 0)   # host, port (0 = ephemeral)
) as c:
    ...
```

### Type Casting

```python
import routeros
from routeros import cast, cast_optional, typed_map
from datetime import timedelta

with routeros.dial("192.168.1.1:8728", "admin", "") as c:
    reply = c.run("/interface/print")
    for s in reply.re:
        mtu     = cast(s.map["mtu"],     int)    # → 1500
        running = cast(s.map["running"], bool)   # → True / False

        row = typed_map(s, {
            "name":    str,
            "mtu":     int,
            "running": bool,
            "rx-byte": int,
        })
        print(row)
```

### API Schema Discovery

Discover the full RouterOS API tree and save it as a structured JSON schema.
The output format mirrors the MikroTik REST-API `inspect.json` — a nested tree
of `_type: "dir"`, `"cmd"`, and `"arg"` nodes with parameter descriptions.

```python
import routeros

with routeros.dial("192.168.1.1:8728", "admin", "secret") as c:
    schema = routeros.discover_api(c)

print(schema["version"])         # e.g. "7.20.8"
print(schema["endpoint_count"])  # e.g. 541

# Navigate the nested tree
ip_addr = schema["tree"]["ip"]["address"]
print(ip_addr["_type"])          # "dir"

add_cmd = ip_addr["add"]
print(add_cmd["_type"])          # "cmd"
print(add_cmd["address"])        # {"_type": "arg", "desc": "A.B.C.D ..."}
```

The schema is saved to `data/mikrotik/api/routeros_<version>.json` automatically
and reloaded from disk on subsequent calls (no re-discovery needed).

```python
# Load a previously saved schema without a live connection
schema = routeros.load_api_schema("7.20.8")
if schema:
    print(schema["tree"]["ip"]["firewall"])
```

#### Schema JSON structure

```json
{
  "version": "7.20.8",
  "generated_at": "2026-03-21T14:00:00+00:00",
  "endpoint_count": 541,
  "tree": {
    "ip": {
      "_type": "dir",
      "address": {
        "_type": "dir",
        "add": {
          "_type": "cmd",
          "address":   {"_type": "arg", "desc": "A.B.C.D    (IP address)"},
          "interface": {"_type": "arg", "desc": "string value"},
          "disabled":  {"_type": "arg"}
        },
        "print": {
          "_type": "cmd",
          "brief":         {"_type": "arg"},
          "detail":        {"_type": "arg"},
          "count-only":    {"_type": "arg"},
          "without-paging":{"_type": "arg"}
        },
        "remove": {
          "_type": "cmd",
          "numbers": {"_type": "arg", "desc": "see documentation"}
        }
      }
    }
  }
}
```

#### Discovery strategies

1. **REST-API `inspect.json`** (preferred) — if pre-downloaded MikroTik REST-API
   files exist in `data/mikrotik/rest-api/<version>/`, they are loaded and merged
   (`inspect.json` + `extra/inspect.json`) for the richest result.
   Covers RouterOS 7.9 – 7.22+.

2. **Binary-API BFS** (fallback) — walks the live router's `/console/inspect`
   endpoint with comma-separated path components, querying `request=syntax` per
   command to obtain parameter names and descriptions.

#### Force re-discovery

```python
# Ignore the cached file and re-discover from the live device
schema = routeros.discover_api(c, force=True)
```

#### Custom output directory

```python
from pathlib import Path

schema = routeros.discover_api(c, data_dir=Path("/tmp/routeros-schemas"))
```

### Logging

```python
import logging
import routeros

logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger("routeros")      # library logger

with routeros.dial("192.168.1.1:8728", "admin", "", logger=log) as c:
    c.run("/system/resource/print")
```

---

## API Reference

See [`docs/api-reference.md`](docs/api-reference.md) for the full API.

| Symbol | Description |
|---|---|
| `dial(address, user, password, ...)` | Connect via plain TCP |
| `dial_tls(address, user, password, ...)` | Connect via TLS/SSL |
| `Client.run(*words)` | Send command, wait for reply |
| `Client.run_args(words)` | Same with list argument |
| `Client.async_start()` | Enable async / concurrent mode |
| `Client.listen(*words)` | Start streaming listener |
| `Reply.re` | List of `!re` sentences |
| `Reply.done` | Final `!done` sentence |
| `ListenReply` | Iterable streaming reply |
| `Sentence.map` | `dict[str, str]` of all fields |
| `cast(value, type)` | Cast a single RouterOS value |
| `cast_optional(value, type)` | Cast, returning `None` for `""` |
| `typed_map(sentence, schema)` | Cast all fields from a schema dict |
| `discover_api(client, ...)` | Walk & cache the full RouterOS API schema |
| `load_api_schema(version, ...)` | Load a previously saved schema from disk |

---

## Development

### Requirements

- Python ≥ 3.10
- [uv](https://docs.astral.sh/uv/)

### Setup

```bash
git clone https://github.com/Butterfly-Student/routeros-py
cd routeros-py/routeros

uv sync
```

### Running Tests

```bash
# Unit tests only (no device needed)
uv run pytest tests/unit/ -v

# With coverage
uv run pytest tests/unit/ --cov=routeros --cov-report=term-missing

# Integration tests (requires .env with device credentials)
cp .env.example .env
# Edit .env with your RouterOS host/credentials
uv run pytest tests/integration/ -v -m integration

# All tests
uv run pytest
```

### Docker (RouterOS test environment)

```bash
cd docker
cp .env.example .env        # edit if needed
docker compose up -d
# Wait ~60 s for RouterOS to boot, then run integration tests
uv run pytest tests/integration/ -v
```

---

## Environment Variables

Copy `.env.example` to `.env` and fill in your values:

```env
ROUTEROS_HOST=192.168.1.1
ROUTEROS_PORT=8728
ROUTEROS_USER=admin
ROUTEROS_PASSWORD=
ROUTEROS_TLS_PORT=8729
ROUTEROS_TLS_VERIFY=false
```

---

## License

MIT — see [LICENSE](LICENSE).
