This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
PyISY is the original async Python library for Universal Devices ISY-994 / IoX controllers (Polisy, eisy). It is the upstream package on PyPI (pyisy) used by the Home Assistant Core isy994 integration. This is the v3.x.x branch (asyncio + aiohttp). The threaded v2.x.x branch is no longer the default.
This package has a successor, PyISYoX, which is a from-scratch rewrite by the v3 contributor with a different API (entity model, ISYConnectionInfo dataclass, helpers/ submodule, etc.). The HACS beta integration hacs-isy994 consumes PyISYoX. Do not import PyISYoX patterns into PyISY — keep the public API stable since Home Assistant Core depends on it.
# Install dev + test dependencies + editable install
pip install -r requirements.txt -r requirements-dev.txt -r requirements-test.txt
pip install -e .
# Set up pre-commit (CI runs the same)
pre-commit install
pre-commit run --all-files
# Run the offline pytest suite
pytest # full run
pytest --cov=pyisy --cov-report=term-missing
pytest tests/test_nodes.py # one file
pytest --snapshot-update # refresh syrupy snapshots after intentional behavior changes
# Smoke-test against a real ISY (also a usable script template)
python3 -m pyisy http://your-isy-url:80 username password
python3 -m pyisy -h # full options
python3 -m pyisy -v ... # verbose logging
python3 -m pyisy -q ... # no event stream
python3 -m pyisy -n ... # also load node servers
# Build docs
cd docs && make htmlCI (.github/workflows/ci.yml) runs two jobs: pre-commit (lint) and tests (pytest on Python 3.11 and 3.14, the HA-supported range). Coverage prints to the workflow log; there's no external coverage service wired up. The suite is fully offline — fixtures under tests/fixtures/ are anonymized real-controller XML and tests/conftest.FakeConnection serves them, so no live ISY is required. Snapshots live under tests/__snapshots__/*.ambr and are tracked. For behaviors that genuinely need a controller (event stream / websocket reconnect, REST round-trips), still validate via python3 -m pyisy ....
Lint stack (all run by pre-commit):
- ruff (lint + format) — config in
pyproject.toml, targetpy39, line length 110 - pyupgrade (
--py39-plus) - codespell with an ISY-specific ignore list (
isy,nid,dof,don,BATLVL, etc. — extend it if codespell flags a real ISY term) - yamllint, prettier
pyisy/isy.py — the ISY class is the single object users construct. Construction does no I/O. await isy.initialize() is required and performs:
conn.test_connection()→ parses/rest/configinto aConfiguration.- If the model is not
ISY 994, callsconn.increase_available_connections()— IoX hardware allows far more concurrent connections than ISY-994 (the connection semaphore is sized accordingly inconnection.py). - Fires off
asyncio.gather()for status, time, nodes, programs, variable defs, variables, and (conditionally) network resources, then constructsClock,Nodes,Programs,Variables,NetworkResourcesfrom the resulting XML. await self.nodes.update(xml=...)applies status to nodes.
await isy.shutdown() stops the websocket / event stream and closes the aiohttp session.
Connection owns the aiohttp session and all REST I/O. Notable details:
- Uses an asyncio Semaphore to enforce ISY-994's strict simultaneous-connection limit (2 HTTPS / 5 HTTP);
increase_available_connections()raises it for IoX. request()handles retries with backoff, swallows expected 404s whenok404=True, and force-decodes responses as UTF-8 witherrors="ignore"(ISY firmware emits invalid UTF-8 in some node names — see CHANGELOG #126).request(retry404=True)makes the 404 branch fall into the retry/backoff loop instead of returningNone. Use this from command-issuing callers (NodeBase.send_cmd,Folder.send_cmd,Variable.set_value,ISY.query,ISY.send_x10_cmd) — the ISY-994 firmware emits spurious 404s on/rest/nodes/.../cmd/...when its Insteon network is overwhelmed (#184). Don't enable on read paths; the existing retry budget is bounded (5 retries, ~3.4s) but multiplying that across status polls is wasteful.get_new_client_session(use_https, tls_ver)andget_sslcontext()are module-level helpers because the ISY-994 supports legacy TLS 1.1 and weak ciphers; consumers (HA Core) need to obtain a session compatible with the device.
Two mutually-exclusive transports for real-time updates:
websocket.WebSocketClient— preferred, opt in viaISY(use_websocket=True). Started/stopped explicitly withisy.websocket.start()/.stop().tcpsocket.EventStream— legacy SOAP-over-TCP socket subscription. Lazily constructed whenisy.auto_update = Trueis set. Reconnect is handled by a daemonThread(_auto_reconnecterinisy.py) that recreates theEventStreamon disconnect.
Setting auto_update while websockets are enabled is a no-op with a warning — the two paths must not be mixed. Connection-state notifications go through isy.connection_events (an EventEmitter) using the ES_* constants (ES_CONNECTED, ES_RECONNECTING, ES_RECONNECT_FAILED, etc.).
eventreader.ISYEventReader parses raw event XML; the ISY class itself dispatches system_status_changed_received() and node manager _node_changed_received() from parsed events.
Each platform module is a dict-like collection that owns its entities and exposes an EventEmitter for changes:
nodes/—Nodesmanages bothNode(devices) andGroup(scenes);nodebase.NodeBaseis the shared base.NodeChangedEvent(innodes/__init__.py) is the payload published onisy.nodes.status_events. Group/Scene status excludes Insteon battery devices and stateless controllers (ControlLinc / RemoteLinc v1) — see CHANGELOG #156, #256.programs/—Programsindexes bothProgramandprograms.folder.Folder. Folders expose a.leafproperty used to invoke folder-level run actions.variables/—Variablesis a 2-level mapping:isy.variables[type][id]where type1= integer,2= state.networking.NetworkResources/NetworkCommand— only loaded whenConfiguration[CONFIG_NETWORKING]orCONFIG_PORTALis set.node_servers.NodeServers— Polyglot node server definitions; loaded only ifwith_node_servers=Trueis passed toinitialize().clock.Clock— derived from/rest/time; provides sunrise/sunset/tz info.configuration.Configuration— dict-like wrapper around the parsed/rest/configXML; consult it for feature flags before triggering optional REST calls.
helpers.pycontainsEventEmitter/EventListener(the pub/sub primitives every manager uses) plus the XML-walking utilities (value_from_xml,attr_from_xml,attr_from_element, …). Most parsing in this codebase still usesxml.dom.minidomdirectly.constants.pyis large (commands, URLs, UOM tables, node families, system-status codes,ES_*connection events,NODE_CHANGED_ACTIONS,X10_COMMANDS). Add new constants here rather than scattering literals.logging.py—_LOGGERis the package-wide logger under thepyisynamespace; events use a separatepyisy.eventslogger so consumers can filter. Callenable_logging()once at startup.
- Public API stability:
pyisy.ISY,pyisy.connection.Connection, the manager classes, and theISY*Errorexceptions inpyisy/__init__.pyare consumed by Home Assistant Core. Renaming or changing signatures is a breaking change. If you find yourself wanting a different shape, that work belongs in PyISYoX, not here. - No presumptive status updates: since v3.0.0, sending a command does not optimistically update
node.status. Consumers that don't run an event stream are expected to callnode.update(wait_time=UPDATE_INTERVAL)themselves. - UOM/precision sentinel:
ISY_PROP_NOT_SET = "-1"distinguishes "node has no ST property" fromISY_VALUE_UNKNOWN("status not yet reported"). Don't conflate them — see CHANGELOG v2.1.0 #98. - XML decoding: always go through
Connection.request()so the UTF-8-with-ignore decode is applied. Don't read aiohttp responses directly. - Ruff ignores in
pyproject.tomlare deliberate (e.g.S101,SLF001,PLR091*); don't try to fix what's listed there as part of unrelated work. ISYResponseParseErroris the canonical "controller returned bad/missing data" signal. Everyparse()method raises it onXML_ERRORS, andISY.initialize()raises it when load-bearing setup responses (status, time, nodes, programs) areNoneso HA Core can convert that intoConfigEntryNotReadyand retry instead of silently mounting an empty controller (see #297).- Test fixtures must stay anonymized. Anything added or refreshed under
tests/fixtures/must have Insteon prefixes randomized (the 3-byte device prefix; keep the 4th byte so subnode/group references stay consistent acrossnodes.xml/status.xml/programs.xml), personal names replaced, and lat/long zeroed. Z-Wave addresses (ZY007_1) and folder/group integer ids are not Insteon device addresses — leave them. A reproducible scrubber lives outside the repo at~/src/.devcontainer_shared/home-assistant-core/isy994_stashed/anonymize_insteon_addresses.py; re-run with the same seed if you need new captures. - Test design. Tests use
tests.conftest.FakeConnection(serves canned XML fromtests/fixtures/) plus abuild_isy(nodes_xml, status_xml)factory for feature-specific scenarios. Action-method tests (climate, lock, program/folder, node commands) replaceisy.conn.requestwith anAsyncMockand assert the URL it was invoked with — the connection's realcompile_urlruns so URL-encoding regressions surface. Snapshots (syrupy) capture only structural/feature-flag summaries, never raw fixture contents.
v3.x.x— current default branch; async, what every consumer ships.v2.x.x— legacy synchronous/threaded line; only fix here if explicitly asked.
- ReadTheDocs: https://pyisy.readthedocs.io
- Source: https://github.com/automicus/PyISY
- Successor library: PyISYoX (separate repo) — different package name, different API.
- Downstream consumer: Home Assistant Core
isy994integration.