Skip to content

Add 'ultratempdirect' heater type for direct RS-485 UltraTemp control#1152

Open
bestander wants to merge 1 commit intotagyoureit:masterfrom
bestander:heatpumpdirect
Open

Add 'ultratempdirect' heater type for direct RS-485 UltraTemp control#1152
bestander wants to merge 1 commit intotagyoureit:masterfrom
bestander:heatpumpdirect

Conversation

@bestander
Copy link

In my original pool setup as configured by the installer, UltraTemp was setup as a Solar heater. This caused a lot of unreliability in HeatPump controls because the EasyTouch controller may override the heatpump operations. I wanted to have a direct control over the heatpump and implemented a new heater type that talks to UltraTemp directly via RS-485 Action 114/115, bypassing the OCP entirely.

Previously doing this required hacking existing heater types with workaround flags (nixieOverride). This adds a clean new heater type (id 8, 'ultratempdirect') that:

  • Always uses the NixieUltratemp RS-485 class (Action 114/115 protocol)
  • Always has master=1 (njsPC controls it, not the OCP)
  • Has a user-configurable RS-485 address
  • Uses heater id >255 so EasyTouch config messages don't interfere
  • Supports both heating and cooling via the UltraTemp thermostat logic

Changes:

  • Add type 8 to heaterTypes valueMaps (SystemBoard, EasyTouchBoard, IntelliCenterBoard)
  • Add 'ultratempdirect' case to NixieHeaterBase factory, syncHeaterStates thermostat logic, and heat mode/source availability checks
  • Count ultratempdirect as heat pump installed so Heat Pump / Heat Pump Preferred modes appear in body heat mode options
  • Improve processUltraTempStatus() with null-safety checks, state change logging, and guard against unconfigured heat pump addresses
  • Auto-initialize NCP heaters on first use to handle the race condition where syncHeaterStates runs before ncp.initAsync completes
  • Sort master=1 heaters before master=0 in syncHeaterStates so NCP-controlled heaters take priority over OCP ghost heaters
  • Accept 'heatpump'/'heatpumppref' heat modes in the ultratemp thermostat case so the type works with standard heat pump mode selection

In my original pool setup as configured by the installer, UltraTemp was setup as a Solar heater.
This caused a lot of unreliability in HeatPump controls because the EasyTouch controller may override
the heatpump operations. I wanted to have a direct control over the heatpump and implemented a
new heater type that talks to UltraTemp directly via RS-485 Action 114/115, bypassing the OCP entirely.

Previously doing this required hacking existing heater types with workaround flags (nixieOverride).
This adds a clean new heater type (id 8, 'ultratempdirect') that:
- Always uses the NixieUltratemp RS-485 class (Action 114/115 protocol)
- Always has master=1 (njsPC controls it, not the OCP)
- Has a user-configurable RS-485 address
- Uses heater id >255 so EasyTouch config messages don't interfere
- Supports both heating and cooling via the UltraTemp thermostat logic

Changes:
- Add type 8 to heaterTypes valueMaps (SystemBoard, EasyTouchBoard,
  IntelliCenterBoard)
- Add 'ultratempdirect' case to NixieHeaterBase factory, syncHeaterStates
  thermostat logic, and heat mode/source availability checks
- Count ultratempdirect as heat pump installed so Heat Pump / Heat Pump
  Preferred modes appear in body heat mode options
- Improve processUltraTempStatus() with null-safety checks, state change
  logging, and guard against unconfigured heat pump addresses
- Auto-initialize NCP heaters on first use to handle the race condition
  where syncHeaterStates runs before ncp.initAsync completes
- Sort master=1 heaters before master=0 in syncHeaterStates so
  NCP-controlled heaters take priority over OCP ghost heaters
- Accept 'heatpump'/'heatpumppref' heat modes in the ultratemp
  thermostat case so the type works with standard heat pump mode selection
@tagyoureit
Copy link
Owner

PR #1152 Review: ultratempdirect Heater Type

PRs: #1152 (njsPC) / #103 (dashPanel)
Branch: heatpumpdirect -> master
Date: February 16, 2026


1. Overview

PR #1152 adds a new heater type ultratempdirect (type id 8) that lets njsPC talk directly to an UltraTemp heat pump via RS-485 Action 114/115, bypassing the EasyTouch OCP entirely.

The Real-World Problem

The PR author has a legitimate frustration. Their pool installer configured their UltraTemp heat pump as a "Solar" heater type on the EasyTouch controller. This is a common installer shortcut, but it causes real problems: the EasyTouch OCP applies solar differential logic (start/stop temperature deltas, efficiency calculations) to what is actually a heat pump. The result is unreliable heat pump behavior -- the OCP makes heating decisions based on rules designed for solar panels, not compressor-driven heat pumps.

Beyond this specific misconfiguration, EasyTouch has well-documented limitations with UltraTemp control in general. The controller cannot independently schedule when the heat pump runs, and RS-485 remote mode is known to cause rapid on/off cycling (every 5-8 minutes) due to poor temperature deadband handling. These are hardware/firmware limitations in the OCP that users understandably want to work around.

The PR author's solution was to have njsPC take over direct RS-485 control of the UltraTemp, bypassing the OCP entirely. This is a sound goal -- the problem is in how it was implemented.

What the PR Does

The PR introduces a brand new heater type (ultratempdirect, type 8) to represent "an UltraTemp that njsPC controls directly." Under the hood, it routes to the exact same NixieUltratemp RS-485 class that the existing ultratemp type already uses when configured with master=1. The new type exists primarily to signal "don't let the OCP touch this" -- but as we'll see in section 3, there's already a mechanism for that.

Files changed (6):

  • controller/boards/SystemBoard.ts -- adds type 8, modifies heat mode/source availability, adds master=1 sort priority
  • controller/boards/EasyTouchBoard.ts -- adds type 8, suppresses OCP ghost heaters when ultratempdirect present
  • controller/boards/IntelliCenterBoard.ts -- adds type 8
  • controller/comms/messages/Messages.ts -- adds Protocol.Heater response matching
  • controller/comms/messages/status/HeaterStateMessage.ts -- null-safety, state-change logging
  • controller/nixie/heaters/Heater.ts -- routes ultratempdirect to NixieUltratemp, auto-init race fix

PR #103 (dashPanel) is trivial: adds case 'ultratempdirect': fallthrough to the ultratemp glyph/options in scripts/config/heaters.js.


2. Valid Bug Fixes Worth Cherry-Picking

Not everything in this PR should be rejected. Mixed in with the new heater type are four genuine bug fixes and improvements that stand on their own. These should be cherry-picked into master regardless of what happens with the ultratempdirect type itself, because they improve direct heater control for any master=1 heater configuration:

2a. Protocol.Heater Response Matching (Messages.ts)

Adds response matching for the heater protocol so njsPC can correctly pair Action 114 requests with Action 115 responses. Without this, direct heater control (for any master=1 heater) may fail to match responses.

if (msgOut.protocol === Protocol.Heater) {
    if (msgIn.source !== msgOut.dest || (msgIn.dest !== msgOut.source && msgIn.dest !== 16)) { return false; }
    if (this.action > 0 && this.action === msgIn.action) return true;
    return false;
}

2b. Null-Safety + Logging (HeaterStateMessage.ts)

Guards processUltraTempStatus() against unconfigured heater addresses (prevents crash if an Action 115 arrives from an unknown heater) and adds state-change logging for debugging:

let heater: Heater = sys.heaters.getItemByAddress(msg.source);
if (typeof heater === 'undefined' || !heater.isActive) {
    msg.isProcessed = true;
    return;
}
// ... state change logging ...
if (prevOn !== sheater.isOn || prevCooling !== sheater.isCooling) {
    logger.info(`UltraTemp heartbeat: src=${msg.source} status=${byte} ...`);
}

2c. Auto-Init Race Condition Fix (Heater.ts)

Handles the case where syncHeaterStates runs before ncp.initAsync completes. Instead of rejecting with an error, it auto-initializes the heater on first use:

if (typeof h === 'undefined') {
    let heater = sys.heaters.getItemById(hstate.id);
    if (typeof heater !== 'undefined' && heater.master === 1 && heater.isActive !== false) {
        logger.info(`NCP: Auto-initializing heater ${hstate.id}-${heater.name}`);
        h = await this.initHeaterAsync(heater);
    }
}

2d. master=1 Sort Priority (SystemBoard.ts)

Sorts NCP-controlled heaters (master=1) before OCP-controlled heaters (master=0) in syncHeaterStates, so directly controlled heaters get priority over OCP ghost heaters:

bodyHeaters.sort((a, b) => {
    if (a.master !== b.master) return b.master - a.master;
    if (heaterTypes.transform(a.type).hasPreference) return -1;
    else if (heaterTypes.transform(b.type).hasPreference) return 1;
    return 0;
});

3. Architectural Concern: Why a New Heater Type is the Wrong Approach

Recommendation: Do not merge the new ultratempdirect type.

The Core Issue: Confusing "What It Is" with "Who Controls It"

The heaterTypes valueMap in njsPC answers one question: what kind of heater hardware is this? Each entry represents a physically different piece of equipment with its own RS-485 protocol:

Val Name Hardware
1 gas Gas Heater
2 solar Solar Heater
3 heatpump Heat Pump
4 ultratemp UltraTemp
5 hybrid UltraTemp ETi Hybrid
6 mastertemp MasterTemp
7 maxetherm Max-E-Therm

An ultratempdirect heater is not a different piece of hardware. It's the exact same UltraTemp heat pump, sitting on the same RS-485 bus, speaking the same Action 114/115 protocol. The only difference is who is sending the commands -- the OCP or njsPC. That's a control method distinction, not a hardware distinction.

The Precedent Problem

If we accept "direct control" as a reason to create a new type, the same argument applies to every other RS-485 heater. Someone with a MasterTemp could want njsPC to bypass their EasyTouch and send Action 112/116 directly. Someone with a Hybrid ETi could want the same via Action 112/113. We'd end up with:

  • mastertempdirect -- MasterTemp bypass
  • hybriddirect -- UltraTemp ETi Hybrid bypass
  • heatpumpdirect -- generic heat pump bypass
  • And potentially similar patterns for pumps, chlorinators, and any other equipment

Each new "direct" type adds entries to valueMaps across SystemBoard, EasyTouchBoard, and IntelliCenterBoard. Each needs case statements in syncHeaterStates, getInstalledHeaterTypes, updateHeaterServices, and the dashPanel UI. The maintenance burden grows linearly with every piece of equipment someone wants to control directly.

The Right Abstraction Already Exists

The codebase already solved this problem at a design level. There are two properties on every equipment item that together answer the question "who controls this device and how do they reach it":

  1. heater.master (0=OCP, 1=NCP) -- This is the "who controls it" switch. When master=0, the OCP owns the device and njsPC just listens. When master=1, njsPC/Nixie takes direct control. The EasyTouchBoard.setHeaterAsync() already checks for this: if master === 1, it delegates to SystemBoard.setHeaterAsync() which creates a Nixie-managed heater. This delegation is already in the code and works today.

  2. heater.portId (0=primary, 1+=aux) -- This is the "which wire" switch. It routes RS-485 traffic to a specific USB adapter. Every heater already has this property, and the NixieUltratemp class already uses this.heater.portId when creating outbound Action 114 messages.

  3. NixieUltratemp class -- Already knows how to send Action 114 commands and receive Action 115 responses. It already handles thermostat logic, cooling, startup delays, and communication error tracking. No code changes are needed to make it work with a master=1 heater of the existing ultratemp type.

  4. Multi-port RS-485 -- Comms.ts already supports multiple controller.comms* sections, each creating a separate RS485Port. This infrastructure exists for exactly this kind of scenario.

The right answer is master=1 on an existing ultratemp type, not a new type. The PR author's ultratempdirect essentially reimplements what master=1 already provides, but baked into the type system where it doesn't belong.


4. Manual Testing (JSON Editing)

To prove that the existing master=1 mechanism works without a new heater type, you can test it today by editing poolConfig.json directly. This requires no code changes (apart from the cherry-picked bug fixes above). The trade-off is that there's currently no UI for setting master=1 on a heater -- you have to edit JSON. Section 6 addresses closing that gap properly.

Prerequisites

Before doing this, the UltraTemp must be removed from the OCP's heater configuration (set it to "none" on the EasyTouch panel). This is the critical step. The OCP and njsPC cannot both command the same heater on the same bus -- if they do, the UltraTemp receives conflicting Action 114 commands and rapid-cycles between heat/off states. One controller must own the heater exclusively.

Steps

  1. Stop njsPC.

  2. Edit data/poolConfig.json. Add a new entry to the heaters array:

{
  "id": 256,
  "isActive": true,
  "portId": 0,
  "type": 4,
  "body": 32,
  "master": 1,
  "cooldownDelay": 5,
  "startTempDelta": 5,
  "stopTempDelta": 3,
  "coolingEnabled": true,
  "differentialTemp": 3,
  "address": 112,
  "name": "UltraTemp Direct"
}

Key fields:

  • id: 256 -- Must be >= 256 so EasyTouch config messages (which manage ids 1-255) don't overwrite it.
  • type: 4 -- UltraTemp (existing type, not a new one).
  • master: 1 -- Tells njsPC this heater is controlled by the Nixie Control Panel, not the OCP.
  • portId: 0 -- Use the primary RS-485 bus (same bus as OCP). Set to 1 if using a 2nd USB adapter.
  • address: 112 -- The UltraTemp's RS-485 address (default 112; check the heat pump's INTELL ADDRESS menu setting).
  • body: 32 -- Pool/Spa shared body. Use 0 for pool-only or 1 for spa-only.
  1. Restart njsPC. The NixieHeaterCollection.initAsync() will find master === 1, create a NixieUltratemp instance, and begin polling/commanding the UltraTemp via Action 114/115 on portId 0.

  2. Verify. Check the njsPC log for:

    • Initializing Heater UltraTemp Direct -- confirms Nixie picked it up
    • UltraTemp heartbeat: src=112 status=... -- confirms RS-485 communication (requires cherry-pick 2b)

5. Alternative A: 2nd USB Stick (Zero Code Changes)

The manual test in section 4 shares a single RS-485 bus between the OCP and njsPC, which works but requires removing the UltraTemp from the OCP's config. Some users may not want to (or can't) change their OCP configuration. For those cases, a second USB RS-485 adapter (~$10-15) gives the UltraTemp its own dedicated bus, completely eliminating the dual-master concern.

The idea is simple: physically move the UltraTemp's RS-485 wires off the OCP's bus and onto a private bus that only njsPC can see. The OCP will show a heater communication error (it's trying to talk to a device that's no longer on its bus), but that's cosmetic -- the UltraTemp is happily taking orders from njsPC on its own private line.

Setup

  1. Physically rewire the UltraTemp's RS-485 (yellow/green wires) from the OCP's com port to the 2nd USB adapter.

  2. Add a second comms section to config.json:

{
  "controller": {
    "comms": {
      "type": "local",
      "portId": 0,
      "enabled": true,
      "rs485Port": "/dev/ttyUSB0"
    },
    "comms1": {
      "type": "local",
      "portId": 1,
      "enabled": true,
      "rs485Port": "/dev/ttyUSB1",
      "portSettings": {
        "baudRate": 9600,
        "dataBits": 8,
        "parity": "none",
        "stopBits": 1,
        "rtscts": false,
        "autoOpen": false,
        "lock": false
      }
    }
  }
}
  1. Set the heater's portId: 1 in poolConfig.json (same JSON as section 4, but with "portId": 1).

Advantages

  • Complete bus isolation: OCP cannot send conflicting commands to UltraTemp
  • OCP config doesn't need to change (it will show a heater comm error, but that's cosmetic)
  • Works with the existing codebase today

Known Gap

hasAssignedEquipment() in controller/comms/Comms.ts (line 1256) only checks pumps and chlorinators, not heaters. This means the aux port won't report having assigned equipment. This is a trivial fix (add a heater check) but not strictly required for functionality.


6. Alternative B: Small UI Changes (Recommended)

The manual JSON editing in section 4 proves the concept, but "stop the app and edit JSON" isn't a real solution for end users. The reason the PR author created a new heater type in the first place is that the dashPanel UI has no way to set master=1 on a heater. There's no dropdown, no checkbox, no toggle. The master property exists in the data model, the backend fully supports it, but the UI simply doesn't expose it.

This is already a solved problem elsewhere in the codebase. The chlorinator configuration UI has a "Controlled By" dropdown that lets you choose between OCP and NCP, and a port selector for choosing which USB adapter to use. The heater config UI just needs the same treatment.

