Thanks for looking. This is a short guide to getting the project running and knowing what is expected of a change.
neohab is an openHAB UI add-on: a Java shell of under two hundred lines that registers a tile,
serves static files and sets their cache headers, wrapped around a React + TypeScript app. Nearly
all the work is in web/.
pom.xml, bnd.bnd, src/main/… the openHAB add-on shell (Java, rarely changes)
web/ the app
src/api/ openHAB REST, auth, the live-state stream
src/model/ pure data model and layout maths (no React)
src/store/ application state (zustand)
src/components/ shared UI, the grids
src/widgets/ one folder per widget
src/editor/ edit-mode panels and forms
src/settings/ one file per section of the Settings screen
src/themes/ the theming contract and the built-in themes
docs/ documentation
e2e/ browser end-to-end suites, run against a real openHAB
You need Node 20.19+ or 22.12+, which is what Vite 7 requires. The Maven build downloads its own Node 24 regardless. For the add-on jar you also need a JDK 21.
cd web
npm install
cp .env.example .env.local # point OPENHAB_URL at a real openHAB
npm run devThe dev server proxies /rest, /auth, /icon/, /static and /images to that server, so the
frontend runs against your real items with no Java build at all. Reading works anonymously. Editing
needs an administrator sign-in in the app.
To build the installable add-on:
./mvnw -B -ntp clean install # -> target/org.openhab.ui.neohab-<version>.jarThe Maven build downloads its own pinned Node into .tools/ and runs the frontend build itself,
so it needs nothing installed beyond a JDK.
cd web
npm run check # typecheck + lint + formatting + unit testsThat is what CI runs. All four must pass.
npm run typecheckruns TypeScript in strict mode with no unused locals.npm run lintruns ESLint. Watchreact-hooks/exhaustive-depsin particular: if you suppress it, say why in a comment on the line above.npm run format:checkchecks formatting. If it fails,npm run formatfixes it: the formatter is oxfmt, configured inweb/.oxfmtrc.json, and it owns layout entirely so that a lint failure is always a real mistake rather than a stray space. It leavesapp.css, the i18n catalogs and the captured API fixtures alone.npm testruns the unit suite (vitest). Usenpm run test:watchwhile you work.
Unit tests live beside the module they cover, as *.test.ts, and run in Node with no DOM.
Everything that can be pure is: the layout maths, the gauge model, chart aggregation, the partial
export planner, the configuration diff, the template evaluator, the theming contract. If you are
adding logic, put it in a pure module and test it there. It is faster to write and far faster to
run than driving a browser.
End-to-end suites live in e2e/ and drive a real browser against a real openHAB server. CI
does not run them, because they need a server. See e2e/README.md for how to
point them at yours. They are the right tool for anything that only exists in a browser: layout,
gestures, live updates, theming as rendered.
A note on both: a check that has never failed proves nothing. When you fix a bug, make sure the test you add fails against the code before your fix.
One folder under src/widgets/, exporting a single WidgetDefinition:
export const myWidget: WidgetDefinition<MyConfig> = {
type: 'mywidget', // stored in configuration - stable, do not rename
name: 'My widget',
description: 'Shown in the palette',
defaultSize: { w: 3, h: 2 },
defaultConfig: () => ({ item: '' }),
settings: [ // the editor form builds itself from this
{ key: 'item', type: 'item', label: 'openHAB Item' },
],
itemKeys: (c) => [c.item], // which items to track live
Component: MyWidget,
}Register it in src/widgets/index.ts. The grid, the editor, the settings panel, copy and paste,
exports and the per-widget universal settings all come for free.
A widget that binds an item owes the registry two more answers, and src/widgets/settings.test.ts
will fail you until it gets them: canCommand, and controlFor, which says what control the hold
sheet should offer for this widget's own configuration. If the widget only reads its item, mark the
field readOnly: true and return undefined. The default is a slider on a 0-100 scale, which is
wrong for a sensor and wrong for anything with a scale of its own.
Three more things to know:
- Stored configuration is untrusted. A backup, a shared export or a hand edit is written
verbatim, so guard every value you do arithmetic on at the point you read it, not at the point
it was entered. Use
Array.isArraybefore.map, not?? []. The second one still throws on an object, and this runs during render. - A widget that throws is contained (see
components/WidgetBoundary.tsx), so a bad configuration costs one tile rather than the whole app. That is a safety net, not permission to skip the guard above. A tile reading "could not be shown" is still a broken widget. - Widget strings go through
t(). Empty states and messages included. An untranslated string is the only English left on screen in another language.
See docs/theming.md. If you are adding a theme, the cross-theme tests in
web/src/themes/themes.test.ts will tell you if you have hit one of the traps.
Match the file you are editing. Beyond that:
- Comments are rare and short. A comment earns its place by saying why something is the way it is, in a sentence, where the code cannot say it and getting it wrong would break something quietly. Anything that restates the code, or narrates how a bug was found, belongs in the commit message instead.
- Write like a person, not like a generator. Plain words, varied sentence length, no em dashes
(a plain
-is fine), no filler adjectives. That applies to docs, comments, commit messages and UI strings alike. - Smallest change that does the job. No speculative abstraction.
- Prefer a boring dependency, or none.