Skip to content

feat(gateway): PR1 — domain types, state bus, scale-factor helpers - #1

Merged
andreuSignes merged 26 commits into
mainfrom
pr1/domain-state-scalefactor
Jun 28, 2026
Merged

andreuSignes merged 26 commits into
mainfrom
pr1/domain-state-scalefactor

Conversation

@andreuSignes

@andreuSignes andreuSignes commented Jun 28, 2026 •

Copy link
Copy Markdown
Owner

PR1 v2 — domain types, state bus, scale-factor, pnpm, ESLint, CI

Foundation slice of the sunspec-modbus-gateway change.

What this PR delivers

Domain types (src/state/inverter-state.types.ts)

  • InverterState interface with all numeric fields renamed to design.md §3:
    acPowerWatts, acVoltageVolts, acCurrentAmps, gridFrequencyHz,
    lifetimeEnergyKwh, operatingState (numeric union 1..8), isStale,
    lastUpdatedAt (ms epoch).
  • vendorName and modelName fields added per design.md §3.
  • M101_ST const enum mapping SunSpec St enum values (1..8).
  • InverterAdapter abstract class with vendorName, modelName, read().
  • INVERTER_ADAPTER DI token.

State bus (src/state/inverter-state.service.ts)

  • InverterStateService with publish() and snapshot() (renamed from
    setState()/getState()).
  • publish() owns lastUpdatedAt: stamps Date.now() on fresh publishes,
    preserves the previous timestamp on stale publishes so the time-based
    stale check trips correctly.
  • snapshot() zeroes ALL production fields on stale (acPowerWatts,
    acVoltageVolts, acCurrentAmps, gridFrequencyHz, operatingState → OFF=1),
    preserves lifetimeEnergyKwh and identity fields (vendorName, modelName,
    serialNumber).

Scale-factor math (src/modbus/scale-factor.ts)

  • chooseScaleFactor algorithm: integer values prefer SF=0; fractional
    values prefer the most-negative SF that fits; overflow downgrades to
    positive SF; clamps at SF_FLOOR (-10).
  • applyScaleFactor uses 10^(-sf) to recover the raw register value
    (e.g. 230.5 V with SF=-1 → raw 2305).
  • encode() one-shot helper returning { value, scaleFactor }.
  • splitAcc32() returns a named { hi, lo } object (BE: upper 16 bits in hi).

Review-blocking issues resolved (v1 review)

  1. Field renames in InverterState match design.md §3 exactly.
  2. Method renames on InverterAdapter (fetchState → read) and
    InverterStateService (setState/getState → publish/snapshot).
  3. Scale-factor algorithm — precision-preserving implementation
    that handles 230.5 → SF=-1, 50000 → SF=1, 1e15 → SF=-10 (clamp).
  4. Stale-zeroing completeness — snapshot() zeros all four production
    fields, preserves lifetimeEnergyKwh and identity.

Strategy pivot (new in v2)

  • pnpm 11 as package manager (packageManager field in package.json,
    .npmrc with shamefully-hoist=true for NestJS deps, pnpm-lock.yaml
    replaces package-lock.json).
  • ESLint v9 flat config (eslint.config.mjs with @eslint/js +
    typescript-eslint, lint script, pnpm lint).
  • GitHub Actions CI (.github/workflows/ci.yml — lint + build + test
    on every PR and push to main, Node 20, pnpm via corepack).
  • GitHub Flow: each PR targets main directly. PR2/PR3 renamed
    from pr2/.../pr3/... to feat/...; merge sequence updated in
    tasks.md. PR1 branch keeps its name because the PR is already open.

Verification

pnpm install --frozen-lockfile
pnpm lint          # clean
pnpm build         # tsc strict
pnpm test          # 38/38 passing

Coverage: 94.36% statements / 88.88% branches / 94.82% lines.

CI workflow runs the same commands on every PR; ready for review.

Files

  • src/state/inverter-state.types.ts (new)
  • src/state/inverter-state.service.ts (rewritten)
  • src/modbus/scale-factor.ts (rewritten)
  • src/gateway/inverters/inverter.adapter.ts (re-export shim)
  • src/domain/inverter-state.ts (deleted — moved)
  • test/unit/scale-factor.spec.ts (expanded)
  • test/unit/inverter-state.service.spec.ts (rewritten)
  • eslint.config.mjs (new)
  • .github/workflows/ci.yml (new)
  • package.json, .npmrc, pnpm-lock.yaml, pnpm-workspace.yaml
  • openspec/changes/sunspec-modbus-gateway/{design,tasks}.md (updated)

Refs: openspec/changes/sunspec-modbus-gateway/

…ergyKwh, operatingState numeric, lastUpdatedAt ms, vendorName, modelName) and move to src/state/inverter-state.types.ts
… publish()/snapshot(); publish() owns lastUpdatedAt; snapshot() zeros all production fields on stale
…integer→SF=0 fits; fractional→most-negative; overflow→SF=1+; clamp at SF_FLOOR); applyScaleFactor uses 10^(-sf); add encode() helper; splitAcc32 returns named object
…s, fixed algorithm, complete stale coverage; ci: drop -- separator for pnpm test --coverage; lint: allow unused vars prefixed with _
@andreuSignes
andreuSignes force-pushed the pr1/domain-state-scalefactor branch from 018fc3a to 18103f0 Compare June 28, 2026 15:45
…ction-setup + concurrency

- .npmrc: add node-version=24 as single source of truth
- ci.yml: grep node-version from .npmrc into $GITHUB_OUTPUT, pass to setup-node
- ci.yml: swap manual corepack + pnpm --version for pnpm/action-setup@v4 (version: 11)
- ci.yml: add concurrency block (cancel-in-progress on same ref)
- ci.yml: add permissions: contents: read (least privilege)
- package.json: engines.node >=20 -> >=24

chore: drop silently-ignored kebab-case key 'verify-deps-before-run' from pnpm-workspace.yaml (canonical key is verifyDepsBeforeRun)
- Add @commitlint/cli + @commitlint/config-conventional + husky (v9) as devDependencies
- Initialize .husky/ via `pnpm exec husky init`
- Add commit-msg hook invoking `pnpm exec commitlint --edit`
- Add commitlint.config.js extending @commitlint/config-conventional with:
  - header-max-length: 72
  - type-case: lowercase
  - subject-case: lowercase first letter
  - body/footer line length: 100
- Allow CommonJS globals in *.config.js via ESLint override (commitlint ships CJS by default)
- Add CI guard: `pnpm dlx commitlint --from=HEAD~5 --to=HEAD` in .github/workflows/ci.yml

Verified: `git commit -m "chore: ..."` passes; `git commit -m "Chore ..."` is rejected.

Refs: Context7 docs for commitlint, husky, conventionalcommits.
actions/setup-node@v4 with `cache: 'pnpm'` invokes pnpm to compute cache keys.
If pnpm is not on PATH yet, the step fails with "Unable to locate executable
file: pnpm" and the whole job aborts before `pnpm/action-setup` runs.

Reorder so `pnpm/action-setup@v4` installs pnpm first, then setup-node can
locate it for caching.

Discovered while verifying the commitlint CI guard on PR #1 — the failure is
unrelated to commitlint and exists on prior commits too (ddd6ae6, 18103f0).
package.json declares `packageManager: pnpm@11.0.0` as the source of truth.
pnpm/action-setup@v4 errors with "Multiple versions of pnpm specified" if
both `version:` and `packageManager` are present.

Remove the redundant `version: 11` input; let package.json drive the version.
actions/checkout defaults to a shallow clone (depth=1) for pull_request
events, which means HEAD~5..HEAD cannot be resolved and the commitlint
guard crashes with "ambiguous argument". Set `fetch-depth: 0` so the full
history is available to commitlint.
The original `--from=HEAD~5 --to=HEAD` range tripped on PR1 commits that
predate the Conventional Commits convention (long headers, long body lines,
missing scopes). Once this PR merges and PR1's commits are baselined, a wider
range can be reintroduced.

For now, `--from=HEAD~1` checks only the freshest commit. The per-commit
hook remains the primary defense; this CI step catches the bypass scenarios
that motivated it — squashed merges via the GitHub web editor with a hand-
typed, non-conforming message.
actions/checkout@v4 checks out a synthetic merge commit (`refs/pull/N/merge`)
for PR builds, with two parents: the PR head and the PR base. Any
`--from=..--to=HEAD` range that includes HEAD walks both lineages, so the
guard lints PR1's pre-convention commits along with my new ones.

Narrowing to HEAD~1 still picks up the synthetic merge + every PR1 commit
reachable through it (CI showed commits as old as 9c0d55f failing). A real
fix needs history rewrite of PR1 commits or a config that exempts them —
both out of scope for this intro PR.

The per-commit husky hook remains the primary defense. A follow-up can
re-add a CI guard once this PR merges and PR1's commits are baselined.
@andreuSignes
andreuSignes force-pushed the pr1/domain-state-scalefactor branch from 9db4681 to 6a88396 Compare June 28, 2026 16:03
- actions/checkout: v4 -> v6
- pnpm/action-setup: v4 -> v6
- actions/setup-node: v4 -> v6
- Replace grep-based Node version extraction from .npmrc with native
  node-version-file: '.nvmrc' support (setup-node v6 does not read .npmrc
  for node-version-file; supported sources are .nvmrc, .node-version,
  .tool-versions, and package.json engines.node/volta.node/devEngines.runtime)
- Remove fetch-depth: 0 since the commitlint CI guard was dropped earlier

Refs: setup-node Context7 docs (/actions/setup-node), .nvmrc convention
used by nvm/fnm/Volta.
@andreuSignes
andreuSignes merged commit ae11503 into main Jun 28, 2026
1 check passed
andreuSignes added a commit that referenced this pull request Jun 28, 2026
The first correction commit (1deb513) only fixed the M101 header
arithmetic. It left the DC block with off-by-2 typos inherited from
the original design.

This commit brings the rest of the M101 const block into alignment
with the canonical SunSpec JSON and the verified implementation
in src/modbus/sunspec-registers.ts:

- DCA:  97 (+27)
- DCA_SF: 98 (+28)
- DCV:  99 (+29)
- DCV_SF: 100 (+30)
- DCW:  101 (+31, was 99)
- DCW_SF: 102 (+32, was missing)
- TMPCAB: 103 (+33, was 101 - collision with DCW)
- TMPSNK: 104 (+34)
- TMPTRNS: 105 (+35)
- TMPOT: 106 (+36)
- TMP_SF: 107 (+37)
- ST: 108 (+38, was 106)
- STVND: 109 (+39, was 107)
- EVT1: 110 (+40, was 108)
- EVT2: 112 (+42, was 110)
- EVT_VND1..4 unchanged from previous commit

Refs: PR2 fresh review finding #1 (minor, design.md still had DC
offset typos after the first correction).
@andreuSignes
andreuSignes deleted the pr1/domain-state-scalefactor 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