Metadata-Version: 2.4
Name: ironflock
Version: 1.10.0
Summary: IronFlock Python SDK for connecting to the IronFlock Platform
License-Expression: MIT
License-File: LICENSE
Author: Marko Petzold, IronFlock GmbH
Author-email: info@ironflock.com
Requires-Python: >=3.11
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Provides-Extra: dev
Provides-Extra: docs
Requires-Dist: autobahn[asyncio,serialization] (==25.12.2)
Requires-Dist: mypy ; extra == "dev"
Requires-Dist: pydantic (>=2.0.0)
Requires-Dist: pydantic ; extra == "dev"
Requires-Dist: pytest ; extra == "dev"
Requires-Dist: pytest-asyncio ; extra == "dev"
Requires-Dist: pytest-cov ; extra == "dev"
Requires-Dist: ruff ; extra == "dev"
Requires-Dist: sphinx (>=8.2.3) ; extra == "docs"
Requires-Dist: sphinx-autodoc-typehints (>=2.0) ; extra == "docs"
Requires-Dist: sphinx-book-theme (>=1.1.4) ; extra == "docs"
Project-URL: Homepage, https://github.com/IronFlock/sdk-py
Project-URL: Repository, https://github.com/IronFlock/sdk-py
Description-Content-Type: text/markdown

# ironflock

