Thermocktat
A lightweight thermostat emulator, primarily designed for BMS software testing (Building Management Systems).
Attributes
| Name |
Type |
Default |
Comment |
| ambient_temperature |
float |
21.0 |
Current temperature reading. |
| setpoint_temperature |
float |
22.0 |
Target temperature. Must be between setpoint_temperature_min and setpoint_temperature_max. |
| mode |
string |
"auto" |
Operating mode: auto | heat | cool | fan. |
| fan_speed |
string |
"medium" |
Fan speed setting: auto | low | medium | high. |
| enabled |
boolean |
true |
Indicates if the thermostat is powered (on/off). |
| setpoint_temperature_min |
float |
16.0 |
setpoint lower bound. |
| setpoint_temperature_max |
float |
28.0 |
setpoint upper bound. |
Regulation - ambient temperature simulation
The regulation of ambient temperature is simulated using a PID regulator and hysteresis (see diagram below).
Regulation simulates the effect of heating or cooling systems controlled by the thermostat that will actually heat and cool the room in order to reach the desired temperature (setpoint).
This example is for the heating mode. If the ambient temperature is above setpoint, or below within a hysteresis range (- TargetHysteresis, 1°C in this example), heating is not triggered. When it is lower with a difference greater than the target hysteresis, heating start until the target temperature is reached. The target is the setpoint temperature plus the target hysteresis.
In auto mode, a second hysteresis ModeChangeHysteresis (greater than TargetHysteresis) can trigger switching regulation direction between cooling and heating. For example, if the TargetHysteresis is 1 and the ModeChangeHysteresis is 2 (default values):
- if temperature setpoint is 20, and ambient temperature is above 22 (
setpoint + ModeChangeHysteresis), regulation will switch to cooling, and cool until 19 (setpoint - TargetHysteresis);
- if temperature setpoint is 20, and ambient temperature is below 18 (
setpoint - ModeChangeHysteresis), regulation will switch to heating, and cool until 21 (setpoint + TargetHysteresis).
Regulation params can be set in the config.yaml file (see cmd/app/config_defaults.yaml). Regulation can also be disabled (in this case, ambient temperature will remain constant).
Heat losses (or gains) through room walls are also simulated and simply modeled by a conduction coefficient. A temperature delta proportional to the difference between outdoor and ambient temperatures and to this coefficient is added to the ambient temperature every second. The heat loss coefficient represents the room's thermal condictivity (the higher the coefficient, the higher the loss). It can be configured in the heat_loss section of the config file (see cmd/app/config_defaults.yaml). Set to 0 for no heat loss.
API Documentation
Configuration
Thermocktat can be configured from a file (see cmd/app/config_defaults.yaml).
Usage:
go run ./cmd/thermocktat -config config.yaml
Configuration can also be passed using environment variables with the TMK_ prefix. Environment variables have priority over config file.
If no config is provided, default values will be used (values from cmd/app/config_defaults.yaml).
For each controller, the addr field is in the format host:port (host will be localhost by default). For most controllers, it is used to set the url that the server will expose. For mqtt, addr is the address of the broker.
Usage
Run directly
TMK_CONTROLLER=http \
TMK_ADDR=:8080 \
go run ./cmd/thermocktat
Compile and run binary
go build -o thermocktat ./cmd/thermocktat
./thermocktat -config config.yaml
Docker Examples
# Run with default params
docker run -p 8080:8080 thermocktat
# Set controller and address using environment variables
docker run --rm -e TMK_CONTROLLER=http -e TMK_ADDR=:8080 -p 8080:8080 thermocktat
docker run --rm -e TMK_CONTROLLER=mqtt -e TMK_ADDR=tcp://host.docker.internal:1883 -e TMK_DEVICE_ID=my-thermocktat thermocktat
docker run --rm -e TMK_CONTROLLER=modbus -e TMK_ADDR=0.0.0.0:1502 -e TMK_DEVICE_ID=my-thermocktat -p 1502:1502 thermocktat
# Run with a config file mounted as a volume
docker run -v $(pwd)/config.yaml:/config.yaml -p 8080:8080 thermocktat -config /config.yaml
CI/CD
Two GitHub Actions workflows handle CI and releases separately.
CI (.github/workflows/ci.yaml)
Runs on PRs (and workflow_dispatch). Main is protected — all changes go through PRs.
test-lint-build ──→ integration-tests ──┐
├──→ docker-push
docker-build ──→ docker-scan ───────────┘
- test-lint-build: Go quality checks (gofmt, goimports, go vet, gopls), unit tests with coverage, binary build.
- docker-build: Builds
linux/amd64 and linux/arm64 images in parallel, uploads as artifacts.
- docker-scan: Runs Trivy vulnerability scan on the built image. Fails on CRITICAL/HIGH CVEs.
- integration-tests: Python (pytest) end-to-end tests for each controller protocol.
- docker-push: After all checks pass, pushes the validated images to
ghcr.io tagged with the PR number (e.g., :pr-42).
Release (.github/workflows/release.yaml)
Runs on tag pushes. Finds the merged PR for the tagged commit, retags its :pr-N image with the version and latest — no rebuild. Signs the image with cosign (keyless) and creates a GitHub Release with an auto-generated changelog.
Releasing
git tag v0.7.0
git push origin v0.7.0
The release workflow finds the merged PR for that commit, locates the corresponding :pr-N image, retags it, signs it, and creates the GitHub Release.
License
MIT