Skip to content

Python SDK

openhal.client is a sync and an async client of the hub's local API v1, and scripts, the CLI and the MCP server all use the client. The client makes no safety decisions, because the hub's kernel checks every command, and a blocked command comes back as a Decision rather than as an exception.

uv pip install "openhal-hub @ git+https://github.com/sohan-bhat/OpenHAL"

The install puts the client in the current environment, so your own scripts can import the client.

Connect

from openhal.client import connect

with connect() as hub:
    print(hub.status().devices, "devices")
    reading = hub.read("printer", "bed_temp")
    print(reading.value, reading.unit)

connect() calls /v1/whoami on the hub straight away, so a wrong address or token fails at once with a clear exception, and check=False skips that call.

The async client has the same methods.

import asyncio
from openhal.client import aconnect

async def main() -> None:
    hub = await aconnect()
    async with hub:
        decision = await hub.write("printer", "bed_target", 70, "degC")
        print(decision.status, decision.message)

asyncio.run(main())

Address and token

ArgumentLookup when not given
urlOPENHAL_URL, then api.host and api.port from config.yaml, then http://127.0.0.1:7420
tokenOPENHAL_TOKEN, then $OPENHAL_HOME/human.token (default ~/.openhal/human.token)
homeOPENHAL_HOME, then ~/.openhal

Client(..., use_human_token_file=False) never falls back to the human token file, so pass use_human_token_file=False for anything that acts for an agent. The client ignores proxy environment variables, so a token is never sent through a proxy.

Exceptions

All of these exceptions derive from openhal.client.OpenHALError, which has message, status_code and detail.

ExceptionWhen
HubNotRunningNo hub responds at the hub address. The message says how to start the hub
UnauthorizedNo token, or the hub does not accept it. The message says where the token came from
NotFoundUnknown device, channel, request, routine or session
NotSupportedThe hub is older and has no such route. A subclass of NotFound
RefusedThe hub refused, for example an agent token trying to approve, or a request that is no longer pending
BadRequestThe hub could not read the request, or a wrong unlock phrase
HubErrorThe hub failed or timed out

Decisions

write, action, approve and every entry of dry_run return a Decision.

FieldMeaning
statusdone, allowed (dry run), blocked, pending or needs_confirmation, failed
codeStable block code, for example above_max, rate_limited, interlock, not_permitted, stopped, misconfigured, queue_full
messageWhat happened and what to do next
value, unitThe value in the channel's unit
limitThe limit that blocked it, with its unit
request_idSet when a person has to approve

decision.ok is true for done and allowed, and decision.pending is true when the request waits for a person.

decision = hub.write("printer", "extruder_target", 300, "degC")
if decision.status == "blocked":
    print(decision.code, decision.limit)
elif decision.pending:
    print("approve with openhal confirm approve", decision.request_id)

The request for a nozzle temperature of 300 degC prints above_max {'max': 260.0, 'unit': 'degC'}. Because a person approves each cancel, a cancel of the print comes back pending.

Images

An image channel, such as a camera frame, reads as an Image with mime_type, bytes, width and height. content() gives the image itself.

frame = hub.read("printer-cam", "frame").value
print(frame.mime_type, frame.width, frame.height)
with open("frame.jpg", "wb") as out:
    out.write(frame.content())

Device views carry the same object without the pixels, so content() is empty there.

Methods

Every method exists on Client and, as a coroutine, on AsyncClient.

Hub and devices

MethodRoute
status()GET /v1/status
whoami()GET /v1/whoami
devices()GET /v1/devices
device(device_id)GET /v1/devices/{id}
drivers()GET /v1/drivers

Reading and control