![Coverage](https://img.shields.io/badge/coverage-73%25-yellow)

## About

With this library you can publish data from your apps on your IoT edge hardware to the fleet data storage of the [IronFlock](https://studio.ironflock.com) devops platform.
When this library is used on a certain device the library automatically uses the private messaging realm (Unified Name Space)
of the device's fleet and the data is collected in the respective fleet database.

So if you use the library in your app, the data collection will always be private to the app user's fleet.

For more information on the IronFlock IoT Devops Platform for engineers and developers visit our [IronFlock](https://www.ironflock.com) home page.

## Requirements

- Python 3.11 or higher (the pinned autobahn 25.12.2 requires it)

## Installation

Install from PyPI:

```shell
pip install ironflock
```
## Usage

```python
import asyncio
from ironflock import IronFlock

# create an IronFlock instance to connect to the IronFlock platform data infrastructure.
# The IronFlock instance handles authentication when run on a device registered in IronFlock.
ironflock = IronFlock()

async def main():
    while True:
        # publish an event (waits up to the reconnect window, 60 s by default, for a connection, then raises RuntimeError)
        publication = await ironflock.publish("test.publish.example", {"temperature": 20})
        print(publication)
        await asyncio.sleep(3)


if __name__ == "__main__":
    ironflock = IronFlock(mainFunc=main)
    ironflock.run()
```

## Options

The `IronFlock` `__init__` function can be configured with the following options:

```python
IronFlock(serial_number=None, mainFunc=None, ironFlockUrl=None, reconnect_window=60)
```

**serial_number**: The device serial number, sent as `DEVICE_SERIAL_NUMBER` in the metadata of publications and table writes. Defaults to the `DEVICE_SERIAL_NUMBER` environment variable; a given `serial_number` takes precedence over it. When neither is there, `IronFlock()` raises `Exception` (`ENV Variable 'DEVICE_SERIAL_NUMBER' is not set!`). It does not change the identity the connection authenticates with while the device agent injects a per-app credential (`APP_AUTH_ID`/`APP_AUTH_SECRET`, which every current agent does): the serial number is the credential only in the legacy fallback, when no per-app credential was injected (e.g. under an older agent).

**mainFunc**: The app's main function, an async function that takes no arguments. `start()` runs it as a task once the connection is established, and `run()`/`run_async()` stop the connection when it returns or raises (see [Lifecycle Methods](#lifecycle-methods)).

**ironFlockUrl**: The router's WebSocket URL, e.g. `"wss://cbw.ironflock.com/ws-ua-usr"`. Without it the SDK takes `DEVICE_ENDPOINT_URL` (which the device agent sets) with its path replaced by `/ws-ua-usr`, else the router of the studio named in `RESWARM_URL` (`https://studio.ironflock.com` gives `wss://cbw.ironflock.com/ws-ua-usr`), else `wss://cbw.ironflock.com/ws-ua-usr`. A `RESWARM_URL` the SDK does not know makes `start()` raise `RuntimeError`; set `DEVICE_ENDPOINT_URL` or `ironFlockUrl` then.

**reconnect_window** (seconds, default `60`, exported as `DEFAULT_RECONNECT_WINDOW`): how long a table operation or a publish rides out a platform restart before it raises, so an app keeps running through a router or platform update. The table operations — `append_to_table`, `append_rows_to_table`, `publish_to_table`, `publish_rows_to_table`, `report_error`, `getHistory`, `get_series_history`, `reveal_secrets`, `verify_secret`, a consumed app's history and series reads, and the read with which a table subscription catches up after a reconnect (see `subscribe_to_table`) — wait that long for a connection, also when issued before `start()`: they wait for `start()` within the same window. They also retry, within the window, a call the data backend does not serve yet, so no row is lost while the data backend restarts (the table publishes are appends, see `publish_to_table`). `publish`, `subscribe`, `subscribe_to_table` (a consumed app's too) and `register_device_function` wait that long for a connection too, and are not retried. A call the data backend serves for no table of that name — `publish_to_table` or `getHistory` of an undeclared or misspelled table, say, or `reveal_secrets` of a table without a secret column — gets the same answer as during a restart (`wamp.error.no_such_procedure`), so its error comes only after the window. `0` turns the window off: these operations then behave as every other operation (see [Connection reliability](#connection-reliability)) — they wait 10 s for a connection and are not retried. A catch-up right after a platform restart then gets no retry. `IronFlock()` raises `ValueError` for a negative value, NaN or an infinity, and `TypeError` for a value that is not a number, `None` included (leave the option out for the default).

The device agent injects the app's identity and settings as environment variables and keeps a copy of them up to date in `/data/env` while the app runs, one `<NAME>.txt` file per value (`IRONFLOCK_ENV_DIR` names another directory). The SDK reads two kinds of values there first, the environment variable being the fallback: the per-app credential (`APP_AUTH_ID`, `APP_AUTH_SECRET`) and the remote-access ports (see `getRemoteAccessUrlForPort`). Everything else — `DEVICE_SERIAL_NUMBER`, `DEVICE_KEY`, `DEVICE_NAME`, `APP_NAME`, `SWARM_KEY`, `APP_KEY`, `ENV`, `TUNNEL_DOMAIN`, `CLOUD_TUNNEL_DOMAIN`, `INSTANCE_KEY` and the router URL variables — comes from the environment the container started with. The agent makes `/data/env` readable by root only: in a container that runs as another user the SDK sees the values the container started with — no changed remote-access port, no credential rotated since — and logs a warning (`logging`, once per process). The per-app credential is read again for every connection attempt — of the app's own connection and of its consumed-app connections — so a credential the agent rotates while the app runs is presented on the next reconnect.

`IronFlock()` only warns (`UserWarning`) when `DEVICE_KEY`, `APP_NAME`, `SWARM_KEY` or `APP_KEY` is not set. The table writes and `subscribe_to_table` then raise `Exception` (`SWARM_KEY not set in environment variables!`, or the same for `APP_KEY`), and the device functions `ValueError`.

## API Reference

`SubscribeOptions`, `CallOptions` and `RegisterOptions` below are autobahn's (`from autobahn.wamp.types import SubscribeOptions`). The handles `subscribe`, `subscribe_to_table` and `register_device_function` return — `Subscription`, `TableSubscription` and `Registration` — are exported from `ironflock`.

### `publish(topic, *args, **kwargs)`

Publishes an event to a topic on the IronFlock message router.

```python
publication = await ironflock.publish("com.myapp.mytopic", {"temperature": 20})
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `topic` | `str` | The URI of the topic to publish to |
| `*args` | positional | Payload arguments |
| `**kwargs` | keyword | Payload keyword arguments |

It waits up to the reconnect window (60 s by default, see [Options](#options)) for a connection, so a publish rides out a short outage, and is then sent once, never retried. The router acknowledges an event nobody subscribes to: subscribers that are down meanwhile miss it.

**Returns:** `Publication` — The publication object (an acknowledged publish receipt).

**Raises:** `ValueError` on invalid parameters; `RuntimeError` with a descriptive message if the publish fails (e.g. no connection within the reconnect window).

---

### `publish_to_table(tablename, *args, **kwargs)`

Writes a row to a fleet table in the IronFlock platform. It is the same as [`append_to_table`](#append_to_tabletablename-args-kwargs): it calls the table's append procedure `append.<SWARM_KEY>.<APP_KEY>.<tablename>`, built from the injected environment variables, so it rides out a platform restart — it waits up to the reconnect window for the connection and retries while the data backend does not serve the table yet — and it raises when the data backend refuses the row. Read the rows back with `getHistory` or `subscribe_to_table`.

> ironflock-py 1.9.1 and earlier published the row to the topic `<SWARM_KEY>.<APP_KEY>.<tablename>` instead: a row published while the data backend restarted was lost, and a refused row was dropped without an error. Now such a row raises `RuntimeError` — a row without `tsp`, or a table missing from the data template (after the reconnect window).

```python
from datetime import datetime, timezone

await ironflock.publish_to_table("sensordata", {
    "tsp": datetime.now(timezone.utc).isoformat(),
    "temperature": 22.5,
    "humidity": 60,
})
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `tablename` | `str` | The name of the table, e.g. `"sensordata"`: letters, digits, `-` and `_`, at most 100 characters (the four table write methods raise `ValueError` for another name) |
| `*args` | positional | Row data to publish — the data backend reads the row from the first argument |
| `**kwargs` | keyword | Extra keyword arguments; the data backend reads them only for columns the data template maps to `kwargs.<name>` |

**Every row needs a `tsp`.** Every table has a mandatory `tsp` column, the row's timestamp, which the data backend reads from the row (`args[0].tsp`, unless the data template maps it elsewhere). It refuses a row without one, as described under `append_to_table`.

**Partial rows on entity tables.** On a table with an entity key (`maintainLatestFlagFor` in the data template), a row may carry just the entity key, `tsp` and the columns that change: a column the row leaves out keeps the entity's previous value, and a key whose value is `None` is sent as null and clears the column. To keep a value, remove the key from the dict rather than setting it to `None`. This holds for all four table write methods. On a table without an entity key, a left-out column is stored as `NULL`.

**Returns:** `Any` — The result of the append procedure, as for `append_to_table`.

**Raises:** `ValueError` on invalid parameters; `RuntimeError` as `append_to_table` raises it, its message beginning `Publish to table '<tablename>'`.

---

### `append_to_table(tablename, *args, **kwargs)`

Appends data to a fleet table by calling the registered append procedure at `append.<SWARM_KEY>.<APP_KEY>.<tablename>`. A refused row is an error. `publish_to_table` does the same.

```python
await ironflock.append_to_table("sensordata", {
    "tsp": datetime.now(timezone.utc).isoformat(),
    "temperature": 22.5,
    "humidity": 60,
})
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `tablename` | `str` | The name of the table, e.g. `"sensordata"` |
| `*args` | positional | Row data to append — the data backend reads the row from the first argument |
| `**kwargs` | keyword | Extra keyword arguments, as for `publish_to_table` |

The row needs a `tsp`, as for `publish_to_table`. The data backend refuses a row without one with `wamp.error.runtime_error` and no reason; the raised error then adds *"the data backend refused the row without a reason — a missing tsp is the usual cause; see the error-logs table"*. The typed refusals are listed under [Data backend refusals](#data-backend-refusals) — `URI_STORAGE_FULL`, for one, is temporary: append the row later.

**Returns:** `Any` — The result of the remote procedure call.

**Raises:** `ValueError` on invalid parameters; `RuntimeError` with a descriptive message if the call fails (e.g. not connected, the procedure is not registered — only after the reconnect window, see [Options](#options) — or the row is refused).

---

### `publish_rows_to_table(tablename, rows, **kwargs)`

Writes **many rows in a single message** (bulk insert). The platform inserts the whole batch atomically (all-or-nothing) in one operation. Use this for high-frequency data where one round-trip per row is too costly. It is the same as [`append_rows_to_table`](#append_rows_to_tabletablename-rows-kwargs): it calls the bulk procedure `appendBulk.<SWARM_KEY>.<APP_KEY>.<tablename>`, rides out a platform restart as `publish_to_table` does, and raises when the data backend refuses the batch. Every row needs its own `tsp`. (ironflock-py 1.9.1 and earlier published the batch to the topic `bulk.<SWARM_KEY>.<APP_KEY>.<tablename>`, where a refused batch was dropped without an error.)

```python
await ironflock.publish_rows_to_table("sensordata", [
    {"tsp": "2024-01-15T10:30:00.000Z", "temperature": 22.5},
    {"tsp": "2024-01-15T10:30:01.000Z", "temperature": 22.7},
])
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `tablename` | `str` | The name of the table, e.g. `"sensordata"` |
| `rows` | `List[dict]` | Non-empty list of row dicts to insert |
| `**kwargs` | keyword | Extra arguments shared by the whole batch |

**Returns:** `Any` — The result of the bulk procedure (e.g. `{"success": True, "count": N}`).

**Raises:** `ValueError` on invalid parameters; `RuntimeError` as `append_rows_to_table` raises it, its message beginning `Bulk publish of <n> row(s) to table '<tablename>'`.

---

### `append_rows_to_table(tablename, rows, **kwargs)`

Appends **many rows in a single RPC** (bulk insert) by calling the dedicated procedure `appendBulk.<SWARM_KEY>.<APP_KEY>.<tablename>`. The platform inserts the whole batch atomically (all-or-nothing): if any row is invalid the entire batch is rejected and nothing is persisted. `publish_rows_to_table` does the same. Every row needs its own `tsp`; the refusals are those of `append_to_table` (a batch refused without a reason gets the same hint, for rows).

```python
result = await ironflock.append_rows_to_table("sensordata", [
    {"tsp": "2024-01-15T10:30:00.000Z", "temperature": 22.5},
    {"tsp": "2024-01-15T10:30:01.000Z", "temperature": 22.7},
])
# result -> {"success": True, "count": 2}
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `tablename` | `str` | The name of the table, e.g. `"sensordata"` |
| `rows` | `List[dict]` | Non-empty list of row dicts to insert |
| `**kwargs` | keyword | Extra arguments shared by the whole batch |

**Returns:** `Any` — The result of the remote procedure call (e.g. `{"success": True, "count": N}`).

**Raises:** `ValueError` on invalid parameters; `RuntimeError` with a descriptive message if the bulk append fails (all-or-nothing — no rows were persisted).

---

### `report_error(error, level="error", append=False, tsp=None, user_message=None)`

Reports an application error into the fleet's `error-logs` table. This is a convenience wrapper over `publish_to_table` / `append_to_table`: it stamps the row with `source="app"`, a severity `level`, and a timestamp, then writes it like any normal table row. The error lands in the same per-databackend `error-logs` table that fleetdb system errors use (tagged `source="system"`), so it is queryable with `getHistory`, streamable with `subscribe_to_table`, usable in board-templates, and delivered in realtime on `transformed.error-logs` — without firing the platform's system-error toast.

```python
# Writes a row to the error-logs table
await ironflock.report_error("Sensor read timed out", level="warning")

# Pass an Exception to capture its traceback (falls back to the message)
try:
    risky_operation()
except Exception as err:
    await ironflock.report_error(err)

# Give the operators a display text of its own (the toast boards show)
await ironflock.report_error("Calibration failed", level="error",
                             user_message="Please recalibrate")
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `error` | `str \| BaseException` | The error message, or an exception whose traceback (or message) is recorded |
| `level` | `str`, optional | Severity: `"error"`, `"warning"`, `"info"` or `"debug"`, the types the board's toast widget shows. Defaults to `"error"`. `"warn"`, the spelling of ironflock-py 1.9.1 and earlier, is sent as `"warning"` |
| `append` | `bool`, optional | Kept for compatibility: the row is appended either way, as `publish_to_table` and `append_to_table` both append. It only picks which of the two names the error message |
| `tsp` | `str`, optional | ISO-8601 timestamp override. Defaults to the current time |
| `user_message` | `str`, optional | Operator-facing display text, written to the row's `user_message` column, which boards bind their toast widget to. Defaults to the error message |

**Returns:** `Any` — The result of the append procedure.

**Raises:** `RuntimeError` with a descriptive message if the append fails (e.g. not connected). It rides out a platform restart as `append_to_table` does.

---

### `subscribe(topic, handler, options=None)`

Subscribes to a topic on the IronFlock message router.

```python
def on_message(*args, **kwargs):
    print("Received:", args, kwargs)

subscription = await ironflock.subscribe("com.myapp.mytopic", on_message)

# later: the handler is called for no further event
await subscription.unsubscribe()
```

Use your own topic names here; [URIs an app may use](#uris-an-app-may-use) lists the names the router reserves. For table rows use `subscribe_to_table`. The subscription is restored after every reconnect; events published on the topic while the connection was down are not delivered.

| Parameter | Type | Description |
|-----------|------|-------------|
| `topic` | `str` | The URI of the topic to subscribe to |
| `handler` | callable | Function called when a message is received |
| `options` | `SubscribeOptions`, optional | Subscription options |

**Returns:** `Subscription` — the handle of the subscription, valid across reconnects: `topic` (read-only), `active` (read-only: whether the subscription is live at the router now — `False` while the connection is down, after a failed restore until a retry succeeds, and after `unsubscribe()`), and `unsubscribe()`, which ends it (see [`unsubscribe`](#unsubscribesubscription)). Each `subscribe()` returns a handle of its own, also for a topic subscribed already.

**Raises:** `RuntimeError` with a descriptive message if the subscription fails (e.g. no connection within the reconnect window).

---

### `subscribe_to_table(tablename, handler, options=None)`

Convenience function to subscribe to a fleet table. Subscribes to the data backend's realtime feed `transformed.<tablename>` and its bulk counterpart `transformed.bulk.<tablename>`. Each event is the row as stored: values typed to the data-template columns, secret columns masked. Receives rows written via both the single-row and the bulk insert paths — rows from a bulk insert are delivered to your handler one at a time, so handler code stays the same.

```python
def on_table_data(*args, **kwargs):
    print("New row:", args, kwargs)

await ironflock.subscribe_to_table("sensordata", on_table_data)
```

**Rows stored while the connection was down arrive after the reconnect.** The router does not replay what was published while the connection was down, so after every reconnect the SDK reads the rows stored meanwhile and delivers them to your handler, oldest first, before the rows that arrive live: every row whose `tsp` is later than the newest row your handler got. Before the first row, the start is the newest row stored when you subscribed: the SDK reads it right after subscribing, and when that read fails — or is answered only after the connection has dropped and joined again meanwhile, when the newest row may be one stored while it was down — it starts at the time of subscribing (by the device's clock); a table that was empty then is read from its start. A row written meanwhile with an older `tsp` (a device sending readings it buffered, say) is not caught up. One catch-up delivers at most the newest 10000 rows, and logs a warning when it read that many. The catch-up read waits for the data backend like any history read, up to the reconnect window (`reconnect_window`, see [Options](#options)), and the live rows wait for it; when it fails, the rows stored meanwhile are not delivered, and a warning is logged — unless `stop()` ended it, which ends the catch-up quietly, delivering nothing more. The handler gets each caught-up row as its only argument (no keyword arguments); a coroutine it returns is awaited before the next row, and an exception it raises is logged (`logging`) while the catch-up goes on.

A transform (a data template's SQL view) has no rows of its own: on every tick of its schedule the data backend publishes the whole view — at most 3000 rows, in the view's order reversed — as **one** event whose first argument is the list of rows. `event_rows(args[0])`, exported from `ironflock`, reads both shapes: a row gives `[row]`, a list of rows the list (`[]` for an empty view), anything else `None`. A transform needs no catch-up: its next tick after a reconnect publishes the whole view again.

```python
from ironflock import event_rows

def on_hourly(*args, **kwargs):
    for row in event_rows(args[0]) or []:
        print(row)

await ironflock.subscribe_to_table("hourly_averages", on_hourly)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `tablename` | `str` | The name of the table to subscribe to |
| `handler` | callable | Function called when new data arrives |
| `options` | `SubscribeOptions`, optional | Subscription options |

**Returns:** `TableSubscription` — the handle of the table subscription, valid across reconnects: `table` (read-only), `active` (read-only: whether both feeds, `transformed.<tablename>` and `transformed.bulk.<tablename>`, are live at the router now), and `unsubscribe()`, which ends both feeds and the catch-up (see [`unsubscribe`](#unsubscribesubscription)).

**Raises:** `RuntimeError` with a descriptive message if the subscription fails (e.g. no connection within the reconnect window). When the bulk feed fails, the row feed is removed again.

---

### `unsubscribe(subscription)`

Ends a subscription: takes the handle `subscribe` or `subscribe_to_table` returned, or a consumed app's `subscribe_to_table` (see [Cross-App Data Access](#cross-app-data-access)). It is the same as calling the handle's own `unsubscribe()`.

```python
status = await ironflock.subscribe("com.myapp.status", on_status)
rows = await ironflock.subscribe_to_table("sensordata", on_row)

await ironflock.unsubscribe(status)  # the same as: await status.unsubscribe()
await rows.unsubscribe()             # both feeds of the table, and its catch-up
```

- **It takes effect at once**, when called: the handler is called for no further event — not for one still queued, not for the remaining rows of a bulk event, not for the rows a catch-up would deliver (a catch-up read in flight is cancelled) — and the subscription is never restored after a later reconnect. A handler call already under way is not waited for, so a handler may unsubscribe itself; a plain function, which cannot `await`, just calls `subscription.unsubscribe()`.
- **It does not wait for a connection.** While the connection is down it removes the subscription locally (the router dropped the session's subscriptions anyway) and completes at once. While it is up it tells the router, and what it returns completes once the router has answered; a refusal raises `RuntimeError` (`Unsubscribe from topic '<topic>' failed with WAMP error '...'`), and the subscription stays removed all the same.
- **It is idempotent**: a second call does nothing, nor does a call after `stop()` beyond removing the subscription locally — a later `start()` does not restore it.
- **Handles are independent**: removing one never removes another's handler, also on the same topic. The WAMP subscription of a topic ends when the topic's last handle is removed.

| Parameter | Type | Description |
|-----------|------|-------------|
| `subscription` | `Subscription` \| `TableSubscription` | The handle; `None` does nothing |

**Returns:** an awaitable (an `asyncio.Task`, or one that has completed already) — await it to know the router has answered. The removal does not depend on it: it has taken effect when `unsubscribe` returns. `unsubscribe` (like the handles' own `unsubscribe()` and `unregister()`) is not a coroutine function: hand what it returns to `asyncio.ensure_future` if you need a future, not to `asyncio.create_task`, which takes coroutines only.

**Raises:** `TypeError` for a `Registration` (end it with [`unregister`](#unregisterregistration)); once awaited, `RuntimeError` if the router refuses.

---

### `getHistory(tablename, queryParams)`

Retrieves historical data from a fleet table or transform: the newest rows that match, in ascending `tsp` order.

```python
# Simple query with limit
data = await ironflock.getHistory("sensordata", {"limit": 100})

# Query with time range and filters
data = await ironflock.getHistory("sensordata", {
    "limit": 500,
    "offset": 0,
    "timeRange": ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"],
    "filterAnd": [
        {"column": "temperature", "operator": ">", "value": 20},
        {"column": "humidity", "operator": "<=", "value": 80}
    ]
})

# Current value(s) only: add the `latest` marker. The data backend derives
# the latest row per entity in SQL (entity = the table's maintainLatestFlagFor
# columns from the data-template; without one, the single most recent row).
current = await ironflock.getHistory("sensordata", {
    "limit": 100,
    "filterAnd": [{"latest": True}]
})

# One field of a large json column: the column comes back pruned to its paths.
readings = await ironflock.getHistory("sensordata", {
    "limit": 100,
    "columns": ["json_data"],
    "columnPaths": ["json_data.sensor.temp"],
})
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `tablename` | `str` | The name of the table or transform to query |
| `queryParams` | `dict` or `TableQueryParams` (from `ironflock.types`), optional | Query parameters (see below). Defaults to `{"limit": 10}` |

**queryParams fields:**

| Field | Type | Description |
|-------|------|-------------|
| `limit` | `int` | Maximum number of rows to return (1–10000, required; 1–3000 for a transform) |
| `offset` | `int`, optional | Offset for pagination |
| `timeRange` | `list`, optional | `[start, end]` — ISO date-time strings or epoch-milliseconds numbers, never mixed; `None` for an open end. A legacy `{"start": ..., "end": ...}` object is converted to the pair |
| `filterAnd` | `list`, optional | AND-ed filter entries: predicates `{"column": str, "operator": str, "value": ...}`, groups `{"combinator": "AND" \| "OR", "filters": [...]}`, and/or the `{"latest": True}` mode marker (see below) |
| `columns` | `List[str]`, optional | Columns to return (`tsp`, `device_key` and `authid` are always included). Omit for all columns |
| `columnPaths` | `List[str]`, optional | Prunes `json` columns: JSON paths in the notation filters use (`"json_data.a.b"`, `"json_data['key']"`). A `json` column listed in `columns` comes back holding only the union of its paths, nested as in the column (a key it lacks as `None`); one with no path here, or named bare, comes back whole. Paths of other columns — not `json`, secret, or not in `columns` — are ignored, as are all paths by a transform and by data backends older than fleetdb v1.0.52. Omit to send none |

Supported filter operators: `=`, `!=`, `<>`, `>`, `<`, `>=`, `<=`, `LIKE`, `ILIKE`, `NOT LIKE`, `NOT ILIKE`, `IN`, `NOT IN`, `IS NULL`, `IS NOT NULL`, `IS CURRENT USER`, in any case and with surrounding spaces: the SDK sends them in the form listed here, the only one the data backend accepts (`"like"` goes out as `"LIKE"`, in groups too). An operator outside this list is sent as given, with a warning.

`IS NULL` and `IS NOT NULL` take no `value`, and they are the only way to ask about NULL: every other operator yields unknown on a NULL column and excludes the row, exactly as in SQL. `IN` and `NOT IN` take a list. `LIKE` is case-sensitive; use `ILIKE` to match case-insensitively.

`IS CURRENT USER` takes no `value` either: it matches rows whose column (a string or bigint one, such as `authid`) equals the account id of whoever reads, resolved by the data backend per read. An anonymous visitor, an app or a service reading matches no row, so it is for board widgets, not app code.

**Combining with OR:** entries of `filterAnd` are AND-ed. A group entry combines its own entries — predicates, the latest marker or further groups — with one operator (`"AND"` or `"OR"`, in any case), so the soft-delete pattern becomes:

```python
filterAnd=[
    {
        "combinator": "OR",
        "filters": [
            {"column": "deleted", "operator": "IS NULL"},
            {"column": "deleted", "operator": "=", "value": False},
        ],
    }
]
```

An entry is a predicate, a group or the marker, never several at once. A data backend that predates groups (fleetdb before v1.2.0) rejects this shape rather than applying part of it, so it fails loudly instead of quietly returning rows the filter excludes.

**Latest values:** a `{"latest": True}` entry in `filterAnd` is not a WHERE predicate but a mode switch: the data backend returns only the latest row per entity, derived on the fly in SQL (`DISTINCT ON` over the entity key declared as `maintainLatestFlagFor` in the table's data-template; a table without an entity key yields the single most recent row). The former physical `latest_flag` column no longer exists — a legacy `{"column": "latest_flag", "operator": "=", "value": True}` filter is still accepted and treated as the marker, but new code should use `{"latest": True}`. Other predicates combine with the marker as expected: entity-key predicates narrow which entities are returned, all other predicates and `timeRange` filter the resulting latest rows.

**Time ranges:** the data backend reads a string bound with JavaScript's `Date`. The SDK refuses a string `datetime.fromisoformat` cannot read, and one `Date` would misread: a date and a time separated by a space in the years 0–99 (`Date` reads `"0001-01-01 00:00:00"` — what `str(datetime.min)` gives — as 2001; separate them with `T`, as `datetime.isoformat()` does). A time without a zone is read as UTC. A numeric bound is epoch milliseconds and must be finite: NaN and ±Inf would reach the data backend as an open bound and drop the time filter, so they raise `ValueError` before anything is sent.

**Transforms:** a transform (a data template's SQL view) is read differently from a table. The data backend applies `limit` — at most 3000, more is refused with `URI_INVALID_LIMIT` — `offset` and `filterAnd` only: it ignores `timeRange`, `columns`, `columnPaths` and the latest marker, and drops a filter on a column the transform does not declare (a transform has no implicit `tsp`, `device_key` or `authid`), which widens the result. Its rows come in the view's own order, reversed: the newest rows, oldest first, only when the view orders newest first. Bound a transform's time range in its SQL.

**Large results:** a result too large for one message of the data backend (8 MiB of JSON) arrives in chunks, which `getHistory` reassembles in memory: you get the rows of a single read, in the same order. Chunks that do not add up (one missing, duplicated, or a row count that differs from the data backend's summary) raise `RuntimeError` instead of returning part of the rows. A single row over 8 MiB, or a result over the data backend's 1 GiB guard, is refused with `URI_RESULT_TOO_LARGE`: lower `limit`, narrow `timeRange`, select fewer `columns`, or prune with `columnPaths`.

**Returns:** `Any` — The query result data (typically a list of row dicts). Columns your data template declares `secret: true` come back as `SECRET_PLACEHOLDER` — see [Secret Columns](#secret-columns).

**Raises:** `ValueError` on invalid parameters; `RuntimeError` with a descriptive message when the history procedure is not registered (table not declared / data backend not running; only after the reconnect window) or the call fails — the data backend's refusals `URI_RESULT_TOO_LARGE`, `URI_INVALID_LIMIT` and `URI_SECRET_COLUMN` among them (see [Data backend refusals](#data-backend-refusals)).

---

### `get_series_history(tablename, params)`

Retrieves **down-sampled time-series** data from a fleet table — columns aggregated per time bucket (e.g. hourly averages). Ideal for charts over long time ranges. Available for tables (not transforms), on data backends from fleetdb v1.0.58 on.

```python
from datetime import datetime, timedelta, timezone
from ironflock import SeriesMetric

now = datetime.now(timezone.utc)
avg = SeriesMetric(ref="temperature", method="AVG")  # AVG, SUM, COUNT, MIN, MAX, FIRST, LAST
series = await ironflock.get_series_history("sensordata", {
    "metrics": [avg, {"ref": "humidity", "method": "MAX"}],
    "bucketMs": 3_600_000,  # one-hour buckets; omit to divide the range into `limit` buckets
    "limit": 24,            # the bucket budget, 1–10000
    "timeRange": [(now - timedelta(hours=24)).isoformat(), now.isoformat()],  # required, with a start
    "groupBy": ["device_key"],
})
for row in series:
    print(row["tsp"], row["device_key"], row[avg.column])  # avg.column is "AVG:temperature"
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `tablename` | `str` | The name of the table to query |
| `params` | `SeriesQueryParams` or `dict` | Series query parameters (see below) |

**params fields:**

| Field | Type | Description |
|-------|------|-------------|
| `metrics` | `List[SeriesMetric \| dict]` | The aggregates computed per bucket, at least one: `{"ref": ..., "method": ...}`. `ref` is a column, or a JSON path into a `json` column (`"json_data.temp"`); `method` is `"AVG"`, `"SUM"`, `"COUNT"`, `"MIN"`, `"MAX"`, `"FIRST"` or `"LAST"`, in capitals (unlike a filter operator, another spelling raises `ValueError`). Each comes back under `"<METHOD>:<ref>"`, which must fit 63 bytes |
| `bucketMs` | `int`, optional | Bucket width in whole milliseconds, at least `1000`. Omit to divide the time range into `limit` buckets |
| `limit` | `int` | The bucket budget (1–10000) — not a row limit |
| `timeRange` | `list` | `[start, end]` — ISO strings or epoch-ms numbers. Required, with a start; `None` as the end means now. The end must lie after the start |
| `groupBy` | `List[str]`, optional | Columns that split every bucket into a row per group |
| `filterAnd` | `list`, optional | AND-ed filter entries: predicates and groups, as for `getHistory` (the `{"latest": True}` marker is not supported in series queries, also not inside a group; use `getHistory` for latest values) |

**Rows:** each row is one bucket — of one group, with `groupBy`: `tsp` is the bucket's start in epoch milliseconds, each `groupBy` column comes back under its own name, and each metric under `"<METHOD>:<ref>"` (`SeriesMetric.column`), e.g. `row["AVG:temperature"]`. The rows ascend by `tsp`; a bucket without rows is absent (no gap filling). AVG and SUM take numeric columns, MIN and MAX strings and timestamps too; FIRST and LAST give the value of the bucket's earliest and latest row; COUNT counts the values that are not null (of `tsp`: the rows). A large result is reassembled from chunks, as for `getHistory`.

**Buckets:** `limit` counts buckets, not rows. Without `bucketMs` the time range is divided into `limit` buckets. With it, the data backend widens the buckets to a multiple of `bucketMs` when the range holds more than `limit` of them — and an open end reaches up to the data backend's now, a little after yours: a range from 24 hours ago with an open end holds 25 one-hour buckets, so `"limit": 24` gets two-hour ones. Bound the end, or allow a bucket more (with `bucketMs` and an open end, `limit` + 1 buckets can come back).

The SDK checks what the data backend would refuse before sending (at least one metric, refs non-blank, known methods, column names within 63 bytes, a start, the end after the start, `bucketMs` an integer of at least 1000); the data backend refuses what it cannot answer with `URI_INVALID_TIME_RANGE`, `URI_INVALID_METRIC`, `URI_INVALID_GROUP_BY` and `URI_SERIES_TOO_MANY_GROUPS` (more than 50,000 rows) — see [Data backend refusals](#data-backend-refusals).

> **Deprecated form.** ironflock-py 1.9.0 and earlier sent `{"metrics": ["temperature"], "method": "AVG", ...}`, which the data backend has refused since fleetdb v1.0.58. That form still works: the SDK sends one metric per column with the given method, emits a `DeprecationWarning`, and renames the result columns back to the column names, so old code keeps reading `row["temperature"]` (a column whose name the row already has — `COUNT` of `tsp` — keeps its `"<METHOD>:<ref>"` name). Move to `metrics=[{"ref": ..., "method": ...}]` and read `row["AVG:temperature"]`.

**Returns:** `Any` — The down-sampled series rows.

**Raises:** `ValueError` on invalid parameters (including a latest marker in `filterAnd`); `RuntimeError` with a descriptive message when the series procedure is not registered (only after the reconnect window) or the call fails.

---

### `call(topic, args=None, kwargs=None, options=None)`

Calls a remote procedure on the IronFlock message router using a full WAMP topic URI. An app cannot register a name like `some.full.wamp.topic` (see [URIs an app may use](#uris-an-app-may-use)), so such a name only reaches procedures that platform services register on the app's data realm, such as the data backend's and the file storage's. To call a function another device of your app registered, use `call_device_function`.

```python
result = await ironflock.call("some.full.wamp.topic", args=[42])
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `topic` | `str` | The full WAMP URI of the procedure to call |
| `args` | `list`, optional | Positional arguments |
| `kwargs` | `dict`, optional | Keyword arguments |
| `options` | `CallOptions`, optional | Call options |

**Returns:** `Any` — The result of the remote procedure call.

**Raises:** `ValueError` on invalid parameters; `RuntimeError` with a descriptive message if the call fails (e.g. not connected, or the procedure is not registered).

---

### `call_device_function(device_key, topic, args=None, kwargs=None, options=None)`

Calls a remote procedure registered by another IronFlock device. Automatically assembles the full WAMP topic as `{SWARM_KEY}.{device_key}.{APP_KEY}.{STAGE}.{topic}`, the same URI `register_device_function` registers on the target device. `STAGE` is `DEV` or `PROD`, the stage of the realm the app joined (`ENV=PROD` in any case selects `PROD`; anything else, unset included, selects `DEV`).

```python
result = await ironflock.call_device_function(42, "com.myapp.myprocedure", args=[42])
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `device_key` | `int` | The device key of the target device |
| `topic` | `str` | The URI of the procedure to call |
| `args` | `list`, optional | Positional arguments |
| `kwargs` | `dict`, optional | Keyword arguments |
| `options` | `CallOptions`, optional | Call options |

**Returns:** `Any` — The result of the remote procedure call.

**Raises:** `ValueError` when `topic` is empty or blank, or when `device_key`, `SWARM_KEY` or `APP_KEY` is not set (missing, empty, blank or `0`); `RuntimeError` with a descriptive message if the call fails (e.g. not connected, or the procedure is not registered).

> `call_function()` is a deprecated alias for `call_device_function()`.

---

### `register_device_function(topic, endpoint, options=None)`

Registers a procedure that can be called by other devices in the fleet, and by dashboard widget actions. Automatically constructs the full WAMP topic as `{SWARM_KEY}.{DEVICE_KEY}.{APP_KEY}.{STAGE}.{topic}` (`STAGE` as for `call_device_function`). Register procedures this way: the router refuses to register names such as `com.myapp.proc` (see [URIs an app may use](#uris-an-app-may-use) for what it accepts), and accepts single registrations only (`RegisterOptions` with an `invoke` policy other than `"single"` is refused).

```python
def add(a, b):
    return a + b

await ironflock.register_device_function("com.myapp.add", add)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `topic` | `str` | The URI of the procedure to register |
| `endpoint` | callable | The function to register |
| `options` | `RegisterOptions`, optional | Registration options |

**Returns:** `Registration` — the handle of the registration, valid across reconnects: `procedure` (read-only: the full WAMP URI), `active` (read-only: whether the registration is live at the router now — `False` while the connection is down, after a failed restore until a retry succeeds, and after `unregister()`), and `unregister()`, which ends it (see [`unregister`](#unregisterregistration)). Unlike in the JavaScript and Go SDKs, `active` stays `True` when another session takes the procedure over with `force_reregister`: autobahn-python only logs the router's notice of that (the next reconnect registers the procedure again).

**Raises:** `ValueError` when `topic` is empty or blank, or when `SWARM_KEY`, `DEVICE_KEY` or `APP_KEY` is not set (missing, empty, blank or `0`); `RuntimeError` with a descriptive message if the registration fails (e.g. no connection within the reconnect window, or the router refuses it).

> **Apps that override `ENV`:** the stage segment is always `DEV` or `PROD`. ironflock-py 1.8.6 and earlier put the raw `ENV` value there. That differs only when `ENV` is missing (older versions sent `None`) or your app sets it in its own environment settings with another spelling, e.g. `prod`. With such an override, devices on 1.8.6 or earlier cannot call functions that devices on a newer version register, and the other way round. Dashboard widget actions call the `DEV`/`PROD` spelling, so they now reach these functions.

> `register()` is an alias for `register_device_function()`. `register_function()` is a deprecated alias.

---

### `unregister(registration)`

Ends a registration: takes the handle `register_device_function` (or one of its aliases) returned. It is the same as calling the handle's own `unregister()`.

```python
add = await ironflock.register_device_function("com.myapp.add", lambda a, b: a + b)

await ironflock.unregister(add)  # the same as: await add.unregister()
```

It works as [`unsubscribe`](#unsubscribesubscription) does: it takes effect at once — the function is called no more, and it is never registered again after a later reconnect; a call already under way is not waited for — it does not wait for a connection, it tells the router while connected, and it is idempotent. Until the router has dropped the registration it may still route calls here: they are refused with `wamp.error.no_such_procedure`, without reaching your function.

**Returns:** an awaitable, as for `unsubscribe` (not a coroutine: await it, or hand it to `asyncio.ensure_future`).

**Raises:** `TypeError` for a `Subscription` or `TableSubscription`; once awaited, `RuntimeError` (`Unregistration of procedure '<procedure>' failed with WAMP error '...'`) if the router refuses — the registration stays removed all the same.

---

### `set_device_location(long, lat)`

Updates the device's location in the platform master data. The maps in device or group overviews will reflect the new location in realtime.

```python
await ironflock.set_device_location(long=8.6821, lat=50.1109)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `long` | `float` | Longitude (-180 to 180) |
| `lat` | `float` | Latitude (-90 to 90) |

**Returns:** `Any` — The result of the location update call.

**Raises:** `ValueError` on invalid coordinates; `RuntimeError` with a descriptive message if the location service call fails.

> **Note:** Location history is not stored. If you need location history, create a dedicated table and use `publish_to_table`.

> **Not available at the moment:** no platform service answers this call on the app's data realm, so it raises `RuntimeError` (`wamp.error.no_such_procedure`). Keep locations in a table of your own until it is.

---

### `getRemoteAccessUrlForPort(port, protocol="http", remote_port_environment=None)`

Returns the URL under which a port declared in the app's `port-template.yml` is reachable once its tunnel is active.

```python
url = ironflock.getRemoteAccessUrlForPort(8080)
# e.g. "https://<device_key>-<app_name>-8080.app.ironflock.com"

vnc = ironflock.getRemoteAccessUrlForPort(5900, protocol="tcp")
# e.g. "tcp://app.ironflock.com:30005"
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `port` | `int` | The port number, as declared in `port-template.yml` |
| `protocol` | `str` | `"http"` (default), `"https"`, `"tcp"` or `"udp"` — the protocol of the port rule, in any case (surrounding spaces are ignored); empty is `"http"` |
| `remote_port_environment` | `str`, optional | tcp/udp only: the variable that holds the remote port, instead of `REMOTE_PORT_FOR_<port>` — the template's own `remote_port_environment`, which device agents before 0.19.8 need |

http and https ports are served under the tunnel label `<DEVICE_KEY>-<app name, lower case>-<port>` (https ports with the platform's `secure-` prefix) on the tunnel domain the device agent injects (`TUNNEL_DOMAIN`), always as an `https://` URL. On a device that belongs to an instance (appliance, `INSTANCE_KEY` set) the cloud-forwarded route `https://i<INSTANCE_KEY>-<label>.<CLOUD_TUNNEL_DOMAIN>` is returned instead.

tcp and udp URLs carry the public port the tunnel assigned, which the device agent announces as `REMOTE_PORT_FOR_<port>` (agent 0.19.8 and later): `<protocol>://<TUNNEL_DOMAIN>:<remote port>`. On an instance device the internet-facing port, the variable's `_CLOUD` companion (`REMOTE_PORT_FOR_<port>_CLOUD`), is preferred (`<protocol>://<CLOUD_TUNNEL_DOMAIN>:<port>`); until the agent announces it, and again once it has removed it (cloud forwarding turned off), the instance-local URL is returned. A port of `0` has not been assigned yet: no URL.

The remote ports are read live from the agent's `/data/env` (the environment the container started with is the fallback), so call the method again rather than caching the result; `TUNNEL_DOMAIN`, `CLOUD_TUNNEL_DOMAIN` and `INSTANCE_KEY` come from the environment the container started with. The agent removes a `_CLOUD` value from `/data/env` when the cloud port goes away; while its mirror is there, the stale value in the container's environment is not used.

- Unset or empty tunnel domains default to `app.ironflock.com`. The platform does not set `CLOUD_TUNNEL_DOMAIN` today: an instance's URLs use `app.ironflock.com` unless the app sets that variable itself (in its environment settings), say for an appliance whose cloud tunnel host is another one.
- The agent tunnels the ports of PROD installs only. In a DEV container the http(s) URL is the one of the PROD install of the same app on this device (the label has no stage), which never reaches this container — reach a DEV app on the device's LAN address (`DEVICE_LAN_IP`, `DEVICE_PORT_FOR_<port>`) instead — and tcp/udp give `None`.
- On an edge device of an appliance installed without a domain (plain mode) the agent sets the tunnel domain to `localhost`, so the URLs composed on it — every non-instance URL, and an instance device's tcp/udp URL until its `_CLOUD` port arrives — point at localhost. Such an appliance serves app ports on its host ports, which a tunnel URL cannot express.
- The agent makes `/data/env` readable by root only (see [Options](#options)): in a container that runs as another user the values are the ones the container started with.

**Returns:** `str | None` — The remote access URL, or `None` when the device key or app name is not available, a tcp/udp port has no remote port announced (yet), the protocol is unknown, or the label is not a valid DNS label (longer than 63 characters, or the app name contains a dot).

---

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `is_connected` | `bool` | `True` if the connection to the platform is established — after a reconnect, once its subscriptions and registrations are restored |
| `files` | `FileStore` | The app's managed object storage — see [Managed File Storage](#managed-file-storage). Safe to access before `start()`; each call waits up to 10 s for the connection (issued before `start()`, for `start()` too) and is never retried (see [`FileStoreError`](#filestoreerror)) |
| `connection` | `CrossbarConnection` | The underlying connection instance (for advanced use) |

### Lifecycle Methods

| Method | Description |
|--------|-------------|
| `run(loop=None)` | Runs `run_async()` to its end. Without `loop` it blocks on an event loop of its own (`asyncio.run`); given a loop that is not running, it blocks on that loop; given a running loop (a FastAPI lifespan, Jupyter), it schedules `run_async()` there and returns the `asyncio.Task` without blocking. `run_sync()` is an alias |
| `await start(cburl=None)` | Starts the connection and waits for the first join — for as long as the app's realm does not exist yet — then starts `mainFunc` as a task and returns. `cburl`, a router URL, overrides `ironFlockUrl`; only the first `start()` sets the URL. Operations issued before `start()` wait for it (see [Connection reliability](#connection-reliability)) |
| `await stop()` | Stops the connection (and the consumed-app connections) and cancels the main task. First thing, it ends every operation still waiting for a connection or for `start()`: they raise at once (see [Connection reliability](#connection-reliability)). The connections leave their realms and do not reconnect afterwards; `start()` connects anew. A `start()` still waiting for the connection raises `ConnectionError`, and so does a `connect_to_app` or `connect_to_all_apps` still opening a connection, which `stop()` aborts (see [Cross-App Data Access](#cross-app-data-access)) |
| `await run_async(cburl=None)` | `start(cburl)`, then waits for `mainFunc` to end (without one, until it is cancelled), then calls `stop()`: the connection closes as soon as `mainFunc` returns or raises, and an exception it raises is re-raised after the stop |

## URIs an app may use

Your app container joins its own data realm, `realm-<SWARM_KEY>-<APP_KEY>-<dev|prod>`, with the router role `app`. The router checks every publish, subscribe, call and register there against that role's allow-list; the SDK's own methods stay inside it. It matters when you pass your own URIs to `publish()`, `subscribe()`, `call()` or `ironflock.connection`. A refused action fails with `wamp.error.not_authorized`, which the `IronFlock` methods raise as a `RuntimeError` (`wamp_error_uri(err)` reads the URI).

| URI on your own realm | Allowed | SDK method |
|-----------------------|---------|------------|
| Your own names, e.g. `com.myapp.status` | publish, subscribe (a prefix subscription such as `com.myapp.` too), call; never register | `publish()`, `subscribe()`, `call()` |
| `<SWARM_KEY>.<APP_KEY>.<table>`, `bulk.<SWARM_KEY>.<APP_KEY>.<table>` | publish only (never subscribe) | – (the table write topics; the SDK appends instead, see `publish_to_table()`) |
| `transformed.<table>`, `transformed.bulk.<table>` | subscribe only | `subscribe_to_table()` |
| `databackend.errors.…`, `files.events…` | subscribe only | – (the data backend's error and file events) |
| `<SWARM_KEY>.<DEVICE_KEY>.<APP_KEY>.<STAGE>.<name>` | register (single), call, publish | `register_device_function()`, `call_device_function()` |
| `append.…`, `appendBulk.…`, `history.transformed.…`, `secret.reveal.…`, `secret.verify.…`, `files.read.…`, `files.write.…` | call (the SDK calls them) | table, history, secret and `files` APIs |
| `sys.appaccess.list`, `sys.appaccess.resolve` | call | `list_consumable_apps()`, `connect_to_app()`, `connect_to_all_apps()` |

**Identity-checked names.** Names that start with a digit — the table write topics and the device-function URIs — also pass the platform's identity check, which binds them to your realm's swarm key. A registration is accepted only for a function URI of your own device with your realm's swarm key, app key and stage (`<SWARM_KEY>.<DEVICE_KEY>.<APP_KEY>.<STAGE>.<name>`, where `STAGE` is `DEV` or `PROD`, the stage of the realm); a prefix or wildcard registration only when it fixes those four segments. Publish and call need only your realm's swarm key: a row published to a table topic with another app key is accepted, but reaches no data backend and is lost. Apps cannot subscribe to these names. Let the SDK build these URIs (`register_device_function()`, `call_device_function()`), and write rows with the table methods: other devices of your app call a device function with `call_device_function()`, and dashboard widget actions call the same URI. Because no app can register names like `com.myapp.proc`, `call()` on such a name only reaches procedures that platform services register on the data realm.

**Reserved names.** The router refuses `sys.` apart from the two cross-app procedures above, and `wamp.` apart from `wamp.session.get` (the JavaScript SDK's heartbeat). The other names in the table belong to the platform: do not start your own names with a digit, `bulk.`, `transformed.`, `databackend.errors.`, `files.`, `append.`, `appendBulk.`, `history.` or `secret.`. Every other first segment is your app's own topic space.

**Nobody subscribes to raw table topics.** The data backend also takes rows published to `<SWARM_KEY>.<APP_KEY>.<table>` and `bulk.<SWARM_KEY>.<APP_KEY>.<table>` (boards' forms write there, and ironflock-py 1.9.1 and earlier's `publish_to_table()` and `publish_rows_to_table()` did). These topics carry the row exactly as sent, including the plaintext of columns declared `secret: true`, which the data backend encrypts only when it stores the row. So no app or dashboard may subscribe to them. Use `subscribe_to_table()`: the data backend publishes every stored row on `transformed.<table>` (bulk inserts on `transformed.bulk.<table>`), typed, with secret columns masked.

**Cross-app access is read-only.** `connect_to_app()` and `connect_to_all_apps()` open a second connection to the provider app's realm with the `app_reader` role (this needs the per-app credential the agent injects as `APP_AUTH_ID`/`APP_AUTH_SECRET`; with the legacy device credential the provider realm is refused, or joined with the provider app's own rights when that app runs on the same device). There your app may subscribe to `transformed.…` and `files.events…`, and call `history.transformed.…` (history and series) and `files.read.…`. Nothing else is allowed, besides the heartbeat probe `wamp.session.get` (used by the JS SDK): no publish, no register, no writes and no secret procedures. The provider's private tables are filtered out as well.

## Connection reliability

The connection reconnects on its own. When the socket drops, the SDK retries
until the router is back, then restores every subscription and every registered
device function — you do not need to re-subscribe after a reconnect. Table
subscriptions also deliver the rows stored while the connection was down (see
`subscribe_to_table`); events on your own topics are not stored, so those
published meanwhile are lost. If one
cannot be restored, the rest still are, and the failed one is retried while the
session lasts (after 1 s, doubling up to 30 s, each wait varied by ±15 %) and
after every reconnect: the router accepted it before, so its refusal is a
passing one, such as the platform's identity check of a device function failing
closed while its authorizer is slow or unavailable. The first failure is logged
as a warning (`logging`), e.g.
`Restore of procedure '…' failed with WAMP error 'wamp.error.not_authorized'; retrying`,
and the success is printed to stdout (`print`, not `logging`) as
`Registration of procedure '…' restored` (or `Subscription to topic '…' restored`).
The handles `subscribe`, `subscribe_to_table` and `register_device_function`
return stay valid across reconnects; their `active` tells whether the
subscription or registration is live at the router at the moment. Until the
restore after a join is done, operations wait for it, as for the connection:
`is_connected` turns `True` only then.

Table calls (see `reconnect_window` under [Options](#options)) ride out a
platform restart: they wait for the connection to come back and retry while the
data backend does not serve its procedures yet — the router answers
`wamp.error.no_such_procedure`, or the data backend's WAMP client refuses the
call with `wamp.error.invalid_argument` "client has no handler for registration
…" because the call overtook its registration (or arrived while it
unregisters the procedure). Such a call never reached a handler, so retrying
cannot run it twice. Any other error is raised as it comes. The table
publishes (`publish_to_table`, `publish_rows_to_table`, `report_error`) are
such calls: they append, so a row written while the data backend restarts is
not lost.

`publish`, `subscribe`, `subscribe_to_table` and `register_device_function`
(with its aliases) wait for the connection within the reconnect window too,
so a short outage does not make them fail; they are not retried. A
subscription or registration made is restored after every later reconnect.

Every other operation — `call`, `call_device_function`,
`set_device_location`, the `files` calls, the cross-app lookups and opens —
waits up to 10 s for the connection and is not retried; without one it raises,
wrapped like its other errors, a `TimeoutError` reading
`Not connected to the IronFlock router (realm '<realm>', no session after 10s). …`.
An operation issued before `start()` — by a task started earlier, say — waits
for `start()` within the same time (the reconnect window, or 10 s) instead of
failing at once. `unsubscribe` and `unregister` do not wait at all (see
[`unsubscribe`](#unsubscribesubscription)).

`stop()` ends every wait at once: an operation still waiting for the
connection, or for `start()`, raises — wrapped like its other errors — a
`ConnectionError` reading `The IronFlock connection was stopped while waiting
for a session on realm '<realm>'` (`… while waiting for start()` before the
first `start()`); so does a table call pausing between two retries. An
operation issued after `stop()` waits, as one issued before `start()`, for the
next `start()`.

A request already sent when its session closes (the router closes it, the link
drops, or `stop()`) fails at once, raised like the method's other errors: its
cause is autobahn's `ApplicationError` with the close reason (e.g.
`wamp.close.system_shutdown`), or `TransportLost` when the link dropped. It is
not retried: it may have reached its handler.

`stop()` ends the connection until the next `start()`: the connection leaves
its realm, and nothing connects again before — not even when the router does
not answer the leave (the SDK then drops the connection after 5 s). `start()`
connects anew. A `start()` still
waiting for the connection — for a realm that does not exist yet, say (see
below) — ends once `stop()` has ended the connection, with `ConnectionError`
`Connection to realm <realm> stopped before it opened`.

A special case is a router that is reachable but has no realm for the app yet.
The concurrent-install race boots the app container before the databackend has
provisioned its realm, so the first joins are refused with
`wamp.error.no_such_realm`; the SDK keeps retrying every couple of seconds and
the app comes up the moment the realm does. A realm still missing after a
minute is most likely never going to appear — the app was deleted, or has no
databackend for this stage — so from then on the SDK slows to one attempt every
two minutes rather than hitting the router every second forever. The fast
cadence returns as soon as the realm appears, or the router itself goes away
and comes back.

The harder case is a socket that dies *silently*: an idle NAT or proxy cuts the
connection without telling either side, or a router restarts behind a load
balancer. Nothing arrives to signal the loss. A connection that only publishes
finds out on its next failing write, but a connection that only subscribes never
writes at all, so without help it sits dead until the process restarts.

**WebSocket keepalive** closes that gap. The SDK pings the router every 30
seconds while the link is idle and expects a pong within 10 seconds; if none
arrives, the transport is dropped and the normal reconnect path runs. Incoming
traffic counts as proof of life, so a busy connection is never probed — the ping
only goes out when the link has actually gone quiet.

This is on by default and needs no configuration. Cross-app connections from
`connect_to_app()` are typically subscribe-only, which is exactly the case
keepalive protects, and they are covered automatically.

> The JavaScript SDK solves the same problem differently: the browser WebSocket
> API exposes no ping, so it uses a WAMP-level heartbeat with tunable
> `heartbeatIntervalMs` / `heartbeatTimeoutMs` properties instead.

## Secret Columns

A data-template column declared `secret: true` is encrypted by the data backend (AES-256-GCM) before it is written, so what Postgres stores is an `ifsec:1:<base64url>` envelope, not the value. The flag is allowed on `dataType: string` columns only, never on `tsp`, and never on a column used as an entity key (`maintainLatestFlagFor`).

No read path hands the plaintext back by accident:

| Read path | What a non-null secret value looks like |
|-----------|------------------------------------------|
| `getHistory`, `subscribe_to_table`, cross-app reads | `SECRET_PLACEHOLDER` (the string `"__secret__"`), exported from `ironflock` |
| SQL — `sys.dataservice.select_query`, the FleetDB Access Postgres login, backups | the raw `ifsec:1:...` ciphertext |
| `reveal_secrets` / `verify_secret` | the decrypted value / a boolean |

`None` stays `None` everywhere, so "set but hidden" remains distinguishable from "never written".

Both procedures below are registered per app realm and callable **only by your app's own containers** — the router denies every browser role and every consuming app, so another app can never decrypt your secrets.

Encryption uses a random IV, so two rows holding the same plaintext hold **different** ciphertext. Equality filters, `GROUP BY`, `ORDER BY`, `DISTINCT` and entity keys on a secret column are impossible, not merely discouraged — the data backend refuses a filter on a secret column (`URI_SECRET_COLUMN`); select the rows by their other columns.

### `reveal_secrets(tablename, query_params={"limit": 10})`

Reads rows of your own table with the secret columns decrypted. The data backend answers at most 30 reveals a minute per app credential on a device; beyond that the call fails with `URI_RATE_LIMITED` — reveal a secret once and keep it, rather than on every use.

```python
# The most recent row, secrets in clear
rows = await ironflock.reveal_secrets("credentials", {
    "limit": 1,
    "filterAnd": [{"latest": True}],
})
print(rows[0]["api_key"])  # the plaintext, not "__secret__"

# Narrow by a NON-secret column — a filter on the secret itself is refused
rows = await ironflock.reveal_secrets("credentials", {
    "limit": 10,
    "filterAnd": [{"column": "gateway_id", "operator": "=", "value": 471}],
})
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `tablename` | `str` | The name of the table to read |
| `query_params` | `dict` or `SecretQueryParams` (from `ironflock.types`) | Same fields as `getHistory` (`limit`, `offset`, `timeRange`, `filterAnd`, `columns`, `columnPaths`), except that `limit` must be 1–100 — the data backend **rejects** a larger value for this procedure rather than clamping it |

**Returns:** `Any` — The rows, in the same shape `getHistory` returns, with the secret columns decrypted.

**Raises:** `ValueError` on invalid parameters; `RuntimeError` when the call fails — `URI_RATE_LIMITED` and `URI_SECRET_COLUMN` among the refusals (see [Data backend refusals](#data-backend-refusals)) — or when the procedure is not registered, which shows only after the reconnect window. The data backend registers it only for a table that declares a secret column, so the error names the causes in this order: the table declares no secret column (`secret: true` in the data template), the table is not declared, or the data backend predates secret columns.

### `verify_secret(tablename, column, candidate, query_params={"limit": 1})`

Checks a candidate against a stored secret **without reading it back** — the comparison happens inside the data backend, in constant time, and nothing decrypted leaves it. Prefer this whenever you don't actually need the plaintext. The data backend answers at most 120 checks a minute per app credential on a device; beyond that the call fails with `URI_RATE_LIMITED` — for a check on every request (an API key, say), cache a positive outcome briefly.

```python
# Is this the current API key? (default query = the most recent row)
ok = await ironflock.verify_secret("credentials", "api_key", received_key)

# Check against a particular entity's rows
ok = await ironflock.verify_secret("credentials", "api_key", received_key, {
    "limit": 1,
    "filterAnd": [
        {"latest": True},
        {"column": "gateway_id", "operator": "=", "value": 471},
    ],
})
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `tablename` | `str` | The name of the table to check |
| `column` | `str` | The secret column to compare |
| `candidate` | `str` | The plaintext to compare the stored value against |
| `query_params` | `dict` or `SecretQueryParams` (from `ironflock.types`) | Selects WHICH rows are checked — same fields as `getHistory`; `limit` must be 1–100, the data backend **rejects** a larger value rather than clamping it |

**Returns:** `bool` — `True` when **any** selected row's column decrypts to `candidate`.

**Raises:** `ValueError` on invalid parameters; `RuntimeError` when the call fails — `URI_RATE_LIMITED`, `URI_NOT_A_SECRET_COLUMN` (a column that is not secret) and `URI_SECRET_COLUMN` (a filter on a secret column) among the refusals — or when the procedure is not registered, as for `reveal_secrets` (the table declares no secret column, the table is not declared, or the data backend predates secret columns; only after the reconnect window).

---

## Cross-App Data Access

Read another app's fleet data from within your app, in the same project and fleet. Your app must declare the provider app in its own data-template `consumes:` section (or hold the wildcard grant, `consumes: [{app: "*"}]`), and the project user must grant access. Access is **read-only**: you can query history and subscribe to realtime rows of the tables and transforms (views) the provider shares — you cannot write to them.

The connection needs the per-app credential the device agent injects (`APP_AUTH_ID`, `APP_AUTH_SECRET`). The platform refuses the legacy device credential — the serial-number fallback of an app that got no per-app credential, e.g. under an older agent — on another app's realm (`NOT_AUTHORIZED`, with a message that says so), unless the provider app runs on the same device, whose realm it then joins with that app's own full rights rather than read-only.

```python
from ironflock import IronFlock, CrossAppAccessError

ironflock = IronFlock()
await ironflock.start()

# Open a read-only handle on another app's data backend
weather = await ironflock.connect_to_app("weather-app")

# Inspect what the provider shares (non-private tables / transforms)
print([t["tablename"] for t in weather.tables])

# Query history, just like your own tables
rows = await weather.get_history("forecasts", {"limit": 100})

# Subscribe to realtime rows
def on_forecast(*args, **kwargs):
    print("New forecast:", args)

await weather.subscribe_to_table("forecasts", on_forecast)

# Access errors carry a machine-readable code
try:
    await ironflock.connect_to_app("unshared-app")
except CrossAppAccessError as err:
    print(err.code)  # e.g. "NO_GRANT"
```

Consumed-app connections are cached per app + stage and are closed automatically by `ironflock.stop()`. Concurrent `connect_to_app` calls for the same app and stage share one attempt, which runs to its end also when a caller stops waiting for it (is cancelled).

`ironflock.stop()` aborts the attempts still in flight, whether they are resolving the provider or connecting to its realm: the call raises `ConnectionError` `Connection to app '<app>' (<stage>) aborted by stop()` — or, when its connection joins all the same, closes it and raises `Connection to app '<app>' (<stage>) closed by stop()`. A denial that races `stop()` is still raised as the `CrossAppAccessError`. `connect_to_all_apps` reports a provider aborted this way like any other provider that failed to open (to `on_error`, or raised with `continue_on_error=False`). No connection to a provider is left open or connecting when `stop()` returns.

A denial while `connect_to_app` waits is raised at once. Once a handle is open, a denial counts as final only when it persists — 3 refusals or more since the connection was last up (the router closing the session for an auth reason counts as one, as does each refused reconnect), the first at least 60 s ago — because the platform refuses the same way while it briefly cannot verify access (its authenticator or database fails). Until then the handle keeps reconnecting, and recovers if a reconnect succeeds; the first refusal is logged as a warning. Once the denial is final, `on_error` is called and the handle is evicted: the next `connect_to_app` opens a fresh connection. The handle's reads and subscriptions fail at once from then on, with the error `on_error` got.

### `connect_to_app(app_name, stage=None, on_error=None)`

Opens a read-only connection to another app's data backend in the same project and returns a `ConsumedApp` handle. Resolves the provider and connects to its realm with this app's own credential (the per-app credential the device agent injects).

```python
weather = await ironflock.connect_to_app("weather-app", stage="prod")
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `app_name` | `str` | Provider app name, as declared in your `consumes:` section |
| `stage` | `str`, optional | Provider stage to connect to (`"dev"` or `"prod"`, in any case). Defaults to the stage of the realm this app joined (`ENV=PROD` in any case gives `prod`, anything else `dev`) |
| `on_error` | callable, optional | Called with a `CrossAppAccessError` (`NOT_AUTHORIZED`) when the connection is denied for good *after* `connect_to_app` resolved (e.g. the grant is later revoked; a denial counts as for good only once it persists, see above) |

**Returns:** `ConsumedApp` — A read-only handle on the provider's data backend.

**Raises:** `ValueError` for an empty `app_name` or a stage other than dev and prod; `CrossAppAccessError` (`code`: `NO_GRANT`, `PROVIDER_NOT_INSTALLED`, `UNKNOWN_APP`, or `NOT_AUTHORIZED`); `ConnectionError` when the provider realm cannot be joined within 50 s, or when `ironflock.stop()` aborts the attempt (see above); `TimeoutError` when the app's own connection has no session for 10 s (the provider is resolved over it; issued before `start()`, the call waits for `start()` within those 10 s).

---

### `list_consumable_apps()`

Lists what a wildcard consumer (`consumes: [{app: "*"}]`) may read: every non-private provider app in the project, with its catalog per stage. One `sys.appaccess.list` call on the app's own realm; it opens no connection to a provider. Pick providers from it and open them with `connect_to_app`, or open them all with `connect_to_all_apps`.

```python
for info in await ironflock.list_consumable_apps():
    print(info["app"], sorted(info["stages"]))  # e.g. weather-app ['dev', 'prod']
```

**Returns:** `List[dict]` — one entry per provider: `{"app": ..., "provider_app_key": ..., "stages": {"dev": {"tables": [...], "transforms": [...]}, "prod": {...}}}`, with a stage only where the provider has a data backend for it. The table and transform entries have the shape of `ConsumedApp.tables`.

**Raises:** `CrossAppAccessError` `NO_GRANT` when this app holds no wildcard grant.

---

### `connect_to_all_apps(stage=None, on_error=None, continue_on_error=True)`

Opens a read-only handle on every provider `list_consumable_apps()` lists, all at once, without a resolve per provider (wildcard consumers only). Each handle is cached as `connect_to_app` caches it, so a later `connect_to_app(name)` returns it, and `ironflock.stop()` closes it.

```python
providers = await ironflock.connect_to_all_apps(on_error=lambda err: print("provider failed:", err))
print([p.app for p in providers])
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `stage` | `str`, optional | `"dev"` or `"prod"`, as for `connect_to_app`; another value raises `ValueError`. A provider without a data backend for this stage is skipped, without a call to `on_error` |
| `on_error` | callable, optional | Called with the exception of each provider that failed to open (with `continue_on_error=True`), and with a `CrossAppAccessError` (`NOT_AUTHORIZED`) when an opened handle is denied for good later, as for `connect_to_app` |
| `continue_on_error` | `bool`, optional | `True` (default): a provider that fails to open goes to `on_error` and is left out of the result. `False`: the call raises the failure of the first provider, in the listing's order, that failed to open; the handles that opened stay cached |

**Returns:** `List[ConsumedApp]` — the handles that opened.

**Raises:** `ValueError` for a stage other than dev and prod; `CrossAppAccessError` `NO_GRANT` when this app holds no wildcard grant; with `continue_on_error=False`, a provider's failure to open (see above).

---

### `ConsumedApp` handle

Returned by `connect_to_app` (and, one per provider, by `connect_to_all_apps`). A read-only view of a provider app's shared tables and transforms.

**Properties:**

| Property | Type | Description |
|----------|------|-------------|
| `app` | `str` | Provider app name |
| `stage` | `str` | Provider stage this handle is connected to (`"dev"` or `"prod"`) |
| `tables` | `List[dict]` | Non-private tables the provider shares (dicts with `tablename`, optional `description`/`columns`) |
| `transforms` | `List[dict]` | Non-private transforms (views) the provider shares (same shape as tables) |
| `is_connected` | `bool` | `True` while the connection to the provider is open |
| `connection` | `CrossbarConnection` | The underlying connection (advanced use) |

#### `consumed_app.get_history(tablename, query_params=None)`

Queries history rows of a shared table or transform. Takes the same query parameters as `getHistory` (`limit`, `offset`, `timeRange`, `filterAnd`, `columns`, `columnPaths`; without `query_params` it reads `{"limit": 10}`) — including the `{"latest": True}` marker in `filterAnd` for reading the provider's current values — and answers the same way: a large result is reassembled from chunks, and a transform applies only `limit` (at most 3000), `offset` and `filterAnd` and returns its rows in the view's order, reversed (see **Transforms** under [`getHistory`](#gethistorytablename-queryparams)).

```python
rows = await weather.get_history("forecasts", {"limit": 100})
current = await weather.get_history("forecasts", {
    "limit": 100,
    "filterAnd": [{"latest": True}],
})
```

Columns the provider marks `secret: true` (see [Secret Columns](#secret-columns)) are never readable across apps — a non-null value arrives as `SECRET_PLACEHOLDER`, and only the owning app's own containers can decrypt it. Filtering on (also inside a group) or selecting such a column raises `CrossAppAccessError` with code `SECRET_COLUMN` client-side rather than returning an empty or masked result.

**Returns:** `Any` — The query result rows.

**Raises:** `ValueError` on invalid parameters; `CrossAppAccessError` (see below); `RuntimeError` when a chunked result does not add up. Any other refusal — the data backend's `URI_RESULT_TOO_LARGE`, say — is raised as autobahn's `ApplicationError`; `wamp_error_uri(err)` reads its URI.

#### `consumed_app.subscribe_to_table(tablename, handler, options=None)`

Subscribes to realtime rows of a shared table or transform. Bulk-inserted rows are delivered one at a time, and the rows stored while the connection was down are caught up after a reconnect, exactly like `subscribe_to_table` on your own app. A transform's events carry the whole view as a list of rows, one event per tick of its schedule; read both shapes with `event_rows(args[0])`.

```python
def on_forecast(*args, **kwargs):
    print("New forecast:", args)

forecasts = await weather.subscribe_to_table("forecasts", on_forecast)

await ironflock.unsubscribe(forecasts)  # or: await forecasts.unsubscribe()
```

**Returns:** `TableSubscription` — the handle, as [`subscribe_to_table`](#subscribe_to_tabletablename-handler-optionsnone) returns it: `table`, `active` and `unsubscribe()`, which ends both feeds and the catch-up. `consumed_app.close()` ends the connection, and with it every subscription on it.

**Raises:** `CrossAppAccessError` — `PRIVATE_TABLE` for a name outside the catalog, `NOT_AUTHORIZED` when the router refuses the subscription (as for the reads), and at once, once the connection is denied for good; `ConnectionError` at once once the handle is closed (see `close()`).

#### `consumed_app.get_series_history(tablename, params)`

Queries down-sampled time-series history of a shared **table** (not available for transforms: a transform's name raises `CrossAppAccessError` `PRIVATE_TABLE`), with the parameters and the rows of [`get_series_history`](#get_series_historytablename-params): one row per bucket (and group), `tsp` the bucket's start in epoch milliseconds, each metric under `"<METHOD>:<ref>"`.

```python
series = await weather.get_series_history("forecasts", {
    "metrics": [{"ref": "temperature", "method": "AVG"}],
    "limit": 500,
    "timeRange": ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"],
})
for row in series:
    print(row["tsp"], row["AVG:temperature"])
```

**params fields (`SeriesQueryParams`):**

| Field | Type | Description |
|-------|------|-------------|
| `metrics` | `List[SeriesMetric \| dict]` | The aggregates per bucket, at least one: `{"ref": column or JSON path, "method": "AVG" \| "SUM" \| "COUNT" \| "MIN" \| "MAX" \| "FIRST" \| "LAST"}` |
| `bucketMs` | `int`, optional | Bucket width in whole milliseconds, at least `1000` |
| `limit` | `int` | The bucket budget (1–10000) |
| `timeRange` | `list` | `[start, end]` — ISO strings or epoch-ms numbers; required, with a start; `None` as the end means now |
| `groupBy` | `List[str]`, optional | Columns that split every bucket into a row per group |
| `filterAnd` | `list`, optional | AND-ed filter entries: predicates and groups — no `{"latest": True}` marker |

The deprecated form (`"metrics": ["temperature"], "method": "AVG"`) still works as for `get_series_history`: it warns, and its rows carry each metric under the column name.

**Returns:** `Any` — The down-sampled series rows.

#### `consumed_app.close()`

Closes this handle's connection to the provider. (All consumed-app connections are also closed by `ironflock.stop()`.) A read still waiting for the connection fails at once, and so does every read and `subscribe_to_table` of the handle afterwards: `ConnectionError` (`The IronFlock connection was stopped: the handle on app '<app>' (<stage>) is closed`). `connect_to_app` opens a fresh handle.

**Returns:** `None`

---

### `CrossAppAccessError`

Raised by `connect_to_app`, `list_consumable_apps`, `connect_to_all_apps` and the `ConsumedApp` methods when cross-app access is denied or misused. Exposes a machine-readable `code`:

| `code` | Meaning |
|--------|---------|
| `NO_GRANT` | The project user has not granted your app access to the provider; for `list_consumable_apps` and `connect_to_all_apps`, your app holds no wildcard grant |
| `PROVIDER_NOT_INSTALLED` | The provider app has no data backend for that stage in this project |
| `UNKNOWN_APP` | No app by that name |
| `PRIVATE_TABLE` | The requested table/transform is not in the provider's shared catalog, or `get_series_history` names a transform |
| `SECRET_COLUMN` | The query filters on or projects a column the provider marks `secret` — its values are encrypted per row and never readable by a consumer |
| `NOT_AUTHORIZED` | The router/provider denied access: `Access to app '<app>' (<stage>) denied: <reason>.`, followed by `The grant may have been revoked.` — or, when this app presented the legacy device credential, by why that is refused on another app's realm (see above). Once a handle's connection is denied for good, its reads and subscriptions raise this error at once |

## Data backend refusals

An operation the data backend refuses fails with one of these WAMP error URIs, the reason as its first argument. The `IronFlock` methods raise a `RuntimeError` whose cause is autobahn's `ApplicationError`; a consumed app's reads raise the `ApplicationError` itself. `wamp_error_uri(err)` reads the URI from either, and the constants are exported from `ironflock`. The SDK retries none of them. `publish_to_table` and `publish_rows_to_table` append, so they raise these too.

```python
from ironflock import URI_STORAGE_FULL, wamp_error_uri

row = {"tsp": datetime.now(timezone.utc).isoformat(), "temperature": 22.5}
try:
    await ironflock.append_to_table("sensordata", row)
except RuntimeError as err:
    if wamp_error_uri(err) == URI_STORAGE_FULL:
        pending.append(row)  # the appliance's disk is nearly full: append the row later
    else:
        raise
```

| Constant | Refused |
|----------|---------|
| `URI_RATE_LIMITED` | `reveal_secrets` beyond 30, `verify_secret` beyond 120 calls a minute per app credential on a device |
| `URI_RESULT_TOO_LARGE` | A read with a single row over the 8 MiB message budget, or a result over the 1 GiB guard (other large results arrive in chunks, which the SDK reassembles); its kwargs name the largest columns |
| `URI_INVALID_LIMIT` | A read of a transform with a `limit` over 3000 |
| `URI_INVALID_TIME_RANGE`, `URI_INVALID_METRIC`, `URI_INVALID_GROUP_BY`, `URI_SERIES_TOO_MANY_GROUPS` | Series queries the table cannot answer (see `get_series_history`) |
| `URI_SECRET_COLUMN`, `URI_NOT_A_SECRET_COLUMN` | A filter on a secret column; `verify_secret` on a column that is not secret (`reveal_secrets` of a table without secret columns fails as not registered instead, see there) |
| `URI_STORAGE_FULL` | An append while the appliance's disk is nearly full (below about 3 GiB free; accepted again from 4 GiB) — temporary: append the rows later |
| `URI_STORAGE_OVERUSAGE` | An append beyond the account's storage allowance; retrying soon does not help |
| `URI_ENTITY_KEY_CONFLICT` | A row whose entity key (`maintainLatestFlagFor`) and `tsp` another row has already, in the batch or stored: give every row its own `tsp` |
| `URI_SECRET_SENTINEL_UNRESOLVABLE`, `URI_SECRET_CIPHERTEXT_REJECTED` | A write that keeps a secret's previous value without an entity key to find it by; a write of an already encrypted value |

A row the data backend refuses for itself — a missing `tsp`, above all — reaches an append as `wamp.error.runtime_error` without a reason; the raised error then names that usual cause (see `append_to_table`).

## Managed File Storage

Every app databackend gets private object storage alongside its tables. With no
`files:` section in the data template you still get one namespace named `default`,
so this works against apps released before FleetFiles existed (a template that
declares namespaces of its own replaces it — see below).

```python
# Store an object and get a permanent URL back in the same call
info = await ironflock.files.put("part-1.jpg", jpeg_bytes, content_type="image/jpeg")

# That URL is safe to put in a FleetDB column — a dashboard widget can render
# <img src="{{photo_url}}"> and it just works
await ironflock.publish_to_table("inspections", {
    "tsp": datetime.now(timezone.utc).isoformat(),
    "part_id": "1",
    "photo_url": info.url,
})

data = await ironflock.files.get("part-1.jpg")
async for obj in ironflock.files.iter(prefix="2026/"):
    print(obj.key, obj.size)
```

The URL never expires, yet stays readable **only** to an authenticated requestor
holding READ on this databackend — an auth proxy re-checks on every request, so
it is safe to store but not a public link.

A **namespace** is a key prefix that carries policy — retention, sharing,
allowed content types. It is not a separate S3 bucket; every namespace lives in
the app's one bucket. Declare one only when a set of objects needs *different
rules*; otherwise stay in `default` and organise with key paths.

Declare namespaces in `data-template.yml`. Declaring any replaces the implicit
`default`: list `default` as well if calls without a `namespace` should keep
working — otherwise they fail with `NO_SUCH_NAMESPACE`.

```yaml
files:
  # Budget the app suggests for itself; the project user's setting is what gets
  # enforced. catalog() reports both as quota_bytes and suggested_quota_bytes.
  quotaBytes: 5368709120
  namespaces:
    # Keep default: declaring namespaces replaces the implicit one.
    - name: default
    - name: frames
      description: Raw camera frames, one JPEG per inspected part.
      contentTypes: ["image/jpeg"]
      maxObjectBytes: 20971520
      retention: { deleteAfter: 30 days }
```

The budget is app-wide, not per namespace — a namespace is only a key prefix inside the app's one storage area, so there is nothing for a per-prefix budget to be enforced against. `maxObjectBytes` *is* per namespace: it caps a single object, not a total — 100 MiB unless set, at most 5 GiB (`OBJECT_TOO_LARGE` above it).

### `files.put(key, data, namespace="default", content_type=None)`

Stores `bytes` (or a `bytearray`; anything else raises `ValueError`). Without
`content_type` the object is stored as `application/octet-stream`. Returns
`ObjectInfo`: `namespace`, `key`, `size`, `etag`, `content_type`,
`last_modified` (ISO 8601, UTC) and `checksum_sha256` (the base64 SHA-256) —
each `None` when the store does not report it — and `url` (see `files.url()`).

### `files.get(key, namespace="default")`

Returns `bytes`. An object over the inline limit comes directly from the object
store, or in ranges over the router where that is not possible (see
[Large objects](#large-objects)).

### `files.list(prefix="", namespace="default", limit=200, cursor="", delimiter=None)`

One page of the file service's listing: `.objects`, `.prefixes`, `.is_truncated`,
`.cursor`. By default the listing is folder-style: `.objects` holds the objects
whose keys continue the prefix without a `/`, and `.prefixes` the folders below
(each ending in `/`), whose objects are not listed. `delimiter=""` lists flat:
every object under the prefix, in subfolders too. The delimiter is sent only
when given.

The prefix is passed on as given. FleetFiles v0.2.0 refuses a prefix that ends
in `/` (with `INTERNAL`), so `list()` cannot open a folder by name:
`list(prefix="2026")` lists `2026-notes.txt` and the folder `2026/` in
`.prefixes`. `files.iter()` yields the objects under a folder.

### `files.iter(prefix="", namespace="default")`

Async iterator over every object under a prefix, in subfolders too, paginating
for you (it asks for a flat listing). A prefix that ends in `/` names a folder:
`iter(prefix="2026/")` yields `2026/a.txt` and `2026/sub/c.txt`, but not
`2026-notes.txt`. The file service stores keys in Unicode normalization form C;
give a folder prefix in NFC too (`unicodedata.normalize("NFC", prefix)`): the
iteration matches keys against it character for character, and raises
`ValueError` when the service lists a key under another form of it.

### `files.stat(key)` / `files.exists(key)`

`stat(key, namespace="default")` returns the object's `ObjectInfo` without
transferring it, and raises `NO_SUCH_OBJECT` when there is none; `exists(key,
namespace="default")` returns a bool, and raises on any other failure.

### `files.delete(key)` / `files.copy(key, to)` / `files.move(key, to)`

`delete(key, namespace="default")`; `copy(key, to, namespace="default",
to_namespace=None)` and `move(key, to, namespace="default", to_namespace=None)`
return the `ObjectInfo` of the object at `to`, in `to_namespace` (default: the
source's namespace).

`move` is copy-then-delete on the client (the service has no move verb), so it
is **not atomic** — a failed delete leaves both copies.

### `files.put_file(key, path)` / `files.get_to_file(key, path)`

`put_file(key, path, namespace="default", content_type=None)` and
`get_to_file(key, path, namespace="default")` are convenience wrappers over a
local file; both return the object's `ObjectInfo`. Their file work runs off the
event loop, in a thread of the call's own.

`get_to_file` creates missing parent directories and writes the file in place,
as `open(path, "wb")` does, once the object's first bytes are in hand: a refused
read (no such object, not authorized) leaves the path untouched, while a read
that fails part-way or is cancelled leaves what was written until then. A FIFO
is written as the object streams in: opening it waits until a reader opens it,
so a FIFO nobody reads blocks the call (not the event loop); `asyncio.wait_for`
can give it up.

`put_file` streams a file over the inline limit from disk; a smaller one is read
and stored like `put()`. A FIFO is read to its end, into memory, and then
stored: opening it waits until a writer opens it, so a FIFO nobody writes blocks
the call (not the event loop); `asyncio.wait_for` can give it up, and nothing is
stored then.

### `files.url(key, namespace="default", version=None)`

The permanent URL. Returns `None` where the deployment has no HTTP edge (a
plain-HTTP appliance) — the signal to fall back to `files.get()`. Pass an ETag as
`version` to let browsers cache it immutably; it is percent-encoded as a query
value, as JavaScript's `encodeURIComponent` encodes one (an ETag comes in double
quotes). The key keeps its `/` separators and the characters
`A-Z a-z 0-9 - . _ ~`; every other byte is percent-encoded, as in the Go and JavaScript SDKs.
`namespace=""` is the default namespace, as in every call. The file edge answers
the URL of an object in a namespace declared `share: none` with 404 — read such
objects with `files.get()`.

### `files.cloud_url(key, namespace="default", version=None)`

The permanent URL through an appliance's cloud files tunnel
(`https://i<instanceKey>-files.<cloud-edge>`): reachable from the internet while
the appliance is online, with the same authorization as `files.url()`. Returns
`None` on cloud-hosted deployments (`files.url()` is internet-reachable there),
where there is no HTTP edge, and while the data backend's cloud-share toggle is
off. The user switches that toggle at any time, so `cloud_url()` re-reads the
catalog when the cached one is older than 30 s (a failed re-read raises; the
next call tries again).

### `files.usage(detail=False)`

What the object store reports, in one call: `size_bytes`, `object_count`,
`quota_bytes` (the **enforced** budget) and `free_bytes`. `free_bytes` is `-1`
when there is no quota — a quota of `0` means unlimited, and `0` free would read
as full.

Pass `detail=True` for a `per_namespace` breakdown. That costs one listing per
namespace (the store accounts per bucket; a namespace is a prefix), so it is off
by default.

### `files.namespaces()` / `files.catalog()`

What this app may use. `namespaces()` returns the catalog's `NamespaceInfo`s:
`name`, `description`, `private` (hidden from consuming apps), `content_types`
(the MIME patterns it accepts — an exact type, `type/*` or `*/*`; `None` or empty
accepts any) and `max_object_bytes` (the cap on one object; `0` when the catalog
reports none). `catalog()` reports `quota_bytes` (**enforced**, read from
the object store) alongside `suggested_quota_bytes` (what the app's data template
asked for) — the quota is a user setting, so those two can differ. `catalog()` also reports
`inline_max_bytes`, `list_max_limit` (the largest page `list()` gets, 1000),
`presign_available`, `presign_max_bytes` (the largest single direct upload),
`sad_key` (the data backend's key, a segment of object URLs), `public_base_url`
and `cloud_base_url`; it is cached after the first call.
`quota_bytes` and `cloud_base_url` are user settings that change at any time, so they are as
of the last fetch: `usage()` reports the current quota, `cloud_url()` re-reads the catalog when
it is older than 30 s, and `catalog(refresh=True)` re-fetches it (also after the data template
changes).

### `files.share_url(key, namespace="default", ttl=900)`

An expiring link anyone holding it can fetch. Unlike `files.url()` this is a
**bearer capability** — nothing re-checks authorization when it is used. Hand it
to a person; do not store it in a column. The server clamps `ttl`.

### `files.upload_url(key, namespace="default", ttl=3600, content_type=None, size=None)`

An expiring URL that accepts a direct upload. Returns `url`, `method`, `headers`
and `expires_in`; send the headers as given, so the object is stored with the
minted `Content-Type`. The namespace's content types and size cap, a declared
`size` and the quota are checked when the URL is minted, not when it is used (it
is signed for the host only): anyone holding it can write any content to its key
until it expires. Treat it as a bearer write capability.

### Large objects

The SDK picks the transport by **size**, automatically:

| Size | Path |
| --- | --- |
| ≤ `inline_max_bytes` (6 MiB) | one WAMP call |
| larger | direct to object storage over HTTPS, bypassing the router; reads fall back to ranges over the router where that is not possible |

`put_file()` and `get_to_file()` stream from and to disk, so a multi-gigabyte
object never has to fit in memory. `put()` and `get()` work on `bytes` and
therefore do hold the object in memory — prefer the file variants above a few
hundred MB. A namespace takes objects up to 100 MiB unless the data template's
`maxObjectBytes` raises its cap, to at most 5 GiB.

The direct path needs a deployment that can presign URLs and a device that can
reach the object store host, not just the router. Where either is missing — the
cached catalog says the deployment cannot presign (`presign_available` is false
on an air-gapped appliance, and by default on any appliance), minting the read
URL answers `NOT_SUPPORTED`, or the object store cannot be reached at all
(`PRESIGN_UNREACHABLE`, before any byte arrived) — `get()` and `get_to_file()`
read the object in ranges over the router instead (FleetFiles v0.1.33 and
later), each at most `inline_max_bytes`. Each range is a read of its own: a read
during which the object is replaced fails with `INTERNAL` ("object changed while
it was read") and is not retried. A file service from before ranged reads
refuses the ranges too, and the read fails with the direct path's error
(`TOO_LARGE` when the catalog said the deployment cannot presign).

Writes have no such fallback. Two ceilings remain, and both raise `TOO_LARGE`
with a reason that says which one you hit:

- **5 GiB** — S3's single-upload limit. Multipart is not implemented yet.
- **`inline_max_bytes` where there is no direct endpoint** — a deployment that
  cannot presign cannot store a larger object at all. The message says so,
  because no retry or smaller chunk can help.

Two field failures of the direct path have their own codes rather than looking
like auth problems: `PRESIGN_UNREACHABLE` (a proxy allowing only the router) and
`CLOCK_SKEW` (S3 rejects requests more than 15 minutes out of step — check NTP).

### `FileStoreError`

A failure of the file service or of a direct transfer raises `FileStoreError`
with a stable `.code` and a human-readable `.reason`. **Branch on `.code`, never
on `.reason`.** Local file errors, a cancelled call and router-level WAMP errors
other than the ones mapped below are raised as they are.

```python
from ironflock.filestore import FileStoreError

try:
    await ironflock.files.put("huge.bin", payload)
except FileStoreError as e:
    if e.code == "QUOTA_EXCEEDED":
        ...
```

| Code | Meaning |
| --- | --- |
| `NOT_AUTHORIZED` | Caller may not perform this operation |
| `NO_SUCH_NAMESPACE` | Namespace is not declared in the data template |
| `NO_SUCH_OBJECT` | Key does not exist |
| `TOO_LARGE` | Exceeds the single-call transfer limit |
| `OBJECT_TOO_LARGE` | Exceeds the namespace's own `maxObjectBytes` |
| `QUOTA_EXCEEDED` | Filestore is full |
| `CONTENT_TYPE_NOT_ALLOWED` | Namespace restricts `contentTypes` |
| `INVALID_KEY` | Malformed key or prefix (see below; FleetFiles v0.2.0 reports `INTERNAL`) |
| `INVALID_RANGE` | A byte range outside the object; the SDK's reads do not surface it |
| `NOT_SUPPORTED` | Backend cannot do this |
| `NOT_AVAILABLE` | No file service on this deployment, or not yet: after a restart, or while it registers its procedures |
| `PRESIGN_UNREACHABLE` | Object store not reachable directly (proxy?) |
| `CLOCK_SKEW` | Device clock too far out of step for S3 |
| `INTERNAL` | Anything else |

A newer server may add codes; unknown ones pass through as `.code` rather than
crashing, so treat anything unrecognised as a generic failure.

File calls wait up to 10 s for the connection and are not retried: unlike table
operations they do not use `reconnect_window`, so during a platform restart a
call can fail with `NOT_AVAILABLE` until the file service has registered again —
as it does whenever it binds the data backend anew. Such a call ran nowhere, so
an app may repeat it.

The file service is the authority on keys: the SDK sends keys and prefixes as
given. A malformed key or prefix — empty; with a leading or trailing `/`; with
an empty, `.` or `..` segment, or a segment ending in `.` or starting or ending
with a space; with one of `\ : * ? " < > | ^ ~`, a control or a Unicode format
character; over 900 bytes, 20 segments or 255 bytes per segment; or under the
reserved `_sys/` prefix — is refused with `INVALID_KEY` by a service that
classifies key errors, and with `INTERNAL` by FleetFiles v0.2.0. Mind
timestamps: `2026-10-09T12:00:00Z` is not a valid key segment (`:`).

## Advanced Usage

If you need more control, `ironflock.connection` is the underlying `CrossbarConnection`;
[crossbar_connection_example.py](https://github.com/IronFlock/sdk-py/tree/main/examples/crossbar_connection_example.py)
shows it used directly. The rules in [URIs an app may use](#uris-an-app-may-use) apply to it as well.

`ironflock` also exports `Stage`, the enum `CrossbarConnection.configure()` takes as `stage`
(`Stage.DEVELOPMENT` is `"DEV"`, `Stage.PRODUCTION` `"PROD"`), `ERROR_LOGS_TABLE` (`"error-logs"`,
the table `report_error` writes to) and `__version__` (the installed package's version; `"0.0.0"`
when the package is not installed).


## Development

This project uses [uv](https://docs.astral.sh/uv/) for dependency management and building.

Install uv if you don't have it:

```shell
curl -LsSf https://astral.sh/uv/install.sh | sh
```

Install dependencies (including dev dependencies):

```shell
uv sync --extra dev
```

Run tests:

```shell
just test-unit    # Run unit tests only
just test         # Run all tests
just test-docker  # Run integration tests with Docker
```

Build and publish a new pypi package:

```shell
just publish
```

Or manually:

```shell
# Clean previous builds
rm -rf dist

# Build the package
uv build

# Upload to PyPI
uv publish
```

Check the package at https://pypi.org/project/ironflock/.

## Test Deployment

To test the package before deploying to PyPI you can use test.pypi.

```shell
just publish-test
```

Or manually:

```shell
uv build
uv publish --publish-url https://test.pypi.org/legacy/
```

Once the package is published you can install it from TestPyPI:

```shell
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ironflock
```

Once the package is published you can use it in other code by putting
these lines at the top of the requirements.txt

```
--index-url https://test.pypi.org/simple/
--extra-index-url https://pypi.org/simple/
```