The cleanest long-term solution is to expose the existing master and portId properties in the dashPanel heater configuration UI, matching the pattern already used for chlorinators. This is a small change -- roughly two lines on the backend and one dropdown on the frontend -- but it unlocks direct control for every heater type, not just UltraTemp.

njsPC Change

Add equipmentMasters and rs485ports to the /config/options/heaters response in web/services/config/Config.ts. Currently the heater options return:

// Current (line 237-252)
let opts = {
    tempUnits: ...,
    bodies: ...,
    maxHeaters: ...,
    heaters: sys.heaters.get(),
    heaterTypes: sys.board.valueMaps.heaterTypes.toArray(),
    heatModes: ...,
    coolDownDelay: ...
};

Should be extended to match the chlorinator pattern (line 370-381):

let opts = {
    // ... existing fields ...
    equipmentMasters: sys.board.valueMaps.equipmentMaster.toArray(),
    rs485ports: await conn.listInstalledPorts()
};

The equipmentMaster valueMap already exists:

Val Name Description
0 ocp Outdoor Control Panel
1 ncp Nixie Control Panel
2 ext External Control Panel

dashPanel Change

Add a "Controlled By" dropdown to the heater config form (the same pattern used in the chlorinator config UI). When the user selects "Nixie Control Panel" (master=1):

  • The PUT /config/heater endpoint receives { master: 1, type: "ultratemp", ... }
  • EasyTouchBoard.setHeaterAsync() sees master === 1 and delegates to SystemBoard.setHeaterAsync()
  • SystemBoard.setHeaterAsync() assigns id >= 256 and creates a Nixie-managed heater
  • NixieUltratemp class handles all RS-485 communication

Optionally add a "Port" dropdown for users with multiple USB adapters.

Why This is Better Than a New Type

This approach solves the problem generically instead of one heater model at a time:

  • No new heater types needed -- Works for UltraTemp, MasterTemp, Hybrid, or any future equipment that someone wants to control directly. One UI change covers them all.
  • Follows existing patterns -- The chlorinator UI already does this exact thing. We're not inventing anything new.
  • Small change footprint -- ~2 lines in njsPC to add the options to the API response, and one dropdown in dashPanel. Compare that to the 211 lines across 6 files that ultratempdirect required.
  • Backend already fully supports it -- EasyTouchBoard.setHeaterAsync() already delegates to SystemBoard.setHeaterAsync() when it sees master === 1. The NixieUltratemp class already handles Action 114/115. The only missing piece is letting the user flip the switch from the UI.

7. Single Bus Feasibility

Can njsPC and the OCP share a single RS-485 bus for direct heater control?

Yes, with one critical requirement: the OCP must NOT also be commanding the same heater.

This is a common question because adding a second USB adapter feels like extra complexity. The good news is that RS-485 is inherently a multi-drop bus -- the OCP, pumps, chlorinators, and heaters are already all sharing it. njsPC is already on that same bus (portId=0), listening to everything and occasionally sending commands. Adding heater control to njsPC's responsibilities on that bus is not fundamentally different from what it already does.

The bus contention concern (two devices trying to transmit at the same time) is already handled. njsPC has TX delays and retry logic in Comms.ts that prevent collisions with the OCP's traffic. The NixieUltratemp class retries Action 114 commands up to 3 times if a response isn't received.

The real danger isn't bus contention -- it's dual-master conflict. If EasyTouch has the UltraTemp configured (as Solar, HeatPump, or any type), the OCP will periodically send its own Action 114 commands based on its own thermostat logic. Meanwhile njsPC sends its own Action 114 commands based on Nixie thermostat logic. The UltraTemp doesn't know or care who is talking to it -- it simply obeys the last command it received. The result is the heater flickering between heat/off as two controllers fight over it.

Resolution: Remove the heater from the OCP's configuration. Once the OCP doesn't know about the heater, it stops sending Action 114 entirely. njsPC becomes the sole master. The 2nd USB stick (section 5) is the alternative for users who can't or won't change their OCP config -- it achieves the same isolation through physical separation rather than configuration.

@bestander
Copy link
Author

bestander commented Feb 16, 2026 via email

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.

2 participants