Skip to content

feat(gateway): PR3 — Solplanet adapter, polling, module, README, Docker - #3

Merged
andreuSignes merged 15 commits into
mainfrom
feat/solplanet-adapter-module
Jun 28, 2026
Merged

andreuSignes merged 15 commits into
mainfrom
feat/solplanet-adapter-module

Conversation

@andreuSignes

Copy link
Copy Markdown
Owner

Summary

PR3 of the sunspec-modbus-gateway change (third and final slice after PR1 and PR2 landed on main). Wires the Solplanet HTTP CGI transport to the Modbus wire surface from PR2, plus Docker support and an opt-in IoTech simulator profile.

What's included

Area Files Notes
Config src/config/configuration.ts Typed env loader + boot-time validator (port range, INVERTER_SN required)
Adapter src/gateway/inverters/solplanet-cgi.adapter.ts @nestjs/axios HTTP GET, maps CGI JSON to InverterState, never throws
Polling src/gateway/inverter-polling.service.ts @Cron(EVERY_5_SECONDS) → adapter.read() → bus.publish() → modbus.refreshFromBus()
Module wiring src/gateway/gateway.module.ts HttpModule + ScheduleModule.forRoot(), DI token binding
Health src/health/health.controller.ts GET /healthz
Bootstrap src/app.module.ts, src/main.ts ConfigModule.forRoot global, enableShutdownHooks
Docs README.md, .env.example Architecture, register map, troubleshooting, security note, Docker, simulator
Tasks openspec/changes/.../tasks.md PR3 tasks marked complete; Docker additions documented
Docker Dockerfile, .dockerignore Multi-stage node:24-alpine, pnpm 11, non-root nestjs (uid 1001), 227 MB image
Compose docker-compose.yml gateway service + modbus-simulator profile (IoTech iotechsys/pymodbus-sim:1.0)
CI .github/workflows/ci.yml New docker job: build, smoke-test /healthz, validate compose YAML

Security notes

  • Modbus TCP has no auth. Run on a trusted VLAN; set MODBUS_HOST to a private interface in production. .env.example carries the warning; README.md § Security checklist repeats it.
  • Non-root container. Image runs as nestjs (uid 1001), not root.
  • No .env in image. .dockerignore excludes it; INVERTER_SN is only injected at docker run time.

IoTech simulator is generic Modbus, NOT SunSpec

The modbus-simulator compose service uses iotechsys/pymodbus-sim:1.0 — a generic Modbus TCP server. It is not a SunSpec simulator, so the gateway's M1/M101 registers will read as zeros from it. It exists for HA smoke-testing without real hardware (does Home Assistant successfully walk the gateway's register map and find the SunS magic at 40000-40001). Behind --profile simulator so the default docker compose up stays minimal. See IoTech Modbus simulator docs.

Verification

  • pnpm lint clean
  • pnpm build clean
  • pnpm test 38/38 passing
  • pnpm test:e2e 12/12 passing (FakeAdapter drives the bus, modbus-serial client reads registers)
  • docker build -t sunspec-gateway:test . succeeds, image is 227 MB
  • docker run … boots, /healthz returns {"status":"ok"}, Modbus TCP binds 0.0.0.0:5020
  • docker compose --profile simulator up starts the IoTech simulator on host :5021
  • docker compose config validates the resolved YAML
  • New CI docker job mirrors the smoke-test on every PR

Sources of truth

  • Solplanet CGI response shape: docs/QG0028_ASW6000-10000-S_EN_540-30170-03_V04_0723-2.pdf
  • SunSpec register map: https://github.com/sunspec/models (verified offsets baked into src/modbus/sunspec-registers.ts from PR2)

Out of scope (deferred)

  • Three-phase inverters (SunSpec Model 103)
  • Setpoint / power-control writes (read-only gateway for v1)
  • Modbus TCP authentication / TLS
  • Other inverter brands (the adapter interface in PR1 is the seam)

The configuration factory now sets KEY='app' so ConfigService.get() reads
land under 'app.modbusHost' etc. validateConfig re-applies defaults
because @nestjs/config calls it BEFORE the load factories merge.

