Ask anyone who has owned two smart home hubs why they replaced the first one, and the answer is rarely the hardware: it is the model. Every hub invents its own vocabulary for “a lamp,” ships its own rules language, and refuses to talk to the neighbors’ protocols — so your Z-Wave stick, your Hue bridge and your MQTT sensors each need a separate integration with a separate device model, and when a vendor sunsets a hub the whole abstraction dies with it. openHAB’s bet, refined over fifteen years, is that this problem is structural: put one device model and one event bus inside a Java process, and make every driver a replaceable module around them.

That bet is visible in the repository this post walks: openhab/openhab-core, branch main at HEAD 15c6da97b24e (checked 2026-11-15), roughly 100 OSGi bundle modules and 2,550 Java files of pure framework — no device drivers, no UI chrome. Two generated architecture graphs accompany the text: an overview of the moving parts, then a detail view of the wiring that carries one command from a UI toggle to a device handler and one state change back.

By the end you will know where a button press becomes an event, how an item command finds the right handler through a profile indirection, how the rule engine runs composable trigger/condition/action graphs, why discovered devices land in an inbox before they become Things, and what the OSGi module system buys that a plain monolithic framework would not.

openHAB Core overview architecture: OSGi base bundle, Thing model, Items and links, event bus, rule engine, discovery

Overview: the org.openhab.core base bundle declares the Item and Event contracts; the thing bundle models devices plus their handlers; CommunicationManager routes item commands and updates through per-link profiles; everything crosses one OSGi EventAdmin event bus; the rule engine and discovery hang off the same bus.

Reading the overview from left to right:

openHAB Core detail architecture: Thing API, binding SPI, internal wiring, links and profiles, event bus, rule engine, discovery and inbox

Detail view: ThingRegistryImpl pushes tracker callbacks into ThingManagerImpl, which wires handler factories to per-device handlers; CommunicationManager resolves profiles per ItemChannelLink; the OSGi event bus connects items, rule triggers and the discovery inbox into one async fabric.

One bus, two object worlds

The design’s pivotal choice is that the “functional” world (Items, what users automate) and the “physical” world (Things, what bindings drive) never hold references to each other. ItemChannelLink, L29 is the entire bridge, and the ItemChannelLinkRegistry, L74 tracks it so relinking an item to a different channel is a registry mutation, not a code change. Consequences show up everywhere: renaming a device requires no UI edits, one item can draw state from several Things, and the same link table powers REST and UI alike.

The bus underneath is deliberately in-process. OSGiEventPublisher hands events to the OSGi EventAdmin running inside the JVM; there is no broker hop between a UI command and a device handler, which is why command latency is a thread schedule rather than a network round trip. If you need fan-out across machines you bring your own transport — the same trade-off we examined when comparing Redis pub/sub to local queues: in-process buses win on latency and lose on topology, and openHAB chose latency, then layered transports (MQTT, Modbus, serial) as separate bundles for the edges that genuinely speak protocols.

What a binding must actually provide

A binding author writes three things: a ThingHandler with handleCommand() and initialize(), a ThingHandlerFactory that claims its Thing types, and XML descriptions under OH-INF/ that the repo’s XML providers parse into ThingType/ChannelType objects — ThingType, L41 lists channel definitions, config parameters and supported bridges. BaseThingHandler, L69 supplies the conveniences (updateState(), updateStatus()) so driver code stays about the device, not the plumbing. Notably, this core repository contains zero device bindings — it ships the SPI, the transports and the marketplace installer machinery; the hundreds of protocol bindings are built elsewhere against these exact interfaces. That is how a bridge like Zigbee2MQTT can be consumed from openHAB without any core change: its MQTT topics are just another client of the MQTT transport bundle, one link table away from ordinary Items.

Rules are graphs, scripts are modules

The automation bundle models rules as connection graphs: trigger outputs feed condition and action inputs by name, with a separator constant (OUTPUT_SEPARATOR, L130) encoding moduleId.outputId references. The engine refuses to run a rule until every module type resolves and every handler attaches, exposing statuses like uninitialized and idle (documented on RuleEngineImpl, L107–113). Script actions (the model.script bundles) are just another module handler type plugged into the same graph — which is why a rule can mix a declarative trigger with a JavaScript action without the engine knowing either syntax. Disabled rules persist separately (DISABLED_RULE_STORAGE, L132) so a restart never resurrects something a user explicitly switched off.

From Eclipse SmartHome to 100 OSGi bundles

The lineage is written in the code. Comments still reference the Eclipse SmartHome tracker (NetUtil.java, L206), and several files carry “port to Eclipse SmartHome” author tags — the project was the Eclipse incubation that openHAB industrialized, and the package roots were renamed org.eclipse.smarthome → org.openhab.core along the way. The build mechanics reflect that OSGi heritage: the README describes the repo as “an artifact repository of OSGi bundles that can be used within smart home products” (L16) whose Maven build “produces OSGi bundles, dependency BOMs … and Karaf feature definitions” (L20). Concretely: 100 bundle modules under bundles/, each a bnd-built OSGi jar; the Karaf feature catalog at feature.xml, L20 assembles openhab-core-base, with transports like openhab-core-io-transport-mqtt, L280 and the rule DSL runtime openhab-core-model-rule, L346 as opt-in features. The version is 5.3.0-SNAPSHOT (pom.xml, L15) under EPL-2.0, and 27 itests/ modules boot real OSGi runtimes from itest.bndrun files (README L38) rather than mocking the registry — which is why service-wiring regressions get caught before release.

Standing next to its neighbors

Compared with Domoticz, which opts for a compact C++ core with Lua/Python scripting, openHAB trades binary weight for a formally versioned extension contract: bindings are OSGi bundles whose compatibility the framework checks, not plugins compiled against a moving header. Compared with Home Assistant, the difference is the same one the diagrams show — openHAB centralizes the event fabric in the runtime and keeps protocol handling in drivers, so the hub process itself stays protocol-agnostic. And for the radio layer, pairing this framework with a bridge such as Zigbee2MQTT keeps the two architectures cleanly separated: openHAB owns modeling and rules; the bridge owns mesh management.

Honest Limits

  • No device drivers ship here. The 100 bundles/ modules are framework, model and transport code; protocol integrations live in the separate add-ons repository, so this source tour explains the contract, not a specific device’s decoding.
  • The event bus is in-JVM only. OSGiEventManager subscribes to OSGi event.topics=openhab inside one process (L44); nothing under org.openhab.core.events serializes events off-box, and remote scenarios are left to separate bundles and deployments.
  • Discovery is binding-dependent. startScan() implementations live with each binding; core only defines the contract (L48) and the inbox, so scan coverage varies per protocol.
  • The rule DSL is part of this repo, but engines for scripts are layered. model.rule/model.script are Xtext language bundles in-tree; their runtime behavior depends on script engines registered as module handlers, which core does not provide.
  • All line numbers refer to main at HEAD 15c6da97b24e (2026-11-15) and will drift as files evolve; the architecture, not the line numbers, is the durable part.
Watch PyShine on YouTube

Contents