Skip to content

Python SDK

openhal.client is the sync and async client of the hub's local API v1, used by scripts, the CLI and the MCP server. Safety checks run in the hub's kernel, and a blocked command comes back as a Decision.

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

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 first, so a wrong address or token fails right away. check=False skips the 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
homeOPENHAL_HOME, then ~/.openhal

Pass use_human_token_file=False for anything that acts for an agent, so the client never falls back to the human token file. The client uses no proxy environment variables, so a token never goes through a proxy.

Exceptions

Every exception derives from openhal.client.OpenHALError, with message, status_code and detail.

ExceptionWhen
HubNotRunningNo hub at the address. The message names the command that starts the hub
UnauthorizedNo token, or a token the hub doesn't accept. The message names the token's source
NotFoundUnknown device, channel, request, routine or session
NotSupportedThe hub is older and has no such route. A subclass of NotFound
RefusedThe hub blocked the request, such as an approval with an agent token or a request no longer pending
BadRequestAn unreadable 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 for a dry run, blocked, pending or needs_confirmation, failed
codeStable block code, such as above_max, rate_limited, interlock, not_permitted, stopped, misconfigured, queue_full
messageWhat happened and the next step
value, unitThe value in the channel's unit
limitThe blocking limit, with its unit
request_idSet when a person has to approve

decision.ok is true for done and allowed, and decision.pending when the request is held 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'}. A cancel of the print comes back pending, since a person approves each cancel.

Images

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

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())

In device views content() is empty, since views carry no pixels.

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
risk(device), every channel's and action's level with the reasonsGET /v1/devices/{id}/risk
set_risk(device, name, level, phrase=None), raising at once and lowering with the phrase or a person's approvalPOST /v1/risk (human)
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 dict with device, channel and value, or device, action and args, works too.

ramp_to moves a channel to a target in steps that each pass max_change_per_s, interval_s apart, each using margin of the allowed change. The kernel checks every step, the ramp stops at the first blocked step, and ramp_to returns every step's Decision. The LED strip from Add a device changes brightness by at most 40 %/s.

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

Find, wait, sample and runs

MethodRoute
find(text=None, unit=None, kind=None, hazard=None, connected=None, limit=50)GET /v1/find
wait_until(condition, timeout_s=30.0, interval_s=1.0)POST /v1/wait_until
sample(targets, duration_s=, interval_s=1.0)POST /v1/sample
run_steps(steps, repeat=1, wait_s=5.0)POST /v1/runs
run(run_id, wait_s=0.0, last=50)GET /v1/runs/{id}
runs()GET /v1/runs
cancel_run(run_id)POST /v1/runs/{id}/cancel
run_caps()GET /v1/runs/caps
set_run_caps(max_steps=None, max_seconds=None, per_agent=None, phrase=None)POST /v1/runs/caps (human)

find searches every device and returns device.channel and device.action targets with units, limits and latest values. Text also matches what a unit measures, so find("current") finds a channel in A, and unit="degC" finds every temperature channel.

for match in hub.find("current", kind="readable").matches:
    print(match.target, match.value, match.unit)

wait_until reads until the condition holds or the timeout passes. sample reads channels at an interval and returns each series with min, max and mean. Every reading goes through the kernel into the audit log.

result = hub.wait_until("led-strip.current_draw < 1 A", timeout_s=30)
print(result.held, result.readings[0].value)

run_steps checks the steps as a plan, then runs the steps in order. A step is a Write, an Action or a dict whose kind is read, write, action, wait or wait_until, and increment adds that much on each repeat. The kernel checks each command, and the run stops at the first step that isn't done. A step that needs a person ends the run with request_id. The reply comes when the run ends or after wait_s, and run() and cancel_run() follow a longer run.

run = hub.run_steps(
    [
        {"kind": "write", "device": "led-strip", "channel": "power", "value": True},
        {"kind": "wait", "seconds": 0.5},
        {"kind": "write", "device": "led-strip", "channel": "power", "value": False},
        {"kind": "wait", "seconds": 0.5},
    ],
    repeat=3,
)
print(run.status, run.message)

run_caps() shows the caps on steps per run, seconds per run, wait or sample, and how many one token has at once. Only a human token changes the caps, with phrase="confirm" to raise one.

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. A script with the human token can approve, so 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 as dicts, typed hello, reading, state, audit or watchdog.

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 methods.

Models

Responses are pydantic models from openhal.client.models that keep unknown fields, so a newer hub works with an older client. at, started_at and finished_at are Unix seconds. A confirmation request's created_at and expires_at use the hub's monotonic clock and only compare with each other.