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.
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:
- The device model is three small interfaces, not a class hierarchy.
Thing, L42 is documented as “a representation of a connected part (e.g. physical device or cloud service)” and amounts to channels, configuration, status and properties (getChannels(), L82).Channel, L42 carries the item type it accepts plus a kind (getKind(), L107) that separates state channels from trigger channels, andBridge extends Thing, L28 lets a hub host child Things. - Nothing calls a device driver directly — everything rides one event bus.
OSGiEventPublisher, L38 implements the openHABEventPublisher, L24 contract by wrapping the standard OSGi EventAdmin and posting every event on a single"openhab"topic (eventAdmin.postEvent(...), L69). On the receive side,OSGiEventManager, L44 declares"event.topics:String=openhab", deserializes payloads through registered factories, and fans out to eachEventSubscriber.receive(), L58 that subscribed to the event type. - Items and Things are decoupled on purpose.
Item, L38 never mentions devices: it is state plus accepted command types (getState(), L45). The join point isItemChannelLink, L29 — an item name plusgetLinkedUID(), L57 — andCommunicationManager, L97 implements bothEventSubscriberand the link-registry change listener, keeping a per-link profile cache warm (L204). - Commands cross exactly one transform layer: profiles. A configured link can pin a profile (
getConfiguredProfileTypeUID, L228); otherwise advisors and the system default factory decide (L246). The default profile ends the trip atonCommandFromItem, L377, which lands in the handler’shandleCommand(ChannelUID, Command), L101 — the one method every binding must implement. - The framework, not the binding, owns the lifecycle.
ThingRegistryImpl, L55 notifies allThingTrackers on every add or update (L213);ThingManagerImpl, L124 picks the factory whosesupportsThingType(), L40 matches, callsregisterHandler(Thing), L55, and runsdoInitializeHandler(), L737 under SafeCaller timeout guards — a hung binding degrades one Thing, not the process. - States come back as events, with a policy for self-echo. A handler’s
updateState(), L324 becomes anItemStateEventon the topic templateopenhab/items/{itemName}/state(L54), while commands onopenhab/items/{itemName}/command(L52) pass throughAutoUpdateManager.receiveCommand(), L138, which can echo the command as state when the channel declares an auto-update policy. - Rules are data: composable modules that subscribe like everyone else. A
Rule, L47 is a graph ofTrigger, L41,Condition, L43 andAction, L42 modules held inRuleEngineImpl, L125’s concurrent map (L146). Event triggers are ordinary subscribers:GenericEventTriggerHandler, L49 implementsEventSubscriber, and a match callstriggered(), L54, which submits to a per-rule sequential executor runningre.runRule()(L86). - Discovery ends in an inbox, never straight in the registry. Bindings implement
DiscoveryService.startScan(), L114; results fan out viathingDiscovered(), L359; thePersistentInbox, L96 stores proposals, and only the explicitapprove(), L187 materializes a managed Thing.
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.
OSGiEventManagersubscribes to OSGievent.topics=openhabinside one process (L44); nothing underorg.openhab.core.eventsserializes 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.scriptare 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
mainat HEAD15c6da97b24e(2026-11-15) and will drift as files evolve; the architecture, not the line numbers, is the durable part.
Enjoyed this post? Never miss out on future posts by following us