# 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 and metrics (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.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 and metrics for group read/write/request; push receive metrics; 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)
    }
})
```

Push telegrams also increment the OpenTelemetry metric `knx.group.operations` with `knx.operation=receive` when a metrics exporter is configured in the host process.

## OpenTelemetry (3.3.0+)

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

| Signal      | What is instrumented                                                                                                           |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **Traces**  | `read()`, `requestValue()`, `write()` — span `knx.group` (child span only; requires an active parent span in the OTEL context) |
| **Metrics** | All of the above plus push `receive` (counter + histogram)                                                                     |

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** — metrics only (no spans), to avoid noise and unrelated HTTP trace parents

Export traces and metrics from the host app (e.g. OTLP for traces, Prometheus exporter for metrics). Without an SDK, instrumentation 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)
