knxultimate
Version:
KNX IP protocol implementation for Node. This is the ENGINE of Node-Red KNX-Ultimate node.
596 lines (555 loc) • 15.6 kB
text/typescript
import { SerialPort, SerialPortOpenOptions } from 'serialport'
import { TypedEventEmitter } from '../TypedEmitter'
import { module as createLogger, KNXLogger } from '../KnxLog'
export type SerialPortSummary = {
path: string
manufacturer?: string
serialNumber?: string
vendorId?: string
productId?: string
type?: string
locationId?: string
}
export interface SerialFT12Options {
path?: string
baudRate?: number
dataBits?: number
stopBits?: number
parity?: 'none' | 'even' | 'odd'
rtscts?: boolean
dtr?: boolean
timeoutMs?: number
/** Treat the interface as a Weinzierl KBerry/BAOS module and run BAOS init (LinkLayer, indication, GA filter off). Default: true */
isKBERRY?: boolean
/** Forward to SerialPort "lock" flag. Default: true */
lock?: boolean
}
type SerialFT12Events = {
ready: () => void
close: () => void
error: (error: Error) => void
cemi: (payload: Buffer) => void
}
const DEFAULT_PATH = '/dev/ttyAMA0'
const DEFAULT_TIMEOUT_MS = 1200
const RESET_TIMEOUT_MS = 3000
const RESET_RETRIES = 3
const CLOSE_GRACE_MS = 2000
const ACK_BYTE = 0xe5
const COMM_MODE_FRAME = Buffer.from([
0xf6, 0x00, 0x08, 0x01, 0x34, 0x10, 0x01, 0x00,
])
export default class SerialFT12 extends TypedEventEmitter<SerialFT12Events> {
private port?: SerialPort
private portListeners?: {
data: (chunk: Buffer) => void
error: (error: Error) => void
close: () => void
}
private rxBuffer: Buffer = Buffer.alloc(0)
// Some KBerry/FT1.2 devices can emit an ACK before we arm waitForAck (e.g. on DTR toggle).
// Keep a spare counter so the next waitForAck can resolve immediately.
private pendingAck = 0
private lastCloseAt?: number
private awaitingAck?: {
resolve: () => void
reject: (err: Error) => void
timer: NodeJS.Timeout
}
private sendToggle = false
private isClosing = false
private logger: KNXLogger
constructor(private readonly options: SerialFT12Options) {
super()
this.logger = createLogger('FT12')
}
static async listPorts(): Promise<SerialPortSummary[]> {
const list = await SerialPort.list()
return list.map((item) => ({
path: item.path,
manufacturer: item.manufacturer || undefined,
serialNumber: item.serialNumber || undefined,
vendorId: item.vendorId || undefined,
productId: item.productId || undefined,
type: item.pnpId || undefined,
locationId: item.locationId || undefined,
}))
}
async open(): Promise<void> {
if (this.port) return
await this.waitAfterCloseIfNeeded()
const handler = await this.createSerialPort()
this.port = handler
this.attachPort(handler)
await this.initialize()
}
async close(): Promise<void> {
this.isClosing = true
const port = this.port
if (!port) {
this.isClosing = false
return
}
// Politely notify the interface before closing to avoid leftover state on KBerry/BAOS.
try {
await this.sendReset()
// Small grace to let the interface settle after reset.
await new Promise((resolve) => setTimeout(resolve, 50))
} catch (err) {
try {
this.logger.warn(
`FT1.2 close: reset before close failed: ${
(err as Error).message
}`,
)
} catch {}
}
await new Promise<void>((resolve) => {
let settled = false
let timeout: NodeJS.Timeout | undefined
const done = () => {
if (settled) return
settled = true
if (timeout) clearTimeout(timeout)
port.off('close', done)
resolve()
}
timeout = setTimeout(() => {
try {
this.logger.warn('FT1.2 close timed out; forcing cleanup')
} catch {}
done()
}, this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
timeout.unref?.()
port.once('close', done)
try {
port.close((err) => {
if (err) {
this.logger.error(`FT1.2 close error: ${err.message}`)
}
done()
})
} catch (error) {
this.logger.error(
`FT1.2 close exception: ${(error as Error).message}`,
)
done()
}
})
// Ensure the underlying serial handle really closes; retry once if it still reports open.
try {
if ((port as any).isOpen) {
try {
this.logger.warn(
'FT1.2 close: port still open after close request, retrying',
)
} catch {}
try {
port.close((err) => {
if (err) {
try {
this.logger.error(
`FT1.2 force close error: ${err.message}`,
)
} catch {}
}
})
} catch (err) {
try {
this.logger.error(
`FT1.2 force close exception: ${
(err as Error).message
}`,
)
} catch {}
}
}
} catch {}
this.detachPortListeners(port)
const pendingAck = this.awaitingAck
if (pendingAck) {
clearTimeout(pendingAck.timer)
pendingAck.reject(new Error('Serial FT1.2 port closed'))
this.awaitingAck = undefined
}
this.port = undefined
this.rxBuffer = Buffer.alloc(0)
this.pendingAck = 0
this.lastCloseAt = Date.now()
this.isClosing = false
}
async sendCemiPayload(payload: Buffer): Promise<void> {
if (!this.port) throw new Error('Serial FT1.2 port is not open')
const frame = this.buildLongFrame(payload)
await this.writeFrameWithAck(frame)
}
private async initialize() {
const isKBERRY = this.options.isKBERRY ?? true
// Weinzierl KBerry / BAOS: full init sequence
await this.sendReset()
if (isKBERRY) {
try {
this.logger.debug('FT1.2 → SetServerItem indication sending=on')
} catch {}
await this.setIndicationSending(true)
try {
this.logger.debug(
'FT1.2 → M_PropWrite COMM_MODE LinkLayer (f6 00 08 01 34 10 01 00)',
)
} catch {}
await this.sendCommMode()
try {
this.logger.debug(
'FT1.2 → M_PropWrite AddressTable length=0 (f6 04 00 01 02 10 01 00 00)',
)
} catch {}
try {
await this.disableGroupFilter()
} catch (err) {
this.logger.warn(
`Unable to disable group-address filter: ${
(err as Error).message
}`,
)
}
} else {
// Generic FT1.2: only reset + COMM_MODE if requested
await this.sendCommMode()
}
this.emit('ready')
}
private async sendReset() {
let attempt = 0
let lastErr: Error | undefined
while (attempt < RESET_RETRIES) {
attempt += 1
try {
this.logger.debug(
`FT1.2 → RESET_REQ 10 40 40 16 (attempt ${attempt}/${RESET_RETRIES})`,
)
} catch {}
const ackPromise = this.waitForAck(
'reset',
this.options.timeoutMs ?? RESET_TIMEOUT_MS,
)
await this.writeRaw(Buffer.from([0x10, 0x40, 0x40, 0x16]))
try {
await ackPromise
return
} catch (err) {
lastErr = err as Error
try {
this.logger.warn(
`FT1.2 RESET ack timeout (attempt ${attempt}/${RESET_RETRIES}): ${
(lastErr as Error).message
}`,
)
} catch {}
if (attempt < RESET_RETRIES) {
// Short backoff before retrying reset
await new Promise((resolve) => setTimeout(resolve, 200))
}
}
}
// Exhausted retries: continue initialisation anyway to avoid endless reconnect loops on devices
// that sometimes drop the reset ACK (observed on some KBERRY units).
try {
this.logger.warn(
`FT1.2 RESET ack missing after ${RESET_RETRIES} attempts, continuing initialisation`,
)
} catch {}
}
private async sendCommMode() {
await this.sendCemiPayload(COMM_MODE_FRAME)
}
/**
* Enable/disable BAOS indication sending (ServerItem #16).
* When enabled, BAOS forwards cEMI indications to the host.
*/
private async setIndicationSending(enable: boolean) {
const itemId = 0x0010
const value = Buffer.from([enable ? 0x01 : 0x00])
await this.sendBaosServerItem(itemId, value)
}
/**
* Set Address Table length = 0 so that KBerry forwards all group addresses.
* This affects receive-side filtering only.
*/
private async disableGroupFilter() {
const cemi = Buffer.from([
0xf6, // M_PropWrite.req
0x04,
0x00, // Interface Object: Address Table (4.0)
0x01, // Object instance 1
0x02, // Property ID 2 (Length)
0x10, // descriptor: 1 element
0x01, // start index = 1
0x00,
0x00, // new length = 0 (UINT16)
])
await this.sendCemiPayload(cemi)
}
private async sendBaosServerItem(itemId: number, data: Buffer) {
const payload = Buffer.alloc(9 + data.length)
let offset = 0
payload[offset++] = 0xf0 // Main service: BAOS
payload[offset++] = 0x02 // Sub service: SetServerItem
payload.writeUInt16BE(itemId, offset)
offset += 2 // start item id
payload.writeUInt16BE(1, offset)
offset += 2 // number of items
payload.writeUInt16BE(itemId, offset)
offset += 2 // first item id
payload[offset++] = data.length
data.copy(payload, offset)
await this.sendBaosPayload(payload)
}
private async sendBaosPayload(payload: Buffer) {
if (!this.port) throw new Error('Serial FT1.2 port is not open')
try {
this.logger.debug(`FT1.2 TX BAOS ${payload.toString('hex')}`)
} catch {}
const frame = this.buildLongFrame(payload)
await this.writeFrameWithAck(frame)
}
private async createSerialPort(): Promise<SerialPort> {
const serialOptions: SerialPortOpenOptions<any> = {
path: this.options.path || DEFAULT_PATH,
baudRate: this.options.baudRate ?? 19200,
dataBits: (this.options.dataBits ?? 8) as 5 | 6 | 7 | 8,
stopBits: (this.options.stopBits ?? 1) as 1 | 1.5 | 2,
parity: this.options.parity ?? 'even',
rtscts: this.options.rtscts ?? false,
lock: this.options.lock ?? true,
autoOpen: false,
}
return new Promise((resolve, reject) => {
const port = new SerialPort(serialOptions)
port.open((err) => {
if (err) {
reject(err)
return
}
const desiredDtr = this.options.dtr ?? true
port.set({ dtr: desiredDtr }, (setErr) => {
if (setErr) {
this.logger.warn(`Unable to set DTR: ${setErr.message}`)
}
resolve(port)
})
})
})
}
private attachPort(port: SerialPort) {
const onData = (chunk: Buffer) => this.handleChunk(chunk)
const onError = (error: Error) => this.emit('error', error)
const onClose = () => {
this.port = undefined
// Delay the close notification (even on intentional closes) to let the serial interface settle
setTimeout(() => {
this.emit('close')
}, 2000).unref?.()
}
this.portListeners = {
data: onData,
error: onError,
close: onClose,
}
port.on('data', onData)
port.on('error', onError)
port.on('close', onClose)
}
private detachPortListeners(port: SerialPort) {
if (!this.portListeners) return
const { data, error, close } = this.portListeners
port.off('data', data)
port.off('error', error)
port.off('close', close)
this.portListeners = undefined
}
private handleChunk(chunk: Buffer) {
try {
this.logger.debug(`FT1.2 RX chunk ${chunk.toString('hex')}`)
} catch {}
this.rxBuffer = Buffer.concat([this.rxBuffer, chunk])
while (this.rxBuffer.length > 0) {
const byte = this.rxBuffer[0]
if (byte === ACK_BYTE) {
this.consumeBytes(1)
this.resolveAck()
continue
}
if (byte === 0x10) {
if (this.rxBuffer.length < 4) break
const frame = this.rxBuffer.subarray(0, 4)
this.consumeBytes(4)
this.handleShortFrame(frame)
continue
}
if (byte === 0x68) {
if (this.rxBuffer.length < 6) break
const len = this.rxBuffer[1]
const total = len + 6
if (this.rxBuffer.length < total) break
const frame = this.rxBuffer.subarray(0, total)
this.consumeBytes(total)
this.handleLongFrame(frame)
continue
}
if (byte === 0xa0) {
if (this.rxBuffer.length < 2) break
const len = this.rxBuffer[1]
const total = len + 2
if (this.rxBuffer.length < total) break
this.consumeBytes(total)
continue
}
// Unknown leading byte, drop it
this.consumeBytes(1)
}
}
private consumeBytes(count: number) {
this.rxBuffer = this.rxBuffer.subarray(count)
}
private handleShortFrame(frame: Buffer) {
if (frame.length !== 4) return
// short frames are rarely used in FT1.2 host mode; we simply acknowledge control frames
this.sendAck()
}
private handleLongFrame(frame: Buffer) {
if (frame.length < 8) return
if (frame[0] !== 0x68 || frame[3] !== 0x68) return
const len = frame[1]
if (frame[2] !== len) return
if (frame[frame.length - 1] !== 0x16) return
const checksum = frame[frame.length - 2]
let calc = frame[4]
const payloadLen = len - 1
const payload = frame.subarray(5, 5 + payloadLen)
for (const byte of payload) {
calc = (calc + byte) & 0xff
}
if (calc !== checksum) {
this.logger.warn('Invalid FT1.2 checksum, dropping frame')
return
}
this.sendAck()
if (payload.length === 0) return
// BAOS payload (0xF0...) vs plain cEMI (0x11/0x29/0xF6/...)
if (payload[0] === 0xf0) {
try {
this.logger.debug(`FT1.2 RX BAOS ${payload.toString('hex')}`)
} catch {}
return
}
try {
this.logger.debug(`FT1.2 RX cEMI ${payload.toString('hex')}`)
} catch {}
this.emit('cemi', payload)
}
private sendAck() {
this.writeRaw(Buffer.from([ACK_BYTE])).catch(() => {})
}
private buildLongFrame(payload: Buffer) {
const len = payload.length + 1
const frame = Buffer.alloc(payload.length + 7)
frame[0] = 0x68
frame[1] = len
frame[2] = len
frame[3] = 0x68
frame[4] = this.sendToggle ? 0x73 : 0x53
this.sendToggle = !this.sendToggle
payload.copy(frame, 5)
let checksum = frame[4]
for (let i = 0; i < payload.length; i += 1) {
checksum = (checksum + payload[i]) & 0xff
}
frame[frame.length - 2] = checksum
frame[frame.length - 1] = 0x16
return frame
}
private async writeFrameWithAck(frame: Buffer): Promise<void> {
const ackPromise = this.waitForAck('frame')
try {
this.logger.debug(`FT1.2 TX frame ${frame.toString('hex')}`)
} catch {}
await this.writeRaw(frame)
await ackPromise
}
private writeRaw(buffer: Buffer): Promise<void> {
if (!this.port) {
return Promise.reject(new Error('Serial port is closed'))
}
return new Promise((resolve, reject) => {
this.port!.write(buffer, (err) => {
if (err) {
reject(err)
return
}
this.port!.drain((drainErr) => {
if (drainErr) {
reject(drainErr)
return
}
resolve()
})
})
})
}
private waitForAck(
label: string,
timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
) {
if (this.awaitingAck) {
// Overlapping ACK wait (for example during close/reset). Drop the previous waiter
// instead of throwing, to avoid crashing the host application.
try {
this.logger.warn(
`Previous FT1.2 ACK promise was still pending (${label}), dropping it`,
)
} catch {}
clearTimeout(this.awaitingAck.timer)
this.awaitingAck = undefined
}
// Resolve immediately if we already saw an ACK while no waiter was registered
if (this.pendingAck > 0) {
this.pendingAck -= 1
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
this.awaitingAck = undefined
reject(
new Error(
`Timeout waiting for FT1.2 ACK (${label})`,
),
)
}, timeoutMs)
this.awaitingAck = {
resolve: () => {
clearTimeout(timer)
this.awaitingAck = undefined
resolve()
},
reject: (err) => {
clearTimeout(timer)
this.awaitingAck = undefined
reject(err)
},
timer,
}
})
}
private resolveAck() {
if (!this.awaitingAck) return
this.awaitingAck.resolve()
}
private async waitAfterCloseIfNeeded() {
if (!this.lastCloseAt) return
const elapsed = Date.now() - this.lastCloseAt
const delay = CLOSE_GRACE_MS - elapsed
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}