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      │
└──────────────────┴──────────────────┴───────────────────────────┘
Deliberately not microservices.

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

ModulePathResponsibility
:server-coreserver/server-coreThe application: controllers, GORM domain classes, services, Quartz jobs, the GraphQL schema and fetchers.
:server-configserver/server-libs/server-configConfigProvider — the git-backed configuration store — and the configuration-key constants.
:server-rulesserver/server-libs/server-rulesHeating and lighting rule facts used by the automation engine.
:web-vue3client/web-vue3The Vue 3 + Quasar PWA.
:demo-simulatordemo/simulatorMQTT 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 path
One execution path, many front ends.

Adding 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 ── SharedWidgetAudit

Seventy-odd GORM domain classes in total, grouped as:

PackageHolds
infraZone, Layer
deviceDevice, DevicePort, DevicePeripheral, Cable, Rack, PatchPanel, categories, join tables, Scenario, DeviceModel, DeviceStatus
jobJob, CronTrigger, EventTrigger, EventDefinition, EventSubscription, EventData, JobExecutionHistory, JobTag
eventsMQTTTopic, TopicName, AuditSource
uiDashboardScreen, DashboardScreenBackground
heatingHeatMode
rootUser, 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:

Omitting the mapping is a security decision.

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

Time series is what grows.

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

ConcernHow it works
AuthenticationUsername and password to POST /api/login, returning a signed HS256 JWT (JWT_SECRET). Tokens are roughly ten hours; there is no refresh token.
Password storageBCrypt.
AuthorisationRole-based: ROLE_USER, ROLE_ADMIN, ROLE_SUPER_ADMIN, enforced on both API and UI routes.
Anonymous surfaceOnly the three shared-link endpoints under /api/public/share/**.
Device endpointsRestricted to configured LAN CIDRs, with a separate trusted-proxy list so a forged forwarding header cannot widen them.
Client IPResolved by a dedicated service; Spring's forward-headers-strategy is intentionally left unset.
SecretsEnvironment variables for bootstrap, the git-backed store for the rest. Rotation means issuing new credentials, not overwriting values.
AuditEvery state change writes an event_log row with its source and actor; shared links additionally get their own audit table covering denied attempts.
Written for a LAN behind a router.

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

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.