|
|
@@ -1,3 +1,168 @@
|
|
|
|
# i7lib
|
|
|
|
# i7lib
|
|
|
|
|
|
|
|
|
|
|
|
i7 library for scripts
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Versioning and the pyproject template
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
There is no `pyproject.toml` in this repository. It is generated from
|
|
|
|
|
|
|
|
`pyproject.toml.tmpl`, and its version comes from the git tag that triggered the build:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```toml
|
|
|
|
|
|
|
|
version = "{{vless .TagName}}"
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gitea-sidecar renders every `*.tmpl` file in the source to its name minus the suffix before
|
|
|
|
|
|
|
|
building the wheel, so a tag of `v0.1.1` produces version `0.1.1` — `vless` strips the
|
|
|
|
|
|
|
|
leading `v`, which git wants and PEP 440 does not. `.TagName` and `.CommitID` are also
|
|
|
|
|
|
|
|
available for anything else worth stamping.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The tag is therefore the only place a version is written. Do not add a hardcoded version
|
|
|
|
|
|
|
|
back into the template: the build would keep producing that one version, and publishing a
|
|
|
|
|
|
|
|
second tag would fail 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Availability
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Inside a job container the library is already importable — the scheduler mounts the SDK
|
|
|
|
|
|
|
|
folder and sets `PYTHONPATH`. Outside one, install the wheel published to your 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;
|
|
|
|
|
|
|
|
a script does not declare them.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 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.
|
|
|
|