# js-knx

TypeScript/JavaScript client for KNX/IP gateways (tunneling connection, link layer).

Depends on [`@opentelemetry/api`](https://www.npmjs.com/package/@opentelemetry/api) for optional tracing (no-op until the host process registers an OpenTelemetry SDK). Works on Node.js 20+.

See [CHANGELOG](https://github.com/kodmax/jsKnx/blob/main/CHANGELOG.md) for release notes. Recent versions:

- **3.5.0** — Removed OpenTelemetry metrics (cardinality); traces unchanged
- **3.4.1** — KNX group trace spans only when a parent span is active (no root spans from js-knx)
- **3.4.0** — `abortConnect()` to cancel in-flight `connect()` during retry; `CONNECTION_ABORTED` error code
- **3.3.0** — OpenTelemetry spans for group read/write/request; optional `onReceive` callback
- **3.2.0** — Per group address read timeout counters (`getReadTimeoutStats()`)
- **3.1.0** — `addResponseListener` / explicit write vs read-response listeners (replaces `addValueListener`)
- **3.0.0** — `KnxLink.group()` (renamed from `getDatapoint()`)

## Installation

```bash
npm install js-knx
# or
yarn add js-knx
```

The package ships as a **dual package**: use `require('js-knx')` in CommonJS or `import { KnxLink } from 'js-knx'` in ESM. Entry points are defined in `package.json` `exports`.

## Quick start

```typescript
import { DPT_HVACMode, KnxLink } from 'js-knx'

const knx = new KnxLink('192.168.0.8', { readTimeout: 5000 })

knx.on('error', err => {
    console.error(err.message, err.code)
})

await knx.connect()

const livingRoom = knx.group({
    address: '2/0/4',
    DataType: DPT_HVACMode
})

const reading = await livingRoom.read()
console.log(reading.text, reading.value)

await knx.disconnect()
```

Group addresses use KNX notation (`main/middle/sub`, e.g. `2/0/4`).

### Listen for bus traffic

```typescript
knx.on('cemi-frame', frame => {
    console.log(frame.source, '->', frame.target, frame.value)
})
```

### Write and subscribe

```typescript
import { DPT_Switch, KnxLink } from 'js-knx'

const knx = new KnxLink('192.168.0.8')
knx.on('error', err => console.error(err))
await knx.connect()

const light = knx.group({ address: '14/0/0', DataType: DPT_Switch })

await light.on()

light.onValue(reading => {
    console.log('state:', reading.text)
})

await knx.disconnect()
```

## CLI

After installation, two commands are available globally:

| Command     | Description                      |
| ----------- | -------------------------------- |
| `knx-read`  | Read a group address once        |
| `knx-write` | Write a value to a group address |

Gateway IP can be passed as the first argument or via the `KNX_GATEWAY` environment variable.

```bash
# read
knx-read 192.168.0.8 2/0/4 HVACMode
KNX_GATEWAY=192.168.0.8 knx-read 2/0/4 Switch

# write
knx-write 2/0/4 Switch 1
knx-write 192.168.0.8 2/0/4 Scaling 50
```

DPT names accept short form (`Switch`, `HVACMode`) or full export name (`DPT_Switch`).

Numeric string values are coerced to numbers automatically (`"1"` → `1`). Time/date strings are passed through as-is.

## Supported DPT types

Each DPT is a class exported from `js-knx`. Instantiate via `knx.group({ address, DataType })`.

### 1.x — Boolean / binary

| Class             | KNX DPT |
| ----------------- | ------- |
| `DPT_Switch`      | 1.001   |
| `DPT_Bool`        | 1.002   |
| `DPT_Enable`      | 1.003   |
| `DPT_Alarm`       | 1.005   |
| `DPT_UpDown`      | 1.008   |
| `DPT_OpenClose`   | 1.009   |
| `DPT_StartStop`   | 1.010   |
| `DPT_State`       | 1.011   |
| `DPT_Reset`       | 1.015   |
| `DPT_Ack`         | 1.016   |
| `DPT_Trigger`     | 1.017   |
| `DPT_Occupancy`   | 1.018   |
| `DPT_Window_Door` | 1.019   |
| `DPT_DayNight`    | 1.024   |
| `DPT_Generic_B1`  | —       |

### 5.x — Unsigned 8-bit

| Class                | KNX DPT |
| -------------------- | ------- |
| `DPT_Scaling`        | 5.001   |
| `DPT_Angle`          | 5.003   |
| `DPT_Percent_U8`     | 5.004   |
| `DPT_Tariff`         | 5.006   |
| `DPT_Value_1_Ucount` | 5.010   |
| `DPT_Generic_U8`     | —       |

### 9.x — Float 16-bit

| Class                  | KNX DPT |
| ---------------------- | ------- |
| `DPT_Value_Temp`       | 9.001   |
| `DPT_Value_Humidity`   | 9.007   |
| `DPT_Value_AirQuality` | 9.008   |
| `DPT_Generic_F16`      | —       |

### 10.x / 11.x / 19.x — Time and date

| Class          | KNX DPT |
| -------------- | ------- |
| `DPT_Time`     | 10.001  |
| `DPT_Date`     | 11.001  |
| `DPT_DateTime` | 19.001  |

### 13.x / 14.x — Energy and power

| Class                                 | KNX DPT |
| ------------------------------------- | ------- |
| `DPT_ActiveEnergy`                    | 13.010  |
| `DPT_Value_Electric_Current`          | 14.019  |
| `DPT_Value_Electric_Potential`        | 14.027  |
| `DPT_Value_Frequency`                 | 14.031  |
| `DPT_Value_Power_Factor`              | 14.057  |
| `DPT_Value_Power`                     | 14.056  |
| `DPT_Value_ApparentPower`             | 14.080  |
| `DPT_Generic_V32` / `DPT_Generic_F32` | —       |

### 20.x / 21.x — HVAC and status

| Class               | KNX DPT |
| ------------------- | ------- |
| `DPT_HVACMode`      | 20.102  |
| `DPT_HVACContrMode` | 20.105  |
| `DPT_StatusGen`     | 21.001  |

Several DPT classes expose convenience methods (e.g. `DPT_Switch.on()` / `.off()`, `DPT_HVACMode` mode constants).

## `KnxLinkConstructorOptions`

All options are optional when calling `new KnxLink(ip, options)`.

| Option                  | Default    | Description                                                                                 |
| ----------------------- | ---------- | ------------------------------------------------------------------------------------------- |
| `readTimeout`           | `10000`    | Timeout (ms) for `read()` waiting for a group response                                      |
| `connectionTimeout`     | `10000`    | Timeout (ms) for the initial KNX/IP tunnel handshake                                        |
| `maxRetry`              | `Infinity` | Retries when the gateway rejects connection (e.g. both tunnel slots busy)                   |
| `retryPause`            | `3000`     | Pause (ms) between connection retries; also used for automatic reconnect after network loss |
| `maxConcurrentMessages` | `16`       | Max telegrams awaiting gateway ACK before back-pressure                                     |
| `maxTelegramsPerSecond` | `24`       | Send rate limit; lower if you see read timeouts on busy buses                               |
| `port`                  | `3671`     | KNX/IP UDP port of the gateway                                                              |
| `autoReconnect`         | `true`     | Reconnect automatically after gateway or network loss (not after `disconnect()`)            |
| `onReceive`             | —          | Optional callback after a push group write is decoded (see below)                           |

Subscribe to link events with `knx.on('error', …)` and `knx.on('cemi-frame', …)`.

### Push receive callback (`onReceive`)

Since **3.3.0**, you can observe spontaneous bus writes without trace spans:

```typescript
const knx = new KnxLink('192.168.0.8', {
    onReceive: ({ groupAddress, dpt, source }) => {
        console.log('push', source, '->', groupAddress, dpt)
    }
})
```

## OpenTelemetry (3.3.0+; metrics removed in 3.5.0)

When the host application registers an OpenTelemetry SDK, js-knx emits **traces only**:

| Signal     | What is instrumented                                                                                                           |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **Traces** | `read()`, `requestValue()`, `write()` — span `knx.group` (child span only; requires an active parent span in the OTEL context) |

Trace attributes: `knx.group_address`, `knx.operation` (`read` \| `request_value` \| `write`), `knx.dpt`.

- **`read()`** — one span for the full round-trip; span events `knx.read_sent` and `knx.value_received`
- **`requestValue()`** — short span covering the group-read send only (no span events, no wait for response)
- **Push writes** — no spans (use `onReceive` if you need a callback)

Child `knx.group` spans are created only when an active parent span exists. Without an SDK, tracing is a no-op.

### Connection behaviour

- **Initial connect** retries with `maxRetry` / `retryPause` until the gateway accepts a tunnel (useful when both tunnel channels are occupied).
- **`abortConnect()`** (since **3.4.0**) — cancel startup while `connect()` is still retrying. Rejects the pending `connect()` with `CONNECTION_ABORTED`, emits `disconnected` with reason `connect-aborted`, and closes any opened UDP transport. No-op when not connecting. Use on process shutdown instead of waiting for retries to finish; call `disconnect()` after a successful connection.
- **Automatic reconnect** runs after unexpected session loss (socket close, gateway disconnect). It does not run after an explicit `disconnect()` or after `abortConnect()`.
- **Second `connect()`** on the same `KnxConnection` throws `CONNECTION_ALREADY_ESTABLISHED` or `CONNECTION_IN_PROGRESS`.

```typescript
import { KnxLink, KnxLinkException } from 'js-knx'

const knx = new KnxLink('192.168.0.8')
const connectPromise = knx.connect()

process.on('SIGTERM', () => {
    knx.abortConnect()
})

try {
    await connectPromise
} catch (err) {
    if (err instanceof KnxLinkException && err.code === 'CONNECTION_ABORTED') {
        // startup cancelled — e.g. dev server restart
    }
}
```

## Datapoint API

Every DPT class extends `DataPointAbstract`:

| Method                         | Description                                                      |
| ------------------------------ | ---------------------------------------------------------------- |
| `read()`                       | Send group read, wait for response (`KnxReading<T>`)             |
| `write(value)`                 | Send group write (DPT-specific value type)                       |
| `requestValue()`               | Send group read without waiting                                  |
| `addWriteListener(cb)`         | Subscribe to incoming group writes                               |
| `addResponseListener(cb)`      | Subscribe to group-read responses (e.g. after `requestValue`)    |
| `onValue(cb)` / `offValue(cb)` | Subscribe to both writes and read responses                      |
| `getAddress()`                 | Group address string                                             |
| `getReadTimeoutStats()`        | Cumulative and consecutive `read()` timeout counts (since 3.2.0) |
| `resetReadTimeoutStats()`      | Reset read timeout counters to zero                              |
| `getLink()`                    | Parent link (`KnxDatapointLink`; implemented by `KnxLink`)       |
| `toString(value?)`             | Human-readable label                                             |

`KnxReading` shape:

```typescript
{
    target: string // group address
    source: string // individual address (e.g. "1.2.3")
    text: string // formatted value
    unit: string
    value: T
}
```

## Error handling

Errors are thrown as `KnxLinkException` with a `code` field:

| Code                             | Typical cause                                                                                                           |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `CONNECTION_ABORTED`             | `abortConnect()` called while `connect()` was retrying (since 3.4.0)                                                    |
| `CONNECTION_TIMEOUT`             | Gateway did not respond during handshake                                                                                |
| `CONNECTION_ERROR`               | Gateway rejected connection (busy, wrong layer, …)                                                                      |
| `CONNECTION_ALREADY_ESTABLISHED` | `connect()` called twice                                                                                                |
| `READ_TIMEOUT`                   | No bus response within `readTimeout`; `details` includes `readTimeoutCount` and `consecutiveReadTimeouts` (since 3.2.0) |
| `DATA_LENGTH_MISMATCH`           | Received payload size does not match DPT                                                                                |
| `PROTOCOL_ERROR`                 | Invalid KNX/IP or cEMI frame                                                                                            |
| `NO_CONNECTION`                  | Send attempted after disconnect                                                                                         |
| `ACK_TIMEOUT`                    | Gateway did not ACK a tunnel telegram (after retry)                                                                     |
| `NETWORK_ERROR`                  | UDP socket connect or send failure                                                                                      |

Listen on `knx.on('error', …)` for non-fatal bus/protocol errors during operation.

## Protocol references

KNX/IP tunneling implementation notes and further reading:

- [KNX/IP documentation (eb-systeme.de)](http://www.eb-systeme.de/?page_id=479) — referenced in the connection layer source

## Development

```bash
git clone https://github.com/kodmax/jsKnx.git
cd jsKnx
yarn install
yarn build
yarn test
yarn lint:ci
```

Local demo (edit gateway IP in `examples/demo.ts`):

```bash
yarn build   # required once so `js-knx` resolves for the example imports
yarn dev
```

## License

[GPL-3.0](LICENSE.md)