@nestjs/platform-express is now an explicit dep — NestFactory.create
needs it for the HTTP listener to bind /healthz, and pnpm does not
auto-hoist transitive @nestjs deps without shamefully-hoist=true.
…hooting

User asked to enlarge the README. Added: table of contents, double-buffer
section, lifecycle table, prerequisites table, .npmrc rationale, configuration
table with security note, pymodbus verification snippet, full register map
verified against sunspec/models, operating-states table, scale-factor math
walkthrough, stale-data handling, adapter contract, troubleshooting table,
observability patterns, security checklist, commit conventions, add-new-brand
guide, project layout.
Marked 3.1-3.8 as completed (already merged into this branch) and
appended 3.9-3.16 for: config namespace fix, README expansion,
Dockerfile + .dockerignore, docker-compose.yml, Docker README section,
Docker verification, CI Docker job, and the final PR3 verification
gate. Includes the security and IoTech-simulator caveats.
…ervice

The 'KEY = app' namespace hack does not work when validate is also
provided: @nestjs/config v3 silently discards the load-factory
namespace and exposes the validate result flat at the top level.
The earlier 'namespace under app.*' commit therefore left every
config.get('app.*') call returning undefined, which crashed the
SolplanetCgiAdapter constructor on 'inverterBaseUrl.replace'.

Verified end-to-end: app now boots, /healthz returns 200, Modbus
TCP server binds to 0.0.0.0:5020, cron schedules every 5 s.
…ator

Dockerfile: node:24-alpine multi-stage. Builder stage compiles TS
with pnpm 11 (frozen-lockfile, ignore-scripts to skip husky's
.git check). Production stage copies pruned node_modules + dist,
runs as non-root 'nestjs' (uid 1001), exposes 3000+5020, ships a
HEALTHCHECK against /healthz.

docker-compose.yml: 'gateway' service built from the local Dockerfile,
.env loaded for INVERTER_SN, host.docker.internal mapping for
container→host inverter reach on macOS/Windows. 'modbus-simulator'
service uses the IoTech pymodbus-sim:1.0 image behind a
'--profile simulator' toggle — it is a GENERIC Modbus TCP server,
NOT a SunSpec simulator, so it is useful only for HA smoke-testing
without the real hardware.

.dockerignore excludes node_modules, dist, coverage, .env, .git,
.github, openspec, .atl, test, IDE configs, and dev-only toolchain
config (lint/jest/husky/nvmrc). Keeps only what the Dockerfile copies.

Verified: docker build succeeds (227MB image), docker compose config
validates the resolved YAML, container boots and /healthz returns 200.
Two new sections between Running and Verifying:

  ## Docker              — quick path, port map, Dockerfile highlights,
                            env-var overrides, resource limits, tear-down.
  ## IoTech Modbus simulator — profile-gated compose service, NOT a
                            SunSpec simulator (generic Modbus TCP only),
                            smoke-test via pymodbus, host port 5021 to
                            avoid collision with the gateway's 5020.

Plus a 'See also' footer with the IoTech simulator docs link, a
mbpoll CLI pointer, and the HA SunSpec integration source. Table of
contents updated to match.
New 'docker' job runs after 'ci' so it only pays the build cost when
lint/build/test/e2e already pass. Steps: docker/setup-buildx-action@v3,
'docker build -t sunspec-gateway:test .', 'docker compose config --quiet'
to validate the resolved YAML, then a 10 s retry loop against
/healthz on the running container.

Adds docker image into CI without slowing the fast feedback loop —
the docker job is gated on ci: success and only runs on the same
runner (ubuntu-latest), reusing the cached apt/pnpm layers from the
first job via actions/cache.
The 'Validate compose stack' step failed in CI because
docker-compose.yml references env_file: .env, and docker compose
config requires the file to exist. CI doesn't ship a populated .env
(it would leak secrets). Touch an empty stub so the YAML validates
without exposing any real env vars.
@andreuSignes
andreuSignes merged commit 484458e into main Jun 28, 2026
2 checks passed
@andreuSignes
andreuSignes deleted the feat/solplanet-adapter-module branch June 28, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant