elero-usb-transmitter-client 1.0.5 → 1.1.1

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.
Files changed (40) hide show
  1. package/.github/workflows/nodejs.yml +27 -0
  2. package/README.md +58 -1
  3. package/dist/UsbTransmitterClient.d.ts +18 -0
  4. package/dist/UsbTransmitterClient.js +284 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +277 -0
  7. package/dist/domain/constants.d.ts +32 -0
  8. package/dist/domain/constants.js +44 -0
  9. package/dist/domain/enums.d.ts +34 -0
  10. package/dist/domain/enums.js +40 -0
  11. package/dist/domain/types.d.ts +3 -0
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.js +18 -0
  14. package/dist/model/Response.d.ts +10 -0
  15. package/dist/model/Response.js +2 -0
  16. package/dist/src/UsbTransmitterClient.d.ts +2 -2
  17. package/dist/src/UsbTransmitterClient.js +5 -4
  18. package/dist/src/cli.d.ts +2 -0
  19. package/dist/src/cli.js +277 -0
  20. package/jest.json +1 -1
  21. package/package.json +6 -3
  22. package/src/UsbTransmitterClient.ts +52 -44
  23. package/src/cli.ts +167 -0
  24. package/test/UsbTransmitterClient.test.ts +39 -0
  25. package/test/UsbTransmitterClientMock.test.ts +182 -0
  26. package/__test__/UsbTransmitterClient.test.ts +0 -31
  27. package/dist/src/ComfortCloudClient.d.ts +0 -21
  28. package/dist/src/ComfortCloudClient.js +0 -215
  29. package/dist/src/model/Device.d.ts +0 -182
  30. package/dist/src/model/Device.js +0 -374
  31. package/dist/src/model/Group.d.ts +0 -10
  32. package/dist/src/model/Group.js +0 -32
  33. package/dist/src/model/LoginData.d.ts +0 -6
  34. package/dist/src/model/LoginData.js +0 -11
  35. package/dist/src/model/Parameters.d.ts +0 -12
  36. package/dist/src/model/ServiceError.d.ts +0 -7
  37. package/dist/src/model/ServiceError.js +0 -41
  38. package/dist/src/model/TokenExpiredError.d.ts +0 -4
  39. package/dist/src/model/TokenExpiredError.js +0 -24
  40. /package/dist/{src/model/Parameters.js → domain/types.js} +0 -0
@@ -1,4 +1,4 @@
1
- import * as SerialPort from 'serialport'
1
+ import { SerialPort } from 'serialport'
2
2
  import * as _ from 'lodash'
3
3
  import {
4
4
  BYTE_HEADER,
@@ -21,10 +21,11 @@ const DEFAULT_STOPBITS = 1
21
21
  const mutex = new Mutex()
22
22
 
23
23
  export class UsbTransmitterClient {
24
- serialPort: SerialPort
24
+ serialPort: SerialPort<any>
25
25
 
26
26
  constructor(devPath: string) {
27
- this.serialPort = new SerialPort(devPath, {
27
+ this.serialPort = new SerialPort({
28
+ path: devPath,
28
29
  baudRate: DEFAULT_BAUDRATE,
29
30
  dataBits: DEFAULT_BYTESIZE,
30
31
  parity: DEFAULT_PARITY,
@@ -59,20 +60,14 @@ export class UsbTransmitterClient {
59
60
  public async checkChannels(): Promise<number[]> {
60
61
  const data = [BYTE_HEADER, BYTE_LENGTH_2, EasyCommand.EASY_CHECK]
61
62
  const release = await mutex.acquire()
62
- await this.sendCommand(data)
63
- return new Promise((resolve, reject) => {
64
- const that = this
65
- this.serialPort.once('readable', function () {
66
- const responseBytes = that.readResponseBytes(RESPONSE_LENGTH_CHECK)
67
- if (responseBytes == null) {
68
- release()
69
- return reject('responseBytes are null.')
70
- }
71
- const response = that.parseResponse(responseBytes as Buffer)
72
- release()
73
- return resolve(response.activeChannels)
74
- })
75
- })
63
+ try {
64
+ await this.sendCommand(data)
65
+ const responseBytes = await this.waitForResponse(RESPONSE_LENGTH_CHECK)
66
+ const response = this.parseResponse(responseBytes)
67
+ return response.activeChannels
68
+ } finally {
69
+ release()
70
+ }
76
71
  }
77
72
 
78
73
  public async getInfo(channel: number): Promise<Response> {
@@ -87,20 +82,14 @@ export class UsbTransmitterClient {
87
82
  lowChannels,
88
83
  ]
89
84
  const release = await mutex.acquire()
90
- await this.sendCommand(data)
91
- return new Promise((resolve, reject) => {
92
- const that = this
93
- this.serialPort.once('readable', function () {
94
- const responseBytes = that.readResponseBytes(RESPONSE_LENGTH_INFO)
95
- if (responseBytes == null) {
96
- release()
97
- return reject('responseBytes are null.')
98
- }
99
- const response = that.parseResponse(responseBytes as Buffer)
100
- release()
101
- return resolve(response)
102
- })
103
- })
85
+ try {
86
+ await this.sendCommand(data)
87
+ const responseBytes = await this.waitForResponse(RESPONSE_LENGTH_INFO)
88
+ const response = this.parseResponse(responseBytes)
89
+ return response
90
+ } finally {
91
+ release()
92
+ }
104
93
  }
105
94
 
106
95
  public async sendControlCommand(
@@ -119,19 +108,38 @@ export class UsbTransmitterClient {
119
108
  controlCommand,
120
109
  ]
121
110
  const release = await mutex.acquire()
122
- await this.sendCommand(data)
111
+ try {
112
+ await this.sendCommand(data)
113
+ const responseBytes = await this.waitForResponse(RESPONSE_LENGTH_INFO)
114
+ const response = this.parseResponse(responseBytes)
115
+ return response
116
+ } finally {
117
+ release()
118
+ }
119
+ }
120
+
121
+ private waitForResponse(length: number): Promise<Buffer> {
123
122
  return new Promise((resolve, reject) => {
124
- const that = this
125
- this.serialPort.once('readable', function () {
126
- const responseBytes = that.readResponseBytes(RESPONSE_LENGTH_INFO)
127
- if (responseBytes == null) {
128
- release()
129
- return reject('responseBytes are null.')
123
+ const timeout = setTimeout(() => {
124
+ cleanup()
125
+ reject(new Error('Timeout waiting for response'))
126
+ }, 2000)
127
+
128
+ const tryRead = () => {
129
+ const buffer = this.serialPort.read(length)
130
+ if (buffer) {
131
+ cleanup()
132
+ resolve(buffer)
130
133
  }
131
- const response = that.parseResponse(responseBytes as Buffer)
132
- release()
133
- return resolve(response)
134
- })
134
+ }
135
+
136
+ const cleanup = () => {
137
+ clearTimeout(timeout)
138
+ this.serialPort.removeListener('readable', tryRead)
139
+ }
140
+
141
+ this.serialPort.on('readable', tryRead)
142
+ tryRead()
135
143
  })
136
144
  }
137
145
 
@@ -142,9 +150,9 @@ export class UsbTransmitterClient {
142
150
  return new Promise((resolve, reject) => {
143
151
  this.serialPort.flush((error) => {
144
152
  if (error) reject(error)
145
- this.serialPort.write(data, (error, bytesWritten: number) => {
153
+ this.serialPort.write(data, (error: Error | null | undefined) => {
146
154
  if (error) reject(error)
147
- resolve(bytesWritten)
155
+ resolve(data.length)
148
156
  })
149
157
  })
150
158
  })
package/src/cli.ts ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander'
3
+ import * as inquirer from 'inquirer'
4
+ import { UsbTransmitterClient } from './UsbTransmitterClient'
5
+ import { ControlCommand } from './domain/enums'
6
+ import { SerialPort } from 'serialport'
7
+
8
+ const program = new Command()
9
+ let client: UsbTransmitterClient | null = null
10
+
11
+ program
12
+ .version('1.0.0')
13
+ .option('-p, --port <path>', 'Path to serial port')
14
+ .parse(process.argv)
15
+
16
+ const options = program.opts()
17
+
18
+ async function main() {
19
+ let portPath = options.port
20
+
21
+ if (!portPath) {
22
+ const ports = await SerialPort.list()
23
+ const portChoices = ports.map((p) => ({ name: `${p.path} ${p.manufacturer || ''}`, value: p.path }))
24
+
25
+ if (portChoices.length === 0) {
26
+ console.error('No serial ports found. Please specify one with --port.')
27
+ process.exit(1)
28
+ }
29
+
30
+ const answer = await inquirer.prompt([
31
+ {
32
+ type: 'list',
33
+ name: 'port',
34
+ message: 'Select Serial Port',
35
+ choices: portChoices,
36
+ },
37
+ ])
38
+ portPath = answer.port
39
+ }
40
+
41
+ client = new UsbTransmitterClient(portPath)
42
+
43
+ try {
44
+ await client.open()
45
+ console.log(`Connected to ${portPath}`)
46
+ await mainMenu()
47
+ } catch (error) {
48
+ console.error('Error connecting to device:', error)
49
+ process.exit(1)
50
+ }
51
+ }
52
+
53
+ async function mainMenu() {
54
+ const answer = await inquirer.prompt([
55
+ {
56
+ type: 'list',
57
+ name: 'action',
58
+ message: 'Main Menu',
59
+ choices: [
60
+ { name: 'Check Channels', value: 'check' },
61
+ { name: 'Select Channel', value: 'select' },
62
+ new inquirer.Separator(),
63
+ { name: 'Exit', value: 'exit' },
64
+ ],
65
+ },
66
+ ])
67
+
68
+ switch (answer.action) {
69
+ case 'check':
70
+ await checkChannels()
71
+ break
72
+ case 'select':
73
+ await selectChannel()
74
+ break
75
+ case 'exit':
76
+ await client!.close()
77
+ process.exit(0)
78
+ }
79
+ }
80
+
81
+ async function checkChannels() {
82
+ console.log('Checking channels...')
83
+ try {
84
+ const channels = await client!.checkChannels()
85
+ console.log('Active Channels:', channels.join(', '))
86
+ } catch (error) {
87
+ console.error('Error checking channels:', error)
88
+ }
89
+ await mainMenu()
90
+ }
91
+
92
+ async function selectChannel() {
93
+ const answer = await inquirer.prompt([
94
+ {
95
+ type: 'input',
96
+ name: 'channel',
97
+ message: 'Enter Channel Number (1-9):',
98
+ validate: (input) => {
99
+ const num = parseInt(input, 10)
100
+ if (isNaN(num) || num < 1 || num > 9) {
101
+ return 'Please enter a number between 1 and 9'
102
+ }
103
+ return true
104
+ },
105
+ },
106
+ ])
107
+
108
+ const channel = parseInt(answer.channel, 10)
109
+ await channelMenu(channel)
110
+ }
111
+
112
+ async function channelMenu(channel: number) {
113
+ const answer = await inquirer.prompt([
114
+ {
115
+ type: 'list',
116
+ name: 'action',
117
+ message: `Channel ${channel} Actions`,
118
+ choices: [
119
+ { name: 'Get Info', value: 'info' },
120
+ { name: 'Move Up', value: 'up' },
121
+ { name: 'Move Down', value: 'down' },
122
+ { name: 'Stop', value: 'stop' },
123
+ new inquirer.Separator(),
124
+ { name: 'Back to Main Menu', value: 'back' },
125
+ ],
126
+ },
127
+ ])
128
+
129
+ if (answer.action === 'back') {
130
+ await mainMenu()
131
+ return
132
+ }
133
+
134
+ try {
135
+ if (answer.action === 'info') {
136
+ const info = await client!.getInfo(channel)
137
+ console.log('Channel Info:', info)
138
+ } else {
139
+ let cmd: ControlCommand
140
+ switch (answer.action) {
141
+ case 'up':
142
+ cmd = ControlCommand.up
143
+ break
144
+ case 'down':
145
+ cmd = ControlCommand.down
146
+ break
147
+ case 'stop':
148
+ cmd = ControlCommand.stop
149
+ break
150
+ default:
151
+ throw new Error('Unknown command')
152
+ }
153
+ console.log(`Sending ${answer.action} command to channel ${channel}...`)
154
+ const response = await client!.sendControlCommand(channel, cmd)
155
+ console.log('Response:', response)
156
+ }
157
+ } catch (error) {
158
+ console.error('Error executing command:', error)
159
+ }
160
+
161
+ await channelMenu(channel)
162
+ }
163
+
164
+ main().catch((err) => {
165
+ console.error('Unexpected error:', err)
166
+ process.exit(1)
167
+ })
@@ -0,0 +1,39 @@
1
+ import { UsbTransmitterClient } from "../src/UsbTransmitterClient"
2
+ import { ControlCommand, EasyCommand, InfoData } from "../src/domain/enums"
3
+ import * as fs from 'fs'
4
+
5
+ jest.setTimeout(200000)
6
+
7
+ const devPath = '/dev/ttyUSB0'
8
+ const client = new UsbTransmitterClient(devPath)
9
+
10
+ const aktiveChannelsForTest = [1, 2]
11
+
12
+ // Skip tests if hardware not present
13
+ const describeHardware = fs.existsSync(devPath) ? describe : describe.skip
14
+
15
+ describeHardware('Integration Tests (Hardware)', () => {
16
+
17
+ test('checkChannels', async () => {
18
+ await client.open()
19
+ const channels = await client.checkChannels()
20
+ expect(channels).toEqual(aktiveChannelsForTest)
21
+ await client.close()
22
+ })
23
+
24
+ test('getInfo', async () => {
25
+ await client.open()
26
+ const response = await client.getInfo(1)
27
+ console.log(response)
28
+ expect(response.command).toEqual(EasyCommand.EASY_ACK)
29
+ await client.close()
30
+ })
31
+
32
+ test('sendControlCommand', async () => {
33
+ await client.open()
34
+ const response = await client.sendControlCommand(1, ControlCommand.down)
35
+ console.log(response)
36
+ expect(response.status).toEqual(InfoData.INFO_MOVING_DOWN)
37
+ await client.close()
38
+ })
39
+ })
@@ -0,0 +1,182 @@
1
+
2
+ import { UsbTransmitterClient } from '../src/UsbTransmitterClient'
3
+ import { ControlCommand, EasyCommand, InfoData } from '../src/domain/enums'
4
+ import { SerialPort } from 'serialport'
5
+ import { BYTE_HEADER } from '../src/domain/constants'
6
+
7
+ // Mock entire serialport module
8
+ jest.mock('serialport')
9
+
10
+ describe('UsbTransmitterClient (Mocked)', () => {
11
+ let client: UsbTransmitterClient
12
+ let mockSerialPortInstance: any
13
+
14
+ beforeEach(() => {
15
+ // Reset mocks
16
+ jest.clearAllMocks()
17
+
18
+ // Setup mock instance
19
+ mockSerialPortInstance = {
20
+ isOpen: false,
21
+ open: jest.fn((cb) => {
22
+ mockSerialPortInstance.isOpen = true
23
+ if (cb) cb(null)
24
+ }),
25
+ close: jest.fn((cb) => {
26
+ mockSerialPortInstance.isOpen = false
27
+ if (cb) cb(null)
28
+ }),
29
+ write: jest.fn((data, cb) => {
30
+ if (cb) cb(null)
31
+ }),
32
+ flush: jest.fn((cb) => {
33
+ if (cb) cb(null)
34
+ }),
35
+ once: jest.fn(),
36
+ read: jest.fn(),
37
+ pipe: jest.fn(),
38
+ on: jest.fn(),
39
+ removeListener: jest.fn()
40
+ }
41
+
42
+ // When new SerialPort() is called, return our mock instance
43
+ ; (SerialPort as unknown as jest.Mock).mockImplementation(() => mockSerialPortInstance)
44
+
45
+ client = new UsbTransmitterClient('/dev/ttyMOCKED')
46
+ })
47
+
48
+ test('open() should open the serial port', async () => {
49
+ await client.open()
50
+ expect(mockSerialPortInstance.open).toHaveBeenCalled()
51
+ expect(mockSerialPortInstance.flush).toHaveBeenCalled()
52
+ })
53
+
54
+ test('close() should close the serial port', async () => {
55
+ await client.close()
56
+ expect(mockSerialPortInstance.close).toHaveBeenCalled()
57
+ })
58
+
59
+ test('checkChannels() calls correct command and parses response', async () => {
60
+ await client.open()
61
+
62
+ // Simulate "readable" event and data read
63
+ mockSerialPortInstance.once.mockImplementation((event: string, cb: Function) => {
64
+ if (event === 'readable') {
65
+ // Trigger the callback immediately to simulate data ready
66
+ cb()
67
+ }
68
+ })
69
+
70
+ // Mock response for check channels (Head, Len, Cmd, Byte3...CS)
71
+ // Response length check is 6 bytes.
72
+ // Byte 3 is bitmap of active channels (1-8). let's say ch 1 and 2 are active (binary 00000011 = 3)
73
+ const responseBuffer = Buffer.from([
74
+ BYTE_HEADER, // 0xAA
75
+ 0x04, // Length
76
+ EasyCommand.EASY_CHECK,
77
+ 0x00, // High channels (starts at 9)
78
+ 0x03, // Low channels (starts at 1, so 1 & 2)
79
+ 0x00 // Checksum (ignored for now in mock, or we calculate it if logic is strict)
80
+ ])
81
+ // Fix checksum if logic requires it: 256 - sum
82
+ const sum = responseBuffer[0] + responseBuffer[1] + responseBuffer[2] + responseBuffer[3] + responseBuffer[4]
83
+ responseBuffer[5] = (256 - (sum % 256)) % 256
84
+
85
+ mockSerialPortInstance.read.mockReturnValue(responseBuffer)
86
+
87
+ const channels = await client.checkChannels()
88
+ expect(channels).toEqual(expect.arrayContaining([1, 2]))
89
+ expect(mockSerialPortInstance.write).toHaveBeenCalledWith(
90
+ expect.arrayContaining([BYTE_HEADER, 0x02, EasyCommand.EASY_CHECK]),
91
+ expect.any(Function)
92
+ )
93
+ })
94
+
95
+ test('sendControlCommand() sends correct bytes', async () => {
96
+ await client.open()
97
+
98
+ mockSerialPortInstance.once.mockImplementation((event: string, cb: Function) => {
99
+ if (event === 'readable') cb()
100
+ })
101
+
102
+ // Response for Info (length 7)
103
+ // 0: AA, 1: 05, 2: EASY_SEND, 3: high, 4: low, 5: status, 6: CS
104
+ const responseBuffer = Buffer.from([
105
+ BYTE_HEADER,
106
+ 0x05,
107
+ EasyCommand.EASY_SEND,
108
+ 0x00,
109
+ 0x01, // Channel 1 bit mask
110
+ InfoData.INFO_MOVING_DOWN,
111
+ 0x00
112
+ ])
113
+ const sum = responseBuffer.slice(0, 6).reduce((a, b) => a + b, 0)
114
+ responseBuffer[6] = (256 - (sum % 256)) % 256
115
+
116
+ mockSerialPortInstance.read.mockReturnValue(responseBuffer)
117
+
118
+ const response = await client.sendControlCommand(1, ControlCommand.down)
119
+
120
+ expect(response.status).toBe(InfoData.INFO_MOVING_DOWN)
121
+
122
+ // Verify write arguments
123
+ // Data: [AA, 05, EASY_SEND, high, low, cmd, CS]
124
+ // Channel 1 -> low=1, high=0
125
+ expect(mockSerialPortInstance.write).toHaveBeenCalledWith(
126
+ expect.arrayContaining([
127
+ BYTE_HEADER,
128
+ 0x05,
129
+ EasyCommand.EASY_SEND,
130
+ 0,
131
+ 1,
132
+ ControlCommand.down
133
+ ]),
134
+ expect.any(Function)
135
+ )
136
+ })
137
+ test('getInfo() should handle fragmented packets (reproduction fix)', async () => {
138
+ await client.open()
139
+
140
+ let readableCallback: Function | null = null;
141
+ mockSerialPortInstance.on.mockImplementation((event: string, cb: Function) => {
142
+ if (event === 'readable') {
143
+ readableCallback = cb
144
+ }
145
+ })
146
+
147
+ let readCallCount = 0
148
+ mockSerialPortInstance.read.mockImplementation((len: number) => {
149
+ readCallCount++
150
+ if (readCallCount === 1) {
151
+ return null // Not enough data yet
152
+ }
153
+ // Return dummy response buffer for getInfo call
154
+ const responseBuffer = Buffer.from([
155
+ BYTE_HEADER,
156
+ 0x05,
157
+ EasyCommand.EASY_SEND,
158
+ 0x00,
159
+ 0x01,
160
+ InfoData.INFO_MOVING_DOWN,
161
+ 0x00
162
+ ])
163
+ const sum = responseBuffer.slice(0, 6).reduce((a, b) => a + b, 0)
164
+ responseBuffer[6] = (256 - (sum % 256)) % 256
165
+ return responseBuffer
166
+ })
167
+
168
+ const infoPromise = client.getInfo(1)
169
+
170
+ // Wait a tick to ensure tryRead() ran once and failed
171
+ await new Promise(r => process.nextTick(r))
172
+
173
+ // Now trigger readable event again (simulation of second packet arriving)
174
+ if (readableCallback) {
175
+ (readableCallback as Function)()
176
+ }
177
+
178
+ const response = await infoPromise
179
+ expect(response.status).toBe(InfoData.INFO_MOVING_DOWN)
180
+ expect(readCallCount).toBeGreaterThanOrEqual(2)
181
+ })
182
+ })
@@ -1,31 +0,0 @@
1
- import { UsbTransmitterClient } from "../src/UsbTransmitterClient"
2
- import { ControlCommand, EasyCommand, InfoData } from "../src/domain/enums"
3
-
4
- jest.setTimeout(200000)
5
-
6
- const client = new UsbTransmitterClient('/dev/ttyUSB0')
7
-
8
- const aktiveChannelsForTest = [1, 2]
9
-
10
- test('checkChannels', async () => {
11
- await client.open()
12
- const channels = await client.checkChannels()
13
- expect(channels).toEqual(aktiveChannelsForTest)
14
- await client.close()
15
- })
16
-
17
- test('getInfo', async () => {
18
- await client.open()
19
- const response = await client.getInfo(1)
20
- console.log(response)
21
- expect(response.command).toEqual(EasyCommand.EASY_ACK)
22
- await client.close()
23
- })
24
-
25
- test('sendControlCommand', async () => {
26
- await client.open()
27
- const response = await client.sendControlCommand(1, ControlCommand.down)
28
- console.log(response)
29
- expect(response.status).toEqual(InfoData.INFO_MOVING_DOWN)
30
- await client.close()
31
- })
@@ -1,21 +0,0 @@
1
- import { Device } from './model/Device';
2
- import { Group } from './model/Group';
3
- import { Parameters } from './model/Parameters';
4
- export declare class ComfortCloudClient {
5
- readonly baseUrl = "https://accsmart.panasonic.com";
6
- readonly urlPartLogin = "/auth/login/";
7
- readonly urlPartGroup = "/device/group/";
8
- readonly urlPartDevice = "/deviceStatus/";
9
- readonly urlPartDeviceControl = "/deviceStatus/control";
10
- readonly appVersion = "2.0.0";
11
- private axiosInstance;
12
- private _token;
13
- set token(value: string);
14
- constructor();
15
- login(username: string, password: string, language?: number): Promise<string>;
16
- getGroups(): Promise<Array<Group>>;
17
- getDevice(id: string): Promise<Device | null>;
18
- private handleError;
19
- setDevice(device: Device): Promise<any>;
20
- setParameters(guid: string, parameters: Parameters): Promise<import("axios").AxiosResponse<any> | null>;
21
- }