MethodRoute
read(device, channel) or read("device.channel")POST /v1/read
read_many(["device.channel", ("device", "channel")])POST /v1/read_many
write(device, channel, value, unit=None), a number with its unit apartPOST /v1/write
action(device, action, args=None, **kwargs), numbers as {"value": 2, "unit": "s"}POST /v1/action
dry_run([Write(...), Action(...), {...}])POST /v1/dry_run
tighten(device, channel, min=None, max=None, rate=None), each a Quantity mappingPOST /v1/tighten
stop(reason)POST /v1/stop
unlock(phrase)POST /v1/unlock (human)
ramp_to(device, channel, target, unit, interval_s=1.0, margin=0.9)several POST /v1/write

Write and Action are the kernel's command models, exported from openhal.client. A plain dict with device and channel and value, or device and action and args, works too.

ramp_to moves a channel to a target in steps that each pass its max_change_per_s, waiting interval_s before each step, with margin of the allowed change per step. The kernel still checks every step, the ramp stops at the first step the kernel does not accept, and ramp_to returns every decision. The LED strip from Add a device lets brightness change by at most 40 %/s.

for decision in hub.ramp_to("led-strip", "brightness", 80, "%"):
    print(decision.status, decision.message)

Confirmations

MethodRoute
confirmations(include_done=False)GET /v1/confirmations
confirmation(request_id)GET /v1/confirmations/{id}
approve(request_id)POST /v1/confirmations/{id}/approve (human)
reject(request_id, reason="")POST /v1/confirmations/{id}/reject (human)

The library never prompts, and because a script with the human token can approve, the human token file is readable by its owner only. The CLI adds the terminal and typed phrase checks of ADR 0012.

Audit log

MethodRoute
audit(device=None, status=None, event=None, since=0, limit=200)GET /v1/audit
verify_audit()GET /v1/audit/verify

Discovery

MethodRoute
discover(drivers=None)POST /v1/discover
unknown_devices()GET /v1/discover/unknown

Routines

MethodRoute
routines()GET /v1/routines
routine(id)GET /v1/routines/{id}
create_routine(id, source, kind="yaml")POST /v1/routines, always disabled
routine_dry_run(id)POST /v1/routines/{id}/dry_run
enable_routine(id)POST /v1/routines/{id}/enable (human, or an agent under ADR 0007's opt-in)
disable_routine(id)POST /v1/routines/{id}/disable
run_routine(id)POST /v1/routines/{id}/run (human)
delete_routine(id)DELETE /v1/routines/{id} (human)
approve_routine(id, device)POST /v1/routines/{id}/approve (human)

Routine.source is wrapped in a device-data block for models, and Routine.source_raw is the plain text.

Driver writer

MethodRoute
start_writer(unknown_id=, driver=, params=, sketch_path=, manual_text=)POST /v1/writer/sessions
writer_session(id)GET /v1/writer/sessions/{id}
propose(id, draft_yaml)POST /v1/writer/sessions/{id}/propose
interview(id)GET /v1/writer/sessions/{id}/interview
answer(id, answers)POST /v1/writer/sessions/{id}/answers (human)
finish(id, save_as=None)POST /v1/writer/sessions/{id}/finish (human)
test_plan(id)GET /v1/writer/sessions/{id}/test_plan

Tokens and pairing

MethodRoute
new_pairing_code()POST /v1/pair/code (human)
pair(code)POST /v1/pair
tokens()GET /v1/tokens (human)
create_token(name, kind="agent", scope="read")POST /v1/tokens (human)
revoke_token(token_id)DELETE /v1/tokens/{id} (human)

Live events

AsyncClient.events() yields the hub's live events from /v1/live (hello, reading, state, audit, watchdog) as dicts.

async with AsyncClient() as hub:
    async for event in hub.events():
        if event["type"] == "reading":
            print(event["device"], event["channel"], event["value"], event["unit"])

Any route

client.call(Call(...)) sends one request built with openhal.client.calls, for routes newer than the client's methods.

Models

Every response is a pydantic model from openhal.client.models that keeps unknown fields, so a newer hub does not break an older client. Timestamps named at, started_at and finished_at are Unix seconds. The created_at and expires_at of a confirmation request use the hub's monotonic clock and only mean something relative to each other.