nodejs-poolcontroller 8.1.2 → 8.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +63 -0
- package/.github/workflows/ghcr-publish.yml +67 -0
- package/Changelog +27 -0
- package/Dockerfile +52 -9
- package/README.md +123 -5
- package/config/Config.ts +57 -7
- package/config/VersionCheck.ts +63 -35
- package/controller/Equipment.ts +1 -1
- package/controller/State.ts +14 -3
- package/controller/boards/NixieBoard.ts +31 -16
- package/controller/comms/Comms.ts +55 -14
- package/controller/comms/messages/Messages.ts +169 -6
- package/controller/comms/messages/status/RegalModbusStateMessage.ts +411 -0
- package/controller/nixie/pumps/Pump.ts +198 -0
- package/defaultConfig.json +5 -0
- package/docker-compose.yml +32 -0
- package/package.json +23 -25
- package/types/express-multer.d.ts +32 -0
- package/.github/workflows/docker-publish-njsPC-linux.yml +0 -50
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copilot Project Instructions
|
|
2
|
+
|
|
3
|
+
Purpose: Help AI agents contribute effectively to nodejs-poolController (njsPC) with minimal ramp-up.
|
|
4
|
+
|
|
5
|
+
## 1. Core Domain & Architecture
|
|
6
|
+
- Goal: Bridge Pentair / compatible pool automation equipment (RS-485 / ScreenLogic) to REST, WebSockets, MQTT, InfluxDB, Rules, and REM (Relay Equipment Manager) interfaces.
|
|
7
|
+
- Startup sequence (see `app.ts`): config.init -> logger.init -> sys.init -> state.init -> webApp.init -> conn.initAsync (RS485 / network) -> sys.start -> webApp.initAutoBackup -> sl.openAsync (ScreenLogic).
|
|
8
|
+
- Major layers:
|
|
9
|
+
1. `config/Config.ts`: Loads/merges `defaultConfig.json` + `config.json`, watches disk, applies env overrides (POOL_*). Always mutate via `config.setSection()` / `config.updateAsync()`.
|
|
10
|
+
2. `controller/`:
|
|
11
|
+
- `comms/Comms.ts` (RS485 transport) + `comms/messages/*` (protocol encode/decode) feeding message objects to system/state.
|
|
12
|
+
- `Equipment.ts` (`sys`): Aggregates boards, pumps, heaters, bodies, chemistry, schedules.
|
|
13
|
+
- `boards/*Board.ts` selected by `BoardFactory.fromControllerType()` (ControllerType enum) to encapsulate model-specific logic.
|
|
14
|
+
- `State.ts`: Higher-level computed/state cache (emits events to interfaces & persistence).
|
|
15
|
+
3. `web/Server.ts`: Orchestrates multiple server/interface types (http/https/http2, mdns, ssdp, mqtt, influx, rule, rem). Each concrete server extends a ProtoServer pattern (see file) and exposes `emitToClients` / `emitToChannel`.
|
|
16
|
+
4. `logger/Logger.ts`: Winston wrapper with packet & ScreenLogic capture, optional replay capture mode (`log.app.captureForReplay`).
|
|
17
|
+
- Data Flow: Raw bytes -> `Comms` -> `Messages` decode -> equipment/state mutation -> events -> `webApp.emitToChannel()` -> clients (dashPanel, bindings, MQTT, etc.).
|
|
18
|
+
|
|
19
|
+
## 2. Key Conventions & Patterns
|
|
20
|
+
- Prefer calling exported singletons (`config`, `logger`, `sys`, `state`, `webApp`, `conn`) — they are initialized once in `app.ts`.
|
|
21
|
+
- Extend support for a new controller board: create `controller/boards/NewBoard.ts` implementing expected interface and add to `BoardFactory` switch.
|
|
22
|
+
- Adding an external interface: implement a server class similar to existing ones in `web/Server.ts` and register in `initInterfaces()` via `type` value in `web.interfaces` section of config.
|
|
23
|
+
- Configuration writes are async & debounced by a semaphore (`_isLoading`). Avoid rapid consecutive writes — batch changes before `setSection`.
|
|
24
|
+
- Logging packets: push through `logger.packet(msg)`; only log when `log.packet.enabled` or capture mode active. Don't bypass logger for protocol-level diagnostics.
|
|
25
|
+
- Use `utils.uuid()` for persistent IDs stored back into config (`web.servers.*` / `web.interfaces.*`).
|
|
26
|
+
|
|
27
|
+
## 3. Build & Run Workflow
|
|
28
|
+
- Scripts (`package.json`): `npm start` = build (tsc) + run `dist/app.js`; `npm run start:cached` skips build (use only after prior successful build); `npm run build` or `watch` for development.
|
|
29
|
+
- Always rebuild after pulling if Typescript sources changed (common; master is live development).
|
|
30
|
+
- Minimum Node >=16 (see `engines`).
|
|
31
|
+
- Typical dev loop: edit TS -> `npm run build` (or `watch` in one terminal) -> `npm run start:cached` in another.
|
|
32
|
+
|
|
33
|
+
## 4. Safe Change Guidelines (Project Specific)
|
|
34
|
+
- Never directly edit `config.json` structure assumptions without updating `defaultConfig.json` and migration logic if needed.
|
|
35
|
+
- When adding message types: place in proper `controller/comms/messages/{config|status}` folder; ensure decode populates strongly typed object consumed by equipment/state.
|
|
36
|
+
- Emitting to clients: prefer channel scoping with `webApp.emitToChannel(channel, evt, payload)` over broad `emitToClients` to reduce noise.
|
|
37
|
+
- Shutdown paths must await: see `stopAsync()` ordering in `app.ts`; replicate that order if introducing new long-lived resources.
|
|
38
|
+
- Packet capture integration: if adding new traffic sources, feed capture arrays so `logger.stopCaptureForReplayAsync()` includes them in backups.
|
|
39
|
+
|
|
40
|
+
## 5. Extending / Examples
|
|
41
|
+
- Add new board: create file, implement constructor(system), override protocol handlers, add case in `BoardFactory`.
|
|
42
|
+
- Add new interface type: define class (e.g., `FooInterfaceServer`) patterned after `MqttInterfaceServer`; map `type: 'foo'` in config to new class in `initInterfaces` switch.
|
|
43
|
+
- Add env override: update `Config.getEnvVariables()` with POOL_* variable mapping.
|
|
44
|
+
|
|
45
|
+
## 6. Debugging Tips
|
|
46
|
+
- Packet issues: enable `log.packet.logToConsole` & set `log.app.level` to `debug` or `silly` in `config.json` (or via capture mode) then rebuild & restart.
|
|
47
|
+
- Config reload: editing `config.json` on disk triggers fs watch; logger reinitializes log settings automatically.
|
|
48
|
+
- Network discovery problems: inspect `mdns` / `ssdp` sections in `web.servers` config; ensure correct interface binding (`ip` / `0.0.0.0`).
|
|
49
|
+
|
|
50
|
+
## 7. Common Pitfalls
|
|
51
|
+
- Forgetting to rebuild after TS edits (leads to running stale `dist`).
|
|
52
|
+
- Mutating returned config objects directly after `getSection` (they are deep-cloned; you must re-set via `setSection`).
|
|
53
|
+
- Adding interface without persisting UUID: ensure `utils.uuid()` assigned when undefined.
|
|
54
|
+
- Logging floods: avoid tight loops writing directly to console; use logger buffering (`packet` flush timer) pattern.
|
|
55
|
+
|
|
56
|
+
## 8. Contribution Checklist (Agent Focused)
|
|
57
|
+
1. Identify layer (comms / equipment / state / interface / config / logging) impacted.
|
|
58
|
+
2. Update or add unit-like logic in correct module; keep cross-layer boundaries (no UI assumptions in lower layers).
|
|
59
|
+
3. Use existing singletons; avoid new global state without need.
|
|
60
|
+
4. Validate build (`npm run build`) before proposing changes.
|
|
61
|
+
5. Provide brief rationale in PR tying change to equipment behavior or integration capability.
|
|
62
|
+
|
|
63
|
+
Feedback welcome: clarify any unclear pattern or request examples to extend this guide.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
name: Publish Docker Image - GHCR
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- master
|
|
7
|
+
tags:
|
|
8
|
+
- 'v*.*.*'
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build-and-push:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
packages: write
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout
|
|
19
|
+
uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Docker meta
|
|
22
|
+
id: meta
|
|
23
|
+
uses: docker/metadata-action@v5
|
|
24
|
+
with:
|
|
25
|
+
images: |
|
|
26
|
+
ghcr.io/${{ github.repository_owner }}/njspc
|
|
27
|
+
tags: |
|
|
28
|
+
type=ref,event=branch
|
|
29
|
+
type=ref,event=pr
|
|
30
|
+
type=semver,pattern={{version}}
|
|
31
|
+
type=semver,pattern={{major}}.{{minor}}
|
|
32
|
+
type=semver,pattern={{major}}
|
|
33
|
+
type=sha
|
|
34
|
+
type=raw,value=latest,enable={{is_default_branch}}
|
|
35
|
+
labels: |
|
|
36
|
+
org.opencontainers.image.source=${{ github.repository }}
|
|
37
|
+
org.opencontainers.image.revision=${{ github.sha }}
|
|
38
|
+
org.opencontainers.image.title=njspc
|
|
39
|
+
org.opencontainers.image.description=Pentair pool controller bridge (GHCR image)
|
|
40
|
+
|
|
41
|
+
- name: Set up QEMU
|
|
42
|
+
uses: docker/setup-qemu-action@v3
|
|
43
|
+
|
|
44
|
+
- name: Set up Docker Buildx
|
|
45
|
+
uses: docker/setup-buildx-action@v3
|
|
46
|
+
|
|
47
|
+
- name: Login to GHCR
|
|
48
|
+
uses: docker/login-action@v3
|
|
49
|
+
with:
|
|
50
|
+
registry: ghcr.io
|
|
51
|
+
username: ${{ github.actor }}
|
|
52
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
53
|
+
|
|
54
|
+
- name: Build and push
|
|
55
|
+
uses: docker/build-push-action@v6
|
|
56
|
+
with:
|
|
57
|
+
context: .
|
|
58
|
+
push: true
|
|
59
|
+
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
|
60
|
+
tags: ${{ steps.meta.outputs.tags }}
|
|
61
|
+
labels: ${{ steps.meta.outputs.labels }}
|
|
62
|
+
cache-from: type=gha
|
|
63
|
+
cache-to: type=gha,mode=max
|
|
64
|
+
|
|
65
|
+
- name: Echo published tags
|
|
66
|
+
run: |
|
|
67
|
+
echo "Published: ${{ steps.meta.outputs.tags }}"
|
package/Changelog
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 8.3.0
|
|
4
|
+
1. Configurable RS‑485 transmit pacing via new `controller.txDelays` for finer collision avoidance and throughput tuning.
|
|
5
|
+
2. Startup & config resilience: empty or invalid `config.json` now auto‑recreated from defaults, corrupt originals backed up.
|
|
6
|
+
3. Latitude / longitude environment overrides to eliminate early heliotrope warnings prior to UI configuration.
|
|
7
|
+
4. Version check enhancements: git detection, safer redirects, throttled polling, warning suppression.
|
|
8
|
+
5. Docker improvements: fixed Dockerfile and added docker‑compose example with named volumes & environment variable guidance.
|
|
9
|
+
6. Add workflow to build and publish docker images to GitHub Container registry.
|
|
10
|
+
7. Runtime requirements: elevated minimum Node.js version to 20+, safe dependency and security/patch updates.
|
|
11
|
+
8. Documentation updates.
|
|
12
|
+
|
|
13
|
+
## 8.1.2
|
|
14
|
+
1. Regal Century pump support added.
|
|
15
|
+
2. Enhanced error handling: replaced EquipmentNotFoundError with InvalidEquipmentIdError for improved clarity.
|
|
16
|
+
3. Improved error handling in NixieBoard, NixieCircuitCommands, and NixieValveCommands.
|
|
17
|
+
4. Refactored tolerance handling in TouchChemControllerCommands to ensure data integrity.
|
|
18
|
+
5. Added new expansion board configuration for SunTouchBoard.
|
|
19
|
+
|
|
20
|
+
## 8.1.1
|
|
21
|
+
1. Enhanced REM server integration with packet capture functionality.
|
|
22
|
+
2. Improved error handling in REMInterfaceServer methods.
|
|
23
|
+
3. Refined chlorinator message processing logic in MessagesMock.
|
|
24
|
+
4. Adjusted mock port checks across various components for consistency.
|
|
25
|
+
5. Refined logic for Nixie schedules.
|
|
26
|
+
6. Fixed documentation links for bindings integrations.
|
|
27
|
+
7. Enhanced queueBodyHeatSettings to handle processing timeouts and improve error logging.
|
|
28
|
+
8. Fixed typo in valveModes.
|
|
29
|
+
|
|
3
30
|
## 8.1.0
|
|
4
31
|
1. Support for dual chlorinators with REM chem controllers. It is now possible to have two separate chlorinators controlled in 'dynamic' mode by two separate REM chems. Note: In order for REM chem to control each chlorinator, each needs to be on a dedicated RS-485 port (not shared with an OCP or any other chlorinator).
|
|
5
32
|
|
package/Dockerfile
CHANGED
|
@@ -1,19 +1,62 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
### Build stage
|
|
2
|
+
FROM node:20-alpine AS build
|
|
3
|
+
LABEL maintainer="nodejs-poolController"
|
|
4
|
+
LABEL org.opencontainers.image.title="nodejs-poolController"
|
|
5
|
+
LABEL org.opencontainers.image.description="Bridge Pentair / compatible pool automation equipment to modern interfaces (REST, WebSockets, MQTT, Influx, Rules)."
|
|
6
|
+
LABEL org.opencontainers.image.licenses="AGPL-3.0-only"
|
|
7
|
+
LABEL org.opencontainers.image.source="https://github.com/tagyoureit/nodejs-poolController"
|
|
8
|
+
|
|
9
|
+
# Install build toolchain only for native deps (serialport, etc.)
|
|
10
|
+
RUN apk add --no-cache make gcc g++ python3 linux-headers udev tzdata git
|
|
11
|
+
|
|
3
12
|
WORKDIR /app
|
|
13
|
+
|
|
14
|
+
# Leverage Docker layer caching: copy only manifests first
|
|
4
15
|
COPY package*.json ./
|
|
5
16
|
COPY defaultConfig.json config.json
|
|
17
|
+
|
|
18
|
+
# Install all deps (including dev) for build
|
|
6
19
|
RUN npm ci
|
|
20
|
+
|
|
21
|
+
# Copy source
|
|
7
22
|
COPY . .
|
|
23
|
+
|
|
24
|
+
# Build Typescript
|
|
8
25
|
RUN npm run build
|
|
9
|
-
RUN npm ci --omit=dev
|
|
10
26
|
|
|
11
|
-
|
|
12
|
-
RUN
|
|
13
|
-
|
|
27
|
+
# Remove dev dependencies while keeping a clean node_modules with prod deps only
|
|
28
|
+
RUN npm prune --production
|
|
29
|
+
|
|
30
|
+
### Runtime stage
|
|
31
|
+
FROM node:20-alpine AS prod
|
|
32
|
+
LABEL org.opencontainers.image.title="nodejs-poolController"
|
|
33
|
+
LABEL org.opencontainers.image.description="Bridge Pentair / compatible pool automation equipment to modern interfaces (REST, WebSockets, MQTT, Influx, Rules)."
|
|
34
|
+
LABEL org.opencontainers.image.licenses="AGPL-3.0-only"
|
|
35
|
+
LABEL org.opencontainers.image.source="https://github.com/tagyoureit/nodejs-poolController"
|
|
36
|
+
ENV NODE_ENV=production
|
|
37
|
+
|
|
38
|
+
# Use existing 'node' user from base image; just ensure work directory exists
|
|
14
39
|
WORKDIR /app
|
|
15
|
-
|
|
40
|
+
RUN mkdir -p /app
|
|
41
|
+
RUN mkdir -p /app/logs /app/data /app/backups /app/web/bindings/custom \
|
|
42
|
+
&& chown -R node:node /app/logs /app/data /app/backups /app/web/bindings /app/web/bindings/custom || true
|
|
43
|
+
|
|
44
|
+
# Copy only the necessary runtime artifacts from build stage
|
|
45
|
+
COPY --chown=node:node --from=build /app/package*.json ./
|
|
46
|
+
COPY --chown=node:node --from=build /app/node_modules ./node_modules
|
|
47
|
+
COPY --chown=node:node --from=build /app/dist ./dist
|
|
48
|
+
COPY --chown=node:node --from=build /app/defaultConfig.json ./defaultConfig.json
|
|
49
|
+
COPY --chown=node:node --from=build /app/config.json ./config.json
|
|
50
|
+
COPY --chown=node:node --from=build /app/README.md ./README.md
|
|
51
|
+
COPY --chown=node:node --from=build /app/LICENSE ./LICENSE
|
|
52
|
+
|
|
16
53
|
USER node
|
|
17
|
-
|
|
18
|
-
|
|
54
|
+
|
|
55
|
+
# Default HTTP / HTTPS (if enabled) ports from defaultConfig (http 4200, https 4201)
|
|
56
|
+
EXPOSE 4200 4201
|
|
57
|
+
|
|
58
|
+
# Basic healthcheck (container considered healthy if process responds to tcp socket open)
|
|
59
|
+
HEALTHCHECK --interval=45s --timeout=6s --start-period=40s --retries=4 \
|
|
60
|
+
CMD node -e "const n=require('net');const s=n.createConnection({host:'127.0.0.1',port:4200},()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1));setTimeout(()=>{s.destroy();process.exit(1)},5000);" || exit 1
|
|
61
|
+
|
|
19
62
|
ENTRYPOINT ["node", "dist/app.js"]
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
```diff
|
|
2
2
|
- INTELLICENTER USERS: Do not upgrade Intellicenter to 2.006. Rollback to 1.064 to use this application.
|
|
3
3
|
```
|
|
4
|
-
# nodejs-poolController - Version 8.
|
|
4
|
+
# nodejs-poolController - Version 8.3.0
|
|
5
5
|
|
|
6
6
|
## What is nodejs-poolController
|
|
7
7
|
|
|
@@ -26,6 +26,16 @@ Equipment supported
|
|
|
26
26
|
## Latest Changes
|
|
27
27
|
See [Changelog](https://github.com/tagyoureit/nodejs-poolController/blob/master/Changelog)
|
|
28
28
|
|
|
29
|
+
## What's new in 8.3.0?
|
|
30
|
+
|
|
31
|
+
1. Configurable RS‑485 transmit pacing via new `controller.txDelays` for finer collision avoidance and throughput tuning.
|
|
32
|
+
2. Startup & config resilience: empty or invalid `config.json` now auto‑recreated from defaults, corrupt originals backed up.
|
|
33
|
+
3. Latitude / longitude environment overrides to eliminate early heliotrope warnings prior to UI configuration.
|
|
34
|
+
4. Version check enhancements: git detection, safer redirects, throttled polling, warning suppression.
|
|
35
|
+
5. Docker improvements: fixed Dockerfile and added docker‑compose example with named volumes & environment variable guidance.
|
|
36
|
+
6. Add workflow to build and publish docker images to GitHub Container registry.
|
|
37
|
+
7. Runtime requirements: elevated minimum Node.js version to 20+, safe dependency and security/patch updates.
|
|
38
|
+
|
|
29
39
|
## What's new in 8.1?
|
|
30
40
|
|
|
31
41
|
Support for dual chlorinators with REM chem controllers. It is now possible to have two separate chlorinators controlled in 'dynamic' mode by two separate REM chems. Note: In order for REM chem to control each chlorinator, each needs to be on a dedicated RS-485 port (not shared with an OCP or any other chlorinator).
|
|
@@ -56,7 +66,7 @@ This is only the server code. See [clients](#module_nodejs-poolController--clie
|
|
|
56
66
|
### Prerequisites
|
|
57
67
|
If you don't know anything about NodeJS, these directions might be helpful.
|
|
58
68
|
|
|
59
|
-
1. Install Nodejs (
|
|
69
|
+
1. Install Nodejs (v20+ required). (https://nodejs.org/en/download/)
|
|
60
70
|
1. Update NPM (https://docs.npmjs.com/getting-started/installing-node).
|
|
61
71
|
1. It is recommended to clone the source code as updates are frequently pushed while releases are infrequent
|
|
62
72
|
clone with `git clone https://github.com/tagyoureit/nodejs-poolController.git`
|
|
@@ -73,14 +83,99 @@ For a very thorough walk-through, see [this](https://www.troublefreepool.com/thr
|
|
|
73
83
|
#### Upgrade Instructions
|
|
74
84
|
Assuming you cloned the repo, the following are easy steps to get the latest version:
|
|
75
85
|
1. Change directory to the njsPC app
|
|
76
|
-
2. `
|
|
77
|
-
3. `
|
|
78
|
-
4.
|
|
86
|
+
2. **Important**: Ensure you have Node.js v20 or higher installed (`node --version`). If not, upgrade Node.js first.
|
|
87
|
+
3. `git pull`
|
|
88
|
+
4. **Important**: Run `npm i` to update dependencies. This is especially important when upgrading to version 8.3.0+ as it requires Node 20+ and has updated dependencies.
|
|
89
|
+
5. Start application as normal, or if using `npm run start:cached` then run `npm run build` to compile the code.
|
|
79
90
|
|
|
80
91
|
### Docker instructions
|
|
81
92
|
|
|
82
93
|
See the [wiki](https://github.com/tagyoureit/nodejs-poolController/wiki/Docker). Thanks @wurmr @andylippitt @emes.
|
|
83
94
|
|
|
95
|
+
### Docker Compose (Controller + Optional dashPanel UI)
|
|
96
|
+
|
|
97
|
+
Below is an example `docker-compose.yml` snippet showing this controller (`njspc`) and an OPTIONAL dashPanel UI service (`njspc-dash`). The dashPanel image is published separately; uncomment if you want a built-in web dashboard on port 5150.
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
services:
|
|
101
|
+
njspc:
|
|
102
|
+
image: ghcr.io/sam2kb/njspc
|
|
103
|
+
container_name: njspc
|
|
104
|
+
restart: unless-stopped
|
|
105
|
+
environment:
|
|
106
|
+
- TZ=${TZ:-UTC}
|
|
107
|
+
- NODE_ENV=production
|
|
108
|
+
# Serial vs network connection options
|
|
109
|
+
# - POOL_NET_CONNECT=true
|
|
110
|
+
# - POOL_NET_HOST=raspberrypi
|
|
111
|
+
# - POOL_NET_PORT=9801
|
|
112
|
+
# Provide coordinates so sunrise/sunset (heliotrope) works immediately - change as needed
|
|
113
|
+
- POOL_LATITUDE=28.5383
|
|
114
|
+
- POOL_LONGITUDE=-81.3792
|
|
115
|
+
ports:
|
|
116
|
+
- "4200:4200"
|
|
117
|
+
devices:
|
|
118
|
+
- /dev/ttyACM0:/dev/ttyUSB0
|
|
119
|
+
# Persistence (create host directories/files first)
|
|
120
|
+
volumes:
|
|
121
|
+
- ./server-config.json:/app/config.json # Persisted config file on host
|
|
122
|
+
- njspc-data:/app/data # State & equipment snapshots
|
|
123
|
+
- njspc-backups:/app/backups # Backup archives
|
|
124
|
+
- njspc-logs:/app/logs # Logs
|
|
125
|
+
- njspc-bindings:/app/web/bindings/custom # Custom bindings
|
|
126
|
+
# OPTIONAL: If you get permission errors accessing /dev/tty*, prefer adding the container user to the host dialout/uucp group;
|
|
127
|
+
# only as a last resort temporarily uncomment the two lines below to run privileged/root (less secure).
|
|
128
|
+
# privileged: true
|
|
129
|
+
# user: "0:0"
|
|
130
|
+
|
|
131
|
+
njspc-dash:
|
|
132
|
+
image: ghcr.io/sam2kb/njspc-dash
|
|
133
|
+
container_name: njspc-dash
|
|
134
|
+
restart: unless-stopped
|
|
135
|
+
depends_on:
|
|
136
|
+
- njspc
|
|
137
|
+
environment:
|
|
138
|
+
- TZ=${TZ:-UTC}
|
|
139
|
+
- NODE_ENV=production
|
|
140
|
+
- POOL_WEB_SERVICES_IP=njspc # Link to backend service name
|
|
141
|
+
ports:
|
|
142
|
+
- "5150:5150"
|
|
143
|
+
volumes:
|
|
144
|
+
- ./dash-config.json:/app/config.json
|
|
145
|
+
- njspc-dash-data:/app/data
|
|
146
|
+
- njspc-dash-logs:/app/logs
|
|
147
|
+
- njspc-dash-uploads:/app/uploads
|
|
148
|
+
|
|
149
|
+
volumes:
|
|
150
|
+
njspc-data:
|
|
151
|
+
njspc-backups:
|
|
152
|
+
njspc-logs:
|
|
153
|
+
njspc-bindings:
|
|
154
|
+
njspc-dash-data:
|
|
155
|
+
njspc-dash-logs:
|
|
156
|
+
njspc-dash-uploads:
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Quick start:
|
|
160
|
+
1. Save compose file.
|
|
161
|
+
2. (Optional) create an empty config file: `touch dash-config.json`.
|
|
162
|
+
3. `docker compose up -d`
|
|
163
|
+
4. Visit Dash UI at: `http://localhost:5150`.
|
|
164
|
+
|
|
165
|
+
Notes:
|
|
166
|
+
* Provide either RS-485 device OR enable network (ScreenLogic) connection.
|
|
167
|
+
* Coordinates env vars prevent heliotrope warnings before the panel reports location.
|
|
168
|
+
* Persistence (controller):
|
|
169
|
+
* `./server-config.json:/app/config.json` main runtime config. You can either:
|
|
170
|
+
* Seed it with a copy of `defaultConfig.json` (`cp defaultConfig.json server-config.json`), OR
|
|
171
|
+
* Start with an empty file and the app will auto-populate it from defaults on first launch. If the file exists but contains invalid JSON it will be backed up to `config.corrupt-<timestamp>.json` and regenerated.
|
|
172
|
+
* Remaining state (data, backups, logs, custom bindings) is typically stored in named volumes in the provided compose for cleaner host directories. If you prefer bind mounts instead, replace the named volumes with host paths similar to the example below.
|
|
173
|
+
* Data artifacts: `poolConfig.json`, `poolState.json` etc. live under `/app/data` (volume `njspc-data`).
|
|
174
|
+
* Backups: `/app/backups` (volume `njspc-backups`).
|
|
175
|
+
* Logs: `/app/logs` (volume `njspc-logs`).
|
|
176
|
+
* Custom bindings: `/app/web/bindings/custom` (volume `njspc-bindings`).
|
|
177
|
+
* To migrate from bind mounts to named volumes, stop the stack, `docker run --rm -v oldPath:/from -v newVolume:/to alpine sh -c 'cp -a /from/. /to/'` for each path, then update compose.
|
|
178
|
+
|
|
84
179
|
### Automate startup of app
|
|
85
180
|
See the [wiki](https://github.com/tagyoureit/nodejs-poolController/wiki/Automatically-start-at-boot---PM2-&-Systemd).
|
|
86
181
|
|
|
@@ -138,6 +233,29 @@ Most of these can be configured directly from the UI in dashPanel.
|
|
|
138
233
|
* `netConnect` - used to connect via [Socat](https://github.com/tagyoureit/nodejs-poolController/wiki/Socat)
|
|
139
234
|
* `netHost` and `netPort` - host and port for Socat connection.
|
|
140
235
|
* `inactivityRetry` - # of seconds the app should wait before trying to reopen the port after no communications. If your equipment isn't on all the time or you are running a virtual controller you may want to dramatically increase the timeout so you don't get console warnings.
|
|
236
|
+
* `txDelays` - (optional) fine‑grained transmit pacing controls added in 8.1+ to better coexist with busy or bridged (socat / multiple panel / dual chlorinator) RS‑485 buses. These values are all in milliseconds. If the block is omitted, internal defaults are used (see `defaultConfig.json`). All values can be hot‑reloaded from config.
|
|
237
|
+
* `idleBeforeTxMs` – Minimum quiet time on the bus (no RX or TX seen) before a new outbound frame may start. Helps avoid collisions just after another device finishes talking. Typical: 40‑80. Set to 0 to disable.
|
|
238
|
+
* `interFrameDelayMs` – Delay inserted between completed outbound attempts (success, retry scheduling, or queue drain) and evaluation of the next outbound message. Replaces the previous fixed 100ms. Typical: 30‑75. Lower values increase throughput but may raise collision / rewind counts.
|
|
239
|
+
* `interByteDelayMs` – Optional per‑byte pacing inside a single frame. Normally 0 (disabled). Set to 1‑2ms only if you observe hardware or USB adapter overruns, or are experimenting with very marginal wiring / long cable runs.
|
|
240
|
+
|
|
241
|
+
Example tuning block - more conservative pacing for SunTouch that works way better than defaults:
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
"txDelays": {
|
|
245
|
+
"idleBeforeTxMs": 60,
|
|
246
|
+
"interFrameDelayMs": 50,
|
|
247
|
+
"interByteDelayMs": 1
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Tuning guidance:
|
|
252
|
+
- Start with the defaults. Only change one value at a time and observe stats (collisions, retries, rewinds) via rs485PortStats.
|
|
253
|
+
- If you see frequent outbound retries or receive rewinds, first raise `idleBeforeTxMs` in small steps (e.g. +10ms) before touching `interFrameDelayMs`.
|
|
254
|
+
- If overall throughput feels sluggish but collisions are low, you may lower `interFrameDelayMs` gradually.
|
|
255
|
+
- Use `interByteDelayMs` only as a last resort; it elongates every frame and reduces total bus capacity.
|
|
256
|
+
- Setting any value too high will simply slow configuration bursts (e.g. on startup); setting them too low can cause more retries and ultimately lower effective throughput.
|
|
257
|
+
|
|
258
|
+
All three parameters are safe to adjust without restarting; edits to `config.json` are picked up by the existing config watcher.
|
|
141
259
|
|
|
142
260
|
## Web section - controls various aspects of external communications
|
|
143
261
|
* `servers` - setting for different servers/services
|
package/config/Config.ts
CHANGED
|
@@ -36,21 +36,52 @@ class Config {
|
|
|
36
36
|
// RKS 05-18-20: This originally had multiple points of failure where it was not in the try/catch.
|
|
37
37
|
try {
|
|
38
38
|
this._isLoading = true;
|
|
39
|
-
|
|
39
|
+
// Read user config (if present) with graceful handling of empty or invalid JSON.
|
|
40
|
+
let userCfg: any = {};
|
|
41
|
+
if (fs.existsSync(this.cfgPath)) {
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(this.cfgPath, "utf8");
|
|
44
|
+
const trimmed = raw.trim();
|
|
45
|
+
if (trimmed.length > 0) userCfg = JSON.parse(trimmed);
|
|
46
|
+
else {
|
|
47
|
+
console.log(`Config file '${ this.cfgPath }' is empty. Populating with defaults.`);
|
|
48
|
+
}
|
|
49
|
+
} catch (parseErr: any) {
|
|
50
|
+
// Backup corrupt file then continue with defaults.
|
|
51
|
+
try {
|
|
52
|
+
const backupName = this.cfgPath.replace(/\.json$/i, `.corrupt-${ Date.now() }.json`);
|
|
53
|
+
fs.copyFileSync(this.cfgPath, backupName);
|
|
54
|
+
console.log(`Config file '${ this.cfgPath }' contained invalid JSON and was backed up to '${ backupName }'. Using defaults.`);
|
|
55
|
+
} catch (backupErr: any) {
|
|
56
|
+
console.log(`Failed to backup corrupt config file '${ this.cfgPath }': ${ backupErr.message }`);
|
|
57
|
+
}
|
|
58
|
+
userCfg = {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
40
61
|
const def = JSON.parse(fs.readFileSync(path.join(process.cwd(), "/defaultConfig.json"), "utf8").trim());
|
|
41
62
|
const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), "/package.json"), "utf8").trim());
|
|
42
|
-
this._cfg = extend(true, {}, def,
|
|
63
|
+
this._cfg = extend(true, {}, def, userCfg, { appVersion: packageJson.version });
|
|
43
64
|
this._isInitialized = true;
|
|
44
65
|
this.updateAsync((err) => {
|
|
45
66
|
if (typeof err === 'undefined') {
|
|
46
67
|
fs.watch(this.cfgPath, (event, fileName) => {
|
|
47
68
|
if (fileName && event === 'change') {
|
|
48
|
-
if (self._isLoading) return; //
|
|
69
|
+
if (self._isLoading) return; // Debounce via semaphore.
|
|
49
70
|
console.log('Updating config file');
|
|
50
71
|
const stats = fs.statSync(self.cfgPath);
|
|
51
72
|
if (stats.mtime.valueOf() === self._fileTime.valueOf()) return;
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
let changedCfg: any = {};
|
|
74
|
+
if (fs.existsSync(self.cfgPath)) {
|
|
75
|
+
try {
|
|
76
|
+
const raw2 = fs.readFileSync(self.cfgPath, "utf8");
|
|
77
|
+
const trimmed2 = raw2.trim();
|
|
78
|
+
if (trimmed2.length > 0) changedCfg = JSON.parse(trimmed2);
|
|
79
|
+
else console.log(`Watched config file is empty; continuing with defaults + existing overrides.`);
|
|
80
|
+
} catch (e: any) {
|
|
81
|
+
console.log(`Error parsing updated config file. Retaining existing configuration. Error: ${ e.message }`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this._cfg = extend(true, {}, def, changedCfg, { appVersion: packageJson.version });
|
|
54
85
|
logger.init(); // only reload logger for now; possibly expand to other areas of app
|
|
55
86
|
logger.info(`Reloading app config: ${fileName}`);
|
|
56
87
|
this.emitter.emit('reloaded', this._cfg);
|
|
@@ -63,8 +94,7 @@ class Config {
|
|
|
63
94
|
this.getEnvVariables();
|
|
64
95
|
} catch (err) {
|
|
65
96
|
console.log(`Error reading configuration information. Aborting startup: ${ err }`);
|
|
66
|
-
|
|
67
|
-
throw err;
|
|
97
|
+
throw err; // Only throw if defaults/package.json could not be read.
|
|
68
98
|
}
|
|
69
99
|
}
|
|
70
100
|
public async updateAsync(callback?: (err?) => void) {
|
|
@@ -189,6 +219,26 @@ class Config {
|
|
|
189
219
|
this._cfg.controller.comms.netPort = env.POOL_NET_PORT;
|
|
190
220
|
bUpdate = true;
|
|
191
221
|
}
|
|
222
|
+
// Allow overriding location coordinates for heliotrope calculations
|
|
223
|
+
if (typeof env.POOL_LATITUDE !== 'undefined') {
|
|
224
|
+
const lat = parseFloat(env.POOL_LATITUDE as any);
|
|
225
|
+
if (!isNaN(lat) && (!this._cfg.controller?.general?.location || this._cfg.controller.general.location.latitude !== lat)) {
|
|
226
|
+
// Ensure nested objects exist
|
|
227
|
+
this._cfg.controller.general = this._cfg.controller.general || {};
|
|
228
|
+
this._cfg.controller.general.location = this._cfg.controller.general.location || {};
|
|
229
|
+
this._cfg.controller.general.location.latitude = lat;
|
|
230
|
+
bUpdate = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (typeof env.POOL_LONGITUDE !== 'undefined') {
|
|
234
|
+
const lon = parseFloat(env.POOL_LONGITUDE as any);
|
|
235
|
+
if (!isNaN(lon) && (!this._cfg.controller?.general?.location || this._cfg.controller.general.location.longitude !== lon)) {
|
|
236
|
+
this._cfg.controller.general = this._cfg.controller.general || {};
|
|
237
|
+
this._cfg.controller.general.location = this._cfg.controller.general.location || {};
|
|
238
|
+
this._cfg.controller.general.location.longitude = lon;
|
|
239
|
+
bUpdate = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
192
242
|
if (bUpdate) this.updateAsync();
|
|
193
243
|
}
|
|
194
244
|
}
|
package/config/VersionCheck.ts
CHANGED
|
@@ -28,10 +28,24 @@ class VersionCheck {
|
|
|
28
28
|
private gitApiHost: string;
|
|
29
29
|
private gitLatestReleaseJSONPath: string;
|
|
30
30
|
private redirects: number;
|
|
31
|
+
private gitAvailable: boolean;
|
|
32
|
+
private warnedBranch: boolean = false;
|
|
33
|
+
private warnedCommit: boolean = false;
|
|
31
34
|
constructor() {
|
|
32
35
|
this.userAgent = 'tagyoureit-nodejs-poolController-app';
|
|
33
36
|
this.gitApiHost = 'api.github.com';
|
|
34
37
|
this.gitLatestReleaseJSONPath = '/repos/tagyoureit/nodejs-poolController/releases/latest';
|
|
38
|
+
this.gitAvailable = this.detectGit();
|
|
39
|
+
// NOTE:
|
|
40
|
+
// * SOURCE_BRANCH / SOURCE_COMMIT env vars (if present) override git commands. These are expected in container builds where .git may be absent.
|
|
41
|
+
// * If git is not available (no binary or not a repo) we suppress repeated warnings after the first occurrence.
|
|
42
|
+
// * Version comparison is rate-limited via nextCheckTime (every 2 days) to avoid Github API throttling.
|
|
43
|
+
}
|
|
44
|
+
private detectGit(): boolean {
|
|
45
|
+
try {
|
|
46
|
+
execSync('git --version', { stdio: 'ignore' });
|
|
47
|
+
return true;
|
|
48
|
+
} catch { return false; }
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
public checkGitRemote() {
|
|
@@ -40,43 +54,46 @@ class VersionCheck {
|
|
|
40
54
|
if (typeof state.appVersion.nextCheckTime === 'undefined' || new Date() > new Date(state.appVersion.nextCheckTime)) setTimeout(() => { this.checkAll(); }, 100);
|
|
41
55
|
}
|
|
42
56
|
public checkGitLocal() {
|
|
43
|
-
|
|
44
|
-
//
|
|
57
|
+
const env = process.env;
|
|
58
|
+
// Branch
|
|
45
59
|
try {
|
|
46
60
|
let out: string;
|
|
47
|
-
if (typeof env.SOURCE_BRANCH !== 'undefined')
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
else {
|
|
52
|
-
let res = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' });
|
|
61
|
+
if (typeof env.SOURCE_BRANCH !== 'undefined') {
|
|
62
|
+
out = env.SOURCE_BRANCH;
|
|
63
|
+
} else if (this.gitAvailable) {
|
|
64
|
+
const res = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' });
|
|
53
65
|
out = res.toString().trim();
|
|
66
|
+
} else {
|
|
67
|
+
out = '--';
|
|
54
68
|
}
|
|
55
|
-
logger.info(`The current git branch output is ${out}`);
|
|
69
|
+
if (out !== '--') logger.info(`The current git branch output is ${out}`);
|
|
56
70
|
switch (out) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
catch (err) {
|
|
71
|
+
case 'fatal':
|
|
72
|
+
case 'command':
|
|
73
|
+
state.appVersion.gitLocalBranch = '--';
|
|
74
|
+
break;
|
|
75
|
+
default:
|
|
76
|
+
state.appVersion.gitLocalBranch = out;
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
66
79
|
state.appVersion.gitLocalBranch = '--';
|
|
67
|
-
|
|
80
|
+
if (!this.warnedBranch) {
|
|
81
|
+
logger.warn(`Unable to retrieve local git branch (git missing or not a repo). Further branch warnings suppressed.`);
|
|
82
|
+
this.warnedBranch = true;
|
|
83
|
+
}
|
|
68
84
|
}
|
|
85
|
+
// Commit
|
|
69
86
|
try {
|
|
70
87
|
let out: string;
|
|
71
|
-
if (typeof env.SOURCE_COMMIT !== 'undefined')
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
else {
|
|
76
|
-
let res = execSync('git rev-parse HEAD', { stdio: 'pipe' });
|
|
88
|
+
if (typeof env.SOURCE_COMMIT !== 'undefined') {
|
|
89
|
+
out = env.SOURCE_COMMIT;
|
|
90
|
+
} else if (this.gitAvailable) {
|
|
91
|
+
const res = execSync('git rev-parse HEAD', { stdio: 'pipe' });
|
|
77
92
|
out = res.toString().trim();
|
|
93
|
+
} else {
|
|
94
|
+
out = '--';
|
|
78
95
|
}
|
|
79
|
-
logger.info(`The current git commit output is ${out}`);
|
|
96
|
+
if (out !== '--') logger.info(`The current git commit output is ${out}`);
|
|
80
97
|
switch (out) {
|
|
81
98
|
case 'fatal':
|
|
82
99
|
case 'command':
|
|
@@ -85,10 +102,12 @@ class VersionCheck {
|
|
|
85
102
|
default:
|
|
86
103
|
state.appVersion.gitLocalCommit = out;
|
|
87
104
|
}
|
|
88
|
-
}
|
|
89
|
-
catch (err) {
|
|
105
|
+
} catch (err) {
|
|
90
106
|
state.appVersion.gitLocalCommit = '--';
|
|
91
|
-
|
|
107
|
+
if (!this.warnedCommit) {
|
|
108
|
+
logger.warn(`Unable to retrieve local git commit (git missing or not a repo). Further commit warnings suppressed.`);
|
|
109
|
+
this.warnedCommit = true;
|
|
110
|
+
}
|
|
92
111
|
}
|
|
93
112
|
}
|
|
94
113
|
private checkAll() {
|
|
@@ -126,15 +145,24 @@ class VersionCheck {
|
|
|
126
145
|
return new Promise<string>((resolve, reject) => {
|
|
127
146
|
try {
|
|
128
147
|
let req = https.request(url, options, async res => {
|
|
129
|
-
if (res.statusCode > 300 && res.statusCode < 400 && res.headers.location)
|
|
148
|
+
if (res.statusCode > 300 && res.statusCode < 400 && res.headers.location) {
|
|
149
|
+
try {
|
|
150
|
+
const redirected = await this.getLatestRelease(res.headers.location);
|
|
151
|
+
return resolve(redirected);
|
|
152
|
+
} catch (e) { return reject(e); }
|
|
153
|
+
}
|
|
130
154
|
let data = '';
|
|
131
155
|
res.on('data', d => { data += d; });
|
|
132
156
|
res.on('end', () => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
157
|
+
try {
|
|
158
|
+
let jdata = JSON.parse(data);
|
|
159
|
+
if (typeof jdata.tag_name !== 'undefined')
|
|
160
|
+
resolve(jdata.tag_name.replace('v', ''));
|
|
161
|
+
else
|
|
162
|
+
reject(`No data returned.`)
|
|
163
|
+
} catch(parseErr: any){
|
|
164
|
+
reject(`Error parsing Github response: ${ parseErr.message }`);
|
|
165
|
+
}
|
|
138
166
|
})
|
|
139
167
|
})
|
|
140
168
|
.end();
|