# API Reference

## Factory Functions

### `routeros.dial(address, username, password, *, timeout, source_address, logger)`

Connect and log in to a RouterOS device over **plain TCP**.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `address` | `str` | — | `"host:port"` (e.g. `"192.168.1.1:8728"`) |
| `username` | `str` | — | RouterOS username |
| `password` | `str` | — | RouterOS password (empty string `""` for blank) |
| `timeout` | `float \| None` | `None` | Socket connect/read timeout in seconds |
| `source_address` | `tuple[str, int] \| None` | `None` | Local `(host, port)` to bind before connecting |
| `logger` | `logging.Logger \| None` | `None` | Custom logger (defaults to `logging.getLogger("routeros")`) |

**Returns:** authenticated `Client`
**Raises:** `OSError` on connection failure; `LoginError` on bad credentials

```python
import routeros

c = routeros.dial("192.168.1.1:8728", "admin", "secret", timeout=10.0)
```

---

### `routeros.dial_tls(address, username, password, *, tls_context, timeout, source_address, logger)`

Connect and log in over **TLS/SSL** (RouterOS API-SSL, default port 8729).

| Parameter | Type | Default | Description |
|---|---|---|---|
| `tls_context` | `ssl.SSLContext \| None` | `None` | Custom SSL context; `None` uses `ssl.create_default_context()` |

All other parameters are the same as `dial()`.

```python
import ssl, routeros

ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE   # for self-signed RouterOS cert

c = routeros.dial_tls("192.168.1.1:8729", "admin", "", tls_context=ctx)
```

---

## Client

### `Client.run(*words) → Reply`

Send a sentence and **block** until the device replies with `!done`.

```python
reply = c.run("/ip/address/print")
reply = c.run("/system/identity/set", "=name=MyRouter")
```

### `Client.run_args(words: list[str]) → Reply`

Same as `run()` but accepts a pre-built list.

```python
reply = c.run_args(["/ip/address/print", "?interface=ether1"])
```

### `Client.async_start() → None`

Start a background daemon thread that routes tagged replies.
After calling this, concurrent `run()` calls are safe.

Raises `AlreadyAsyncError` if called more than once.

```python
c.async_start()
r1 = c.run("/ip/address/print")     # concurrent calls now safe
r2 = c.run("/interface/print")
```

### `Client.listen(*words, queue_size=0) → ListenReply`

Send a command and return a `ListenReply` that **streams** `!re` sentences.
Automatically enables async mode if not already active.

```python
listener = c.listen("/ip/firewall/address-list/listen")
for sentence in listener:
    print(sentence.map)
```

### `Client.listen_args(words, *, queue_size=0) → ListenReply`

Same as `listen()` but accepts a list.

### `Client.close() → None`

Close the underlying socket. Called automatically by `__exit__`.

### Context manager

```python
with routeros.dial("192.168.1.1:8728", "admin", "") as c:
    ...   # c.close() called on exit
```

---

## Reply

```python
@dataclass
class Reply:
    re:   list[Sentence]      # all !re sentences (result rows)
    done: Sentence | None     # final !done sentence
```

```python
reply = c.run("/interface/print")
print(len(reply.re))          # number of interfaces
print(reply.re[0].map)        # dict of first interface
print(reply.done)             # final !done sentence
```

---

## ListenReply

Returned by `Client.listen()`.  Implements `__iter__` for convenience.

```python
class ListenReply:
    done: Sentence | None          # set when the stream ends
    err:  Exception | None         # first error, or None

    def chan(self) -> queue.Queue  # the underlying Queue
    def cancel(self) -> Reply      # send /cancel and wait
    def __iter__(self)             # yield Sentence until stream ends
```

### Iterating

```python
for sentence in listener:
    print(sentence.map)
# Loop ends when the stream is cancelled or !done received.
```

### Queue access

```python
q = listener.chan()
while True:
    item = q.get()          # blocks
    if item is None:        # None = end of stream sentinel
        break
    print(item.map)
```

### Cancellation

```python
listener.cancel()       # sends /cancel, waits for !done
```

---

## Sentence

```python
@dataclass
class Sentence:
    word:  str               # e.g. "!re", "!done", "!trap"
    tag:   str               # async correlation tag
    pairs: list[Pair]        # ordered key-value list
    map:   dict[str, str]    # fast key lookup
```

```python
sentence = reply.re[0]
print(sentence.map["address"])      # "192.168.1.1/24"
print(sentence.map.get("comment"))  # None if absent
for pair in sentence.pairs:
    print(pair.key, "=", pair.value)
```

---

## Pair

```python
@dataclass
class Pair:
    key:   str
    value: str
```

---

## Type Casting

### `cast(value: str, to: Type[T]) → T`

Cast a RouterOS string value to a Python type.

| `to` | Behaviour |
|---|---|
| `str` | identity |
| `int` | `int(value)` |
| `float` | `float(value)` |
| `bool` | `"true"`/`"yes"` → `True`; `"false"`/`"no"` → `False` |
| `datetime.timedelta` | parse `"1w2d3h4m5s"` format |
| `list` | split on `","` |
| `bytes` | hex string (strips `:` and `-`) |

```python
from routeros import cast
from datetime import timedelta

mtu     = cast("1500", int)          # 1500
running = cast("true", bool)         # True
uptime  = cast("2d3h4m5s", timedelta)
```

### `cast_optional(value: str, to: Type[T]) → T | None`

Like `cast()` but returns `None` when `value == ""`.

```python
from routeros import cast_optional

x = cast_optional("", int)     # None
y = cast_optional("42", int)   # 42
```

### `typed_map(sentence, schema: dict[str, Type]) → dict`

Cast all matched fields from a sentence using a schema dict.

```python
from routeros import typed_map

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

---

## API Schema Discovery

### `routeros.discover_api(client, *, data_dir, force, logger) → dict`

Walk the full RouterOS API tree and persist the result as a JSON schema file.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `client` | `Client` | — | A connected, logged-in `Client` |
| `data_dir` | `str \| Path \| None` | `data/mikrotik/api/` | Output directory for cached JSON files |
| `force` | `bool` | `False` | Re-discover even if a cached file exists |
| `logger` | `logging.Logger \| None` | `None` | Custom logger |

**Returns:**

```python
{
    "version":        str,   # e.g. "7.20.8"
    "generated_at":   str,   # ISO 8601 timestamp
    "endpoint_count": int,   # number of navigable dir/path nodes
    "tree":           dict,  # nested inspect.json-compatible tree
}
```

**Raises:** `RouterOSError` if the device version cannot be determined.

```python
import routeros

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

# Navigate the nested tree
add = schema["tree"]["ip"]["address"]["add"]
for name, node in add.items():
    if name != "_type":
        print(name, "→", node.get("desc", ""))
```

See [`introspect.md`](introspect.md) for the full schema format, discovery
strategies, and advanced usage.

---

### `routeros.load_api_schema(version, *, data_dir) → dict | None`

Load a previously saved schema from disk without a live connection.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `version` | `str` | — | RouterOS version string, e.g. `"7.20.8"` |
| `data_dir` | `str \| Path \| None` | `data/mikrotik/api/` | Directory to search |

**Returns:** schema `dict` or `None` if the file does not exist.

```python
schema = routeros.load_api_schema("7.20.8")
if schema:
    print(schema["endpoint_count"])
```

---

## Errors

| Exception | When raised |
|---|---|
| `RouterOSError` | Base class for all library errors |
| `DeviceError` | Device responded with `!trap` or `!fatal` |
| `UnknownReplyError` | Unknown sentence word received |
| `LoginError` | Authentication failed (base) |
| `NoChallengeReceivedError` | No `ret` field in login response |
| `InvalidChallengeReceivedError` | `ret` is not valid hex |
| `AlreadyAsyncError` | `async_start()` called twice |
| `AsyncLoopEndedError` | Async loop died before request completed |

```python
from routeros import DeviceError, RouterOSError

try:
    c.run("/bad/command")
except DeviceError as e:
    print(e.message)       # human-readable message from device
    print(e.sentence.map)  # full sentence fields
```
