From 7a9adfa3e89aa1d47474b41238e649d93fb356ac Mon Sep 17 00:00:00 2001 From: ietxaniz Date: Fri, 2 Oct 2026 17:33:24 +0000 Subject: [PATCH 1/2] Initial commit --- README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e17589f --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# i7lib + +i7 Python library for scripts: signals through the sidecar, customer database, time helpers \ No newline at end of file From 751c25df5b19bbcf08d42c66bac6bb4b8c196351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1igo=20Etxaniz?= Date: Fri, 2 Oct 2026 19:48:12 +0200 Subject: [PATCH 2/2] Initial content, migrated from the aml gateway rpi7-am-1 --- .gitignore | 6 ++ README.md | 164 ++++++++++++++++++++++++++++++++++++++++- pyproject.toml.tmpl | 17 +++++ src/i7lib/__init__.py | 5 ++ src/i7lib/client.py | 88 ++++++++++++++++++++++ src/i7lib/dbutils.py | 79 ++++++++++++++++++++ src/i7lib/timeutils.py | 14 ++++ 7 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 pyproject.toml.tmpl create mode 100644 src/i7lib/__init__.py create mode 100644 src/i7lib/client.py create mode 100644 src/i7lib/dbutils.py create mode 100644 src/i7lib/timeutils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c97096 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +dist/ +build/ +*.egg-info/ +__pycache__/ +*.pyc +pyproject.toml diff --git a/README.md b/README.md index e17589f..6f26c2f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,165 @@ # i7lib -i7 Python library for scripts: signals through the sidecar, customer database, time helpers \ No newline at end of file +Python library for scripts running in an i7 job container. It gives a script three things: +plant signals (read and write) through the job's sidecar, the customer database through the +db-controller, and millisecond time helpers. + +## Availability + +Inside a job container the library is already importable: it is installed in the job's image, +built from the `python-base` repository, whose `local-requirements.txt` names the `i7lib` +version. Nothing is mounted from the gateway, so a new `i7lib` version reaches a script only +through a new image tag. Outside a job, install the wheel published to this org's gitea PyPI +index. The only hard dependency is `requests`; `python-dotenv` is optional and loaded if +present, so a local `.env` can supply the two environment variables below. + +## Endpoints and environment + +| Variable | Default | Injected value in a job | +|---|---|---| +| `SIDECAR_URL` | `http://localhost:80` | `http://sidecar:80` | +| `DB_CONTROLLER_URL` | `http://127.0.0.1:13213` | `http://sidecar:8081` | + +The defaults are for running a script on your own machine. In a job both are set for you, so +a script never hardcodes them. + +## Quick start + +```python +from i7lib import run + +ID_OUTPUT = 49 + +def logic(plant): + temperature = plant.state.get("oven-temperature", 0.0) + batch = plant.new_write_batch() + batch.add(ID_OUTPUT, temperature * 2.0, "oven-output") + plant.send(batch) + +if __name__ == "__main__": + run(logic, interval_ms=1000, offset_ms=200) +``` + +`from i7lib import ...` re-exports the public API; `from i7lib.client import run` and the +other submodule paths work equally well. + +## Signals + +Not every signal is available to every job. `plant.state` holds only the signals the job's +sidecar exposes to it, keyed by both id and name. Which those are is configured for the job +in its `signals.yml`; the names used as keys are the ones written there. + +A signal that is not exposed is simply absent from `plant.state` — reading it does not raise. +Guard with `in` or `.get()` rather than assuming a key is there. + +## i7lib.client + +### `Plant` + +Holds the last state read from the sidecar. + +| Attribute | Contents | +|---|---| +| `state` | value by signal id **and** by signal name, for every signal that has a name | +| `instants` | timestamp of each value, keyed the same way | +| `signals` | the raw list returned by the sidecar | + +A signal present but carrying no value reads as `nan`; its instant defaults to `0`. + +**`read_all()`** — reads every subscribed signal once. Returns `state`, or `{}` on failure. + +**`read_sync(interval_ms, offset_ms)`** — blocks until the sidecar's next sample aligned to +`interval_ms`, offset by `offset_ms`, then returns `state`, or `{}` on failure. This is the +call `run` uses; it is what keeps a script in step with the sample clock instead of drifting. + +**`new_write_batch()`** — returns an empty `WriteBatch`. + +**`send(batch)`** — posts the batch. Does nothing if the batch is empty. + +### `WriteBatch` + +**`add(signal_id, value, name="")`** — queues one write. `signal_id` is coerced to `int`, +`value` to `float`, `name` to `str`. + +A batch is **all or nothing**. If the sidecar does not recognise one of the ids in it, the +entire batch is rejected and none of the writes land. An id it does recognise but which is +not writable is skipped without failing the batch. Neither outcome is visible to the caller — +see Error handling below. + +### `run(logic_func, interval_ms=1000, offset_ms=200)` + +The main loop. Creates a `Plant`, and on every cycle calls `read_sync` and then +`logic_func(plant)`. It never returns. + +Two behaviours to know. When a read fails or comes back empty the cycle is skipped, the loop +sleeps one second and `logic_func` is not called at all. And an exception raised inside +`logic_func` is caught and printed, then the loop sleeps one second and continues — a script +does not die on a bad tick, so a persistent bug shows up as repeated log lines rather than a +crash. + +## i7lib.dbutils + +### `Database()` + +Talks to the db-controller at `DB_CONTROLLER_URL`. + +| Method | Returns | +|---|---| +| `get_row(table, row_id)` | the row, or `None` | +| `insert(table, data)` | `{'id': }`, or `None` | +| `query(table, filters=None, order_by=None, limit=100)` | list of rows, or `[]` | +| `update(table, row_id, data)` | `True` / `False` | +| `list_tables()` | list of table names, or `[]` | +| `list_columns(table)` | list of columns, or `[]` | + +Table and column names come from the customer schema, not from this library. + +These calls set no timeout, unlike the signal calls, so a db-controller that accepts a +connection and then stalls will block the script. Keep database work off the hot path of a +fast `interval_ms` loop. + +## i7lib.timeutils + +**`now()`** — current time in Unix milliseconds, the format used throughout the platform. + +**`now_sec()`** — the same, floored to the second. + +**`to_datetime(unix_milli)`** — converts back to a local `datetime`, for logging. + +## Versioning and the pyproject template + +There is no `pyproject.toml` in this repository — it is generated from +`pyproject.toml.tmpl`, whose version line reads `version = "{{vless .TagName}}"`. +gitea-sidecar renders every `*.tmpl` in the source to its name minus the suffix before +building the wheel, so a tag of `v0.1.5` publishes version `0.1.5`; `vless` strips the +leading `v` that git wants and PEP 440 forbids. The tag is the only place a version is +written, and the published wheel cannot disagree with the tag that produced it. + +Do not put a hardcoded version back into the template: the build would keep producing that +one version, and the next tag would fail with a 409 because the package file already +exists. + +**Building locally** needs the same render step, since the file the build tools look for is +not in the repository: + +```bash +sed 's/{{vless .TagName}}/0.0.0.dev0/' pyproject.toml.tmpl > pyproject.toml +``` + +`pyproject.toml` is gitignored, so a local copy never reaches a build. + +## Error handling + +Every method in this library catches its own exceptions, prints a line, and returns an empty +or falsy result. Nothing raises, and no method reports which signal or row failed. + +The practical consequences: `read_all` and `read_sync` returning `{}` mean "failed" and +"nothing subscribed" alike; `query` and `list_tables` returning `[]` mean "failed" and "no +rows" alike; `send` returning normally does **not** mean the writes were applied. A batch +rejected in full for one unknown id looks exactly like a successful send from inside the +script. + +So when a value is not moving, check the job's own output first — that is where this library +prints its errors. If nothing appears there, the write reached the platform and was rejected +or dropped further in, which is visible only in the platform-side logs. Do not infer success +from the absence of an exception. diff --git a/pyproject.toml.tmpl b/pyproject.toml.tmpl new file mode 100644 index 0000000..f5e5f0b --- /dev/null +++ b/pyproject.toml.tmpl @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "i7lib" +version = "{{vless .TagName}}" +description = "i7 library for scripts" +readme = "README.md" +requires-python = ">=3.8" +dependencies = ["requests>=2.31"] + +[project.optional-dependencies] +dotenv = ["python-dotenv>=1.0"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/i7lib/__init__.py b/src/i7lib/__init__.py new file mode 100644 index 0000000..eca5cb3 --- /dev/null +++ b/src/i7lib/__init__.py @@ -0,0 +1,5 @@ +from . import timeutils +from .client import Plant, WriteBatch, run +from .dbutils import Database + +__all__ = ["Database", "Plant", "WriteBatch", "run", "timeutils"] diff --git a/src/i7lib/client.py b/src/i7lib/client.py new file mode 100644 index 0000000..d353b33 --- /dev/null +++ b/src/i7lib/client.py @@ -0,0 +1,88 @@ +import os +import time +import math +import requests + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + +SIDECAR_URL = os.getenv("SIDECAR_URL", "http://localhost:80") + +class WriteBatch: + def __init__(self): + self.payload = [] + + def add(self, signal_id, value, name=""): + self.payload.append({ + "id": int(signal_id), + "value": float(value), + "name": str(name) + }) + +class Plant: + def __init__(self): + self.state = {} + self.instants = {} + self.signals = [] + + def _parse(self, data): + self.signals = data + self.state = {} + self.instants = {} + for item in data: + val = item.get("value", math.nan) + instant = item.get("instant", 0) + self.state[item["id"]] = val + self.instants[item["id"]] = instant + if item.get("name"): + self.state[item["name"]] = val + self.instants[item["name"]] = instant + + def read_all(self): + try: + res = requests.get(f"{SIDECAR_URL}/api/read/", timeout=2) + res.raise_for_status() + self._parse(res.json().get("data", [])) + return self.state + except Exception as e: + print(f"Error reading state: {e}") + return {} + + def read_sync(self, interval_ms, offset_ms): + try: + timeout_sec = (interval_ms / 1000.0) + 2.0 + res = requests.get(f"{SIDECAR_URL}/api/read/{interval_ms}/{offset_ms}/", timeout=timeout_sec) + res.raise_for_status() + self._parse(res.json().get("data", [])) + return self.state + except Exception as e: + print(f"Error reading state: {e}") + return {} + + def new_write_batch(self): + return WriteBatch() + + def send(self, batch): + if not batch.payload: + return + try: + res = requests.post(f"{SIDECAR_URL}/api/write/", json=batch.payload, timeout=2) + res.raise_for_status() + except Exception as e: + print(f"Error sending batch: {e}") + +def run(logic_func, interval_ms=1000, offset_ms=200): + print(f"Connected to: {SIDECAR_URL} (Sync: {interval_ms}ms, Offset: {offset_ms}ms)") + plant = Plant() + while True: + try: + if not plant.read_sync(interval_ms, offset_ms): + time.sleep(1.0) + continue + logic_func(plant) + except Exception as e: + print(f"Logic Error: {e}") + time.sleep(1.0) diff --git a/src/i7lib/dbutils.py b/src/i7lib/dbutils.py new file mode 100644 index 0000000..ac024b4 --- /dev/null +++ b/src/i7lib/dbutils.py @@ -0,0 +1,79 @@ +import os +import requests + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + +DB_URL = os.getenv("DB_CONTROLLER_URL", "http://127.0.0.1:13213") + +class Database: + def __init__(self): + self.base_url = f"{DB_URL}/api" + + def get_row(self, table, row_id): + """GET /api/db/:table/:id/""" + try: + res = requests.get(f"{self.base_url}/db/{table}/{row_id}/") + res.raise_for_status() + return res.json().get("data") + except Exception as e: + print(f"DB Read Error: {e}") + return None + + def insert(self, table, data): + """POST /api/db/:table/""" + try: + res = requests.post(f"{self.base_url}/db/{table}/", json=data) + res.raise_for_status() + return res.json().get("data") # Returns {'id': lastID} + except Exception as e: + print(f"DB Insert Error: {e}") + return None + + def query(self, table, filters=None, order_by=None, limit=100): + """POST /api/db-query/:table/""" + payload = { + "filters": filters or [], + "orderBy": order_by or [], + "limit": limit + } + try: + res = requests.post(f"{self.base_url}/db-query/{table}/", json=payload) + res.raise_for_status() + return res.json().get("data") + except Exception as e: + print(f"DB Query Error: {e}") + return [] + + def update(self, table, row_id, data): + """PUT /api/db/:table/:id/""" + try: + res = requests.put(f"{self.base_url}/db/{table}/{row_id}/", json=data) + res.raise_for_status() + return True + except Exception as e: + print(f"DB Update Error: {e}") + return False + + def list_tables(self): + """GET /api/db-meta/tables""" + try: + res = requests.get(f"{self.base_url}/db-meta/tables") + res.raise_for_status() + return res.json().get("data") + except Exception as e: + print(f"DB List Tables Error: {e}") + return [] + + def list_columns(self, table): + """GET /api/db-meta/tables/:table/columns""" + try: + res = requests.get(f"{self.base_url}/db-meta/tables/{table}/columns") + res.raise_for_status() + return res.json().get("data") + except Exception as e: + print(f"DB List Columns Error: {e}") + return [] diff --git a/src/i7lib/timeutils.py b/src/i7lib/timeutils.py new file mode 100644 index 0000000..59c8259 --- /dev/null +++ b/src/i7lib/timeutils.py @@ -0,0 +1,14 @@ +import time + +def now(): + """Returns current time in Unix Milliseconds (standard JS/App format)""" + return int(time.time() * 1000) + +def now_sec(): + """Returns current time in Unix Milliseconds, but floored to the second""" + return (int(time.time()) * 1000) + +# If you need to convert a JS-style milli back to a Python datetime for local logging +def to_datetime(unix_milli): + from datetime import datetime + return datetime.fromtimestamp(unix_milli / 1000.0)