Architecture
One JVM process, one database, one broker. Everything else is a detail of that.
System layers
┌──────────────────────────── Clients ────────────────────────────┐
│ Web app / PWA Floor-plan screens Android voice client │
│ (Vue 3 + Quasar) (/wui, kiosk) (Kotlin, wake word) │
│ Telegram bot Shared guest links Any GraphQL caller │
└───────────────────────────┬─────────────────────────────────────┘
GraphQL · REST · WebSocket (STOMP)
┌───────────────────────────▼─────────────────────────────────────┐
│ myHAB backend (Grails 7) │
│ │
│ GraphQL API · Services · Quartz jobs · Event bus │
│ Spring Security (JWT) · Spring Integration (MQTT) │
│ Hazelcast (cache, voice sessions) │
└───────────────────────────┬─────────────────────────────────────┘
JDBC MQTT HTTPS
┌───────────▼──────┬─────────▼────────┬────────▼──────────────────┐
│ PostgreSQL │ MQTT broker │ Vendor clouds │
│ entities + │ ESP32, MegaD, │ FusionSolar, myUplink, │
│ time series │ ONVIF bridge │ Segway, Open-Meteo │
└──────────────────┴──────────────────┴───────────────────────────┘A house is not a distributed system problem. One process that starts in a few seconds, one database to back up, and a broker you were going to run anyway. The operational surface is small enough that one person can hold it in their head at 2 a.m.
Gradle modules
| Module | Path | Responsibility |
|---|---|---|
:server-core | server/server-core | The application: controllers, GORM domain classes, services, Quartz jobs, the GraphQL schema and fetchers. |
:server-config | server/server-libs/server-config | ConfigProvider — the git-backed configuration store — and the configuration-key constants. |
:server-rules | server/server-libs/server-rules | Heating and lighting rule facts used by the automation engine. |
:web-vue3 | client/web-vue3 | The Vue 3 + Quasar PWA. |
:demo-simulator | demo/simulator | MQTT device simulator that makes the public demo behave like real hardware. |
client/android/ is a separate Gradle build, excluded from the root
settings.gradle on purpose: the Android Gradle Plugin and the Grails plugin do not
coexist in one build, and the two have unrelated release cadences.
Technology stack
Backend
- Runtime
- Java 17 (Temurin)
- Framework
- Grails 7.2 / Groovy 4.0
- ORM
- GORM over Hibernate 5
- API
- GraphQL, auto-registered from domain classes
- Security
- Spring Security + REST, HS256 JWT
- Scheduling
- Quartz, JDBC job store
- Messaging
- Spring Integration MQTT
- Cache
- Hazelcast 5.7
- QR / labels
- ZXing
Frontend
- Framework
- Vue 3
- UI
- Quasar 2.18
- State
- Pinia
- API client
- Apollo Client 3.14
- Realtime
- STOMP over WebSocket
- PWA
- Workbox service worker
- Voice input
- Web Speech API
- i18n
- English, Romanian
- Build
- Quasar CLI (Webpack)
Data flow
Sensor readings
Device → MQTT broker → MqttTopicService → PortValueService → PostgreSQL
│
└──→ WebSocket (STOMP) → every open client
Inbound topics are matched against per-model regular expressions, which is how one subscription
(myhab/#) serves ESP boards, meters, inverters, the heat pump, the weather station and
the mower without any of them knowing about the others. Patterns are checked in a fixed order and the
path segments (/state, /value, /mower/, /emeters/)
are chosen so they cannot overlap.
Commands
UI action / scenario / voice / Telegram / shared link
│ all publish the same event
▼
UIMessageService → resolves PERIPHERAL | ZONE | PORT to concrete ports
▼
PowerService → MQTT publish → broker → device
▼ │
event_log row │ device applies + echoes
▼ ▼
audit trail back through the sensor pathAdding a control surface means publishing an event, not reimplementing control logic. That is why the voice assistant, the Telegram bot and guest links all get audit logging, zone expansion and state reconciliation for free.
Virtual devices and the broker loopback
Cloud integrations do not bypass the pipeline. A job polls the vendor's REST API and then
publishes to the broker; the broker echoes the message back through the same
myhab/# subscription, and it is persisted exactly like a reading from a physical sensor.
The weather station, the solar inverter, the smart meter and the mower are all virtual devices treated this way. The benefit is uniformity: one persistence path, one audit path, one WebSocket fan-out, and the MQTT explorer shows cloud data next to hardware data.
Domain model
Zone ──┬── nested Zones
├── DevicePeripheral ──┬── DevicePort ── Device ── Rack
│ └── Cable ── PatchPanel
└── Layer
Job ──┬── CronTrigger
├── EventTrigger ── EventDefinition ── EventSubscription
└── Scenario (Groovy DSL)
User ── UserRole ── Role
Configuration (key/value sidecar on almost any entity)
DashboardScreen ── DashboardScreenBackground
SharedWidget ── SharedWidgetAuditSeventy-odd GORM domain classes in total, grouped as:
| Package | Holds |
|---|---|
infra | Zone, Layer |
device | Device, DevicePort, DevicePeripheral, Cable, Rack, PatchPanel, categories, join tables, Scenario, DeviceModel, DeviceStatus |
job | Job, CronTrigger, EventTrigger, EventDefinition, EventSubscription, EventData, JobExecutionHistory, JobTag |
events | MQTTTopic, TopicName, AuditSource |
ui | DashboardScreen, DashboardScreenBackground |
heating | HeatMode |
| root | User, Role, UserRole, Configuration, UserMessage, NotificationRule, PushSubscription, SharedWidget, SharedWidgetAudit, TimeSeriesStatistic |
The GraphQL layer
Most of the API is not hand-written. GORM domain classes carrying a static graphql block
are auto-registered by a schema factory, which generates queries, list queries with pagination, and
CRUD mutations for each.
Hand-written fetchers cover what auto-CRUD cannot express safely:
- Navigation and breadcrumbs.
- Switch, colour and gate operations, which must go through the event path.
voiceCommand.- Shared-link administration and its audit read.
- The configuration store, split into an admin view and a narrower UI-safe view.
- Owner-scoped reads such as notification rules.
Entities without a GraphQL mapping are skipped by the schema factory, so no auto-CRUD is generated for them. That is why the shared-link audit table has none: rows cannot be forged or deleted through the API, only read through a purpose-built query.
Realtime
Live state arrives over STOMP on WebSocket, not GraphQL subscriptions. A device state change fans out to every connected client immediately, so two phones and a wall tablet agree without polling.
The public shared-link pages are the exception: they have no authenticated socket, so they re-read state after each action and offer a manual refresh.
Persistence and time series
- Schema is managed by GORM with
dbCreate = update— it adds, never removes. port_valuesandevent_logare partitioned, with a composite index on(port_id, ts_created DESC)and a BRIN index onts_createdfor range pruning.- An
archiveschema holds retired partitions. - Quartz keeps its own
qrtz_*tables, so schedules survive restarts. - Rollup jobs aggregate raw values into hourly, daily and monthly statistics.
- Every timestamp is stored in UTC and rendered per user.
Port values accumulate fast when you poll many sensors. Budget disk accordingly, keep the indexes in place, and archive old partitions rather than deleting rows one by one.
Security model
| Concern | How it works |
|---|---|
| Authentication | Username and password to POST /api/login, returning a signed HS256 JWT (JWT_SECRET). Tokens are roughly ten hours; there is no refresh token. |
| Password storage | BCrypt. |
| Authorisation | Role-based: ROLE_USER, ROLE_ADMIN, ROLE_SUPER_ADMIN, enforced on both API and UI routes. |
| Anonymous surface | Only the three shared-link endpoints under /api/public/share/**. |
| Device endpoints | Restricted to configured LAN CIDRs, with a separate trusted-proxy list so a forged forwarding header cannot widen them. |
| Client IP | Resolved by a dedicated service; Spring's forward-headers-strategy is intentionally left unset. |
| Secrets | Environment variables for bootstrap, the git-backed store for the rest. Rotation means issuing new credentials, not overwriting values. |
| Audit | Every state change writes an event_log row with its source and actor; shared links additionally get their own audit table covering denied attempts. |
Internet exposure is supported but is your responsibility: TLS at a proxy you control, LAN-only
device endpoints refused at the edge, and rate limiting on /api/login and the public
share endpoints — neither of which myHAB does for you.
Caching and sessions
Hazelcast provides distributed caching and holds voice-assistant conversation state (five-minute TTL, capped history). Hibernate's second-level cache is off: it makes external database changes — a restore, a manual fix, the demo's reset — immediately visible to the running application, and at house scale the query volume never justified it.
Observability
- Prometheus metrics, application info and a health endpoint under
/actuator, exposed explicitly rather than by default. - Structured logging via Logback, configurable per package.
- The in-app message inbox surfaces operational events — device offline, integration failures, token expiry — to humans rather than only to logs.
- Job execution history records every scheduled run.
- Grafana can be pointed at the same PostgreSQL for long-range analysis, and embedded back into the UI.
Testing
Backend tests use Spock: unit specs under src/test/groovy and
integration specs under src/integration-test/groovy. Static analysis is CodeNarc. The
frontend is linted with ESLint and formatted with Prettier.
./gradlew test
./gradlew server-core:test --tests "org.myhab.services.StatisticsServiceSpec"Build and release
GitHub Actions builds on pushes to master and beta: assemble the combined
JAR, build a Docker image on eclipse-temurin:17-jre, and push it to Docker Hub as
kirpi4ik/myhab:<version>-<sha>. The demo image
(kirpi4ik/myhab-demo) is published from the same commit.