Initial content, migrated from the aml gateway rpi7-am-1
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
pyproject.toml
|
||||
@@ -1,3 +1,165 @@
|
||||
# i7lib
|
||||
|
||||
i7 Python library for scripts: signals through the sidecar, customer database, time helpers
|
||||
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': <new 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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from . import timeutils
|
||||
from .client import Plant, WriteBatch, run
|
||||
from .dbutils import Database
|
||||
|
||||
__all__ = ["Database", "Plant", "WriteBatch", "run", "timeutils"]
|
||||
@@ -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)
|
||||
@@ -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 []
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user