@caseman72/tuya-api 1.0.0
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.
- package/.tuya-devices.example.json +9 -0
- package/README.md +83 -0
- package/index.js +116 -0
- package/lib/climate.js +94 -0
- package/lib/connection.js +56 -0
- package/lib/dps.js +43 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @caseman72/tuya-api
|
|
2
|
+
|
|
3
|
+
Simple Tuya API client for local LAN control of smart climate devices (mini-splits, AC units). No cloud dependency — communicates directly with devices over your local network using the Tuya protocol.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @caseman72/tuya-api
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Create a `.tuya-devices.json` file with your device configurations:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cp .tuya-devices.example.json .tuya-devices.json
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Each device needs:
|
|
20
|
+
- **id** — Tuya Device ID (from Tuya IoT Platform)
|
|
21
|
+
- **name** — Friendly name
|
|
22
|
+
- **key** — Local encryption key (from Tuya IoT Platform device details)
|
|
23
|
+
- **ip** — Local LAN IP (optional, auto-discovered if omitted)
|
|
24
|
+
- **version** — Protocol version (`"3.4"` for most modern devices)
|
|
25
|
+
|
|
26
|
+
The file is searched in:
|
|
27
|
+
1. Current working directory
|
|
28
|
+
2. `~/.config/tuya-api/.tuya-devices.json`
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
import Tuya from '@caseman72/tuya-api';
|
|
34
|
+
|
|
35
|
+
const tuya = new Tuya();
|
|
36
|
+
|
|
37
|
+
// Get all device statuses
|
|
38
|
+
const statuses = await tuya.getAllStatuses();
|
|
39
|
+
|
|
40
|
+
// Control by name or ID
|
|
41
|
+
const status = await tuya.getStatus('Living Room');
|
|
42
|
+
await tuya.setPower('Living Room', true);
|
|
43
|
+
await tuya.setTemperature('Living Room', 72);
|
|
44
|
+
await tuya.setMode('Living Room', 'cool'); // 'heat' or 'cool'
|
|
45
|
+
await tuya.setFanSpeed('Living Room', 'auto'); // 'low', 'medium', 'high', 'auto'
|
|
46
|
+
|
|
47
|
+
// Raw DPS access
|
|
48
|
+
await tuya.setDps('Living Room', '4', 'cold');
|
|
49
|
+
|
|
50
|
+
// Scan device to discover DPS mapping
|
|
51
|
+
const scan = await tuya.scan('Living Room');
|
|
52
|
+
|
|
53
|
+
// Clean up
|
|
54
|
+
await tuya.disconnectAll();
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Constructor Options
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
const tuya = new Tuya({
|
|
61
|
+
devicesPath: '.', // Path to search for .tuya-devices.json
|
|
62
|
+
devices: [...] // Or pass device configs directly
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## DPS Mapping
|
|
67
|
+
|
|
68
|
+
Tuya climate devices use numbered data points (DPS). The defaults work for most mini-splits:
|
|
69
|
+
|
|
70
|
+
| DPS | Function | Values |
|
|
71
|
+
|-----|----------|--------|
|
|
72
|
+
| 1 | Power | `true`/`false` |
|
|
73
|
+
| 24 | Target Temp °F | Integer × 10 (e.g., 690 = 69.0°F) |
|
|
74
|
+
| 23 | Current Temp °F | Integer |
|
|
75
|
+
| 4 | Mode | `"cold"` / `"hot"` |
|
|
76
|
+
| 5 | Fan Speed | `"low"` / `"mid"` / `"high"` / `"auto"` |
|
|
77
|
+
| 19 | Temp Unit | `"f"` / `"c"` |
|
|
78
|
+
|
|
79
|
+
Override per-device in `.tuya-devices.json` with a `dps` object and `temp_scale` value.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { getStatus, setPower, setTemperature, setMode, setFanSpeed, setDps, scan } from './lib/climate.js';
|
|
5
|
+
import { disconnect, disconnectAll } from './lib/connection.js';
|
|
6
|
+
|
|
7
|
+
function loadDevicesFile(devicesPath) {
|
|
8
|
+
const paths = [
|
|
9
|
+
join(devicesPath, '.tuya-devices.json'),
|
|
10
|
+
join(homedir(), '.config', 'tuya-api', '.tuya-devices.json'),
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const filePath = paths.find(p => existsSync(p));
|
|
14
|
+
if (!filePath) return [];
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error('[tuya-api] Error loading devices file:', err.message);
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class Tuya {
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.devicesPath = options.devicesPath || '.';
|
|
27
|
+
|
|
28
|
+
if (options.devices && options.devices.length > 0) {
|
|
29
|
+
this._devices = options.devices;
|
|
30
|
+
} else {
|
|
31
|
+
this._devices = loadDevicesFile(this.devicesPath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Device lookup
|
|
36
|
+
getDevices() {
|
|
37
|
+
return this._devices;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getDevice(idOrName) {
|
|
41
|
+
const search = (idOrName || '').toLowerCase().trim();
|
|
42
|
+
return this._devices.find(d =>
|
|
43
|
+
d.id === idOrName || (d.name || '').toLowerCase().trim() === search
|
|
44
|
+
) || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Climate control
|
|
48
|
+
async getStatus(idOrName) {
|
|
49
|
+
const device = this.getDevice(idOrName);
|
|
50
|
+
if (!device) throw new Error(`Device not found: ${idOrName}`);
|
|
51
|
+
return getStatus(device);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async setPower(idOrName, on) {
|
|
55
|
+
const device = this.getDevice(idOrName);
|
|
56
|
+
if (!device) throw new Error(`Device not found: ${idOrName}`);
|
|
57
|
+
return setPower(device, on);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async setTemperature(idOrName, temp) {
|
|
61
|
+
const device = this.getDevice(idOrName);
|
|
62
|
+
if (!device) throw new Error(`Device not found: ${idOrName}`);
|
|
63
|
+
return setTemperature(device, temp);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async setMode(idOrName, mode) {
|
|
67
|
+
const device = this.getDevice(idOrName);
|
|
68
|
+
if (!device) throw new Error(`Device not found: ${idOrName}`);
|
|
69
|
+
return setMode(device, mode);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async setFanSpeed(idOrName, speed) {
|
|
73
|
+
const device = this.getDevice(idOrName);
|
|
74
|
+
if (!device) throw new Error(`Device not found: ${idOrName}`);
|
|
75
|
+
return setFanSpeed(device, speed);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async setDps(idOrName, dpsIndex, value) {
|
|
79
|
+
const device = this.getDevice(idOrName);
|
|
80
|
+
if (!device) throw new Error(`Device not found: ${idOrName}`);
|
|
81
|
+
return setDps(device, dpsIndex, value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async scan(idOrName) {
|
|
85
|
+
const device = this.getDevice(idOrName);
|
|
86
|
+
if (!device) throw new Error(`Device not found: ${idOrName}`);
|
|
87
|
+
return scan(device);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Get all device statuses
|
|
91
|
+
async getAllStatuses() {
|
|
92
|
+
const results = [];
|
|
93
|
+
for (const device of this._devices) {
|
|
94
|
+
try {
|
|
95
|
+
const status = await getStatus(device);
|
|
96
|
+
results.push(status);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
results.push({ id: device.id, name: device.name, error: err.message });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Connection management
|
|
105
|
+
async disconnect(idOrName) {
|
|
106
|
+
const device = this.getDevice(idOrName);
|
|
107
|
+
if (device) await disconnect(device.id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async disconnectAll() {
|
|
111
|
+
return disconnectAll();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default Tuya;
|
|
116
|
+
export { Tuya };
|
package/lib/climate.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getConnection } from './connection.js';
|
|
2
|
+
import { DEFAULT_DPS, DEFAULT_TEMP_SCALE, MODE_MAP, MODE_REVERSE, FAN_MAP, FAN_REVERSE } from './dps.js';
|
|
3
|
+
|
|
4
|
+
function getDps(device) {
|
|
5
|
+
return { ...DEFAULT_DPS, ...(device.dps || {}) };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getScale(device) {
|
|
9
|
+
return device.temp_scale || DEFAULT_TEMP_SCALE;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function getStatus(device) {
|
|
13
|
+
const conn = await getConnection(device);
|
|
14
|
+
const dps = getDps(device);
|
|
15
|
+
const scale = getScale(device);
|
|
16
|
+
|
|
17
|
+
const data = await conn.get({ schema: true });
|
|
18
|
+
const values = data.dps || data;
|
|
19
|
+
|
|
20
|
+
const tempUnit = values[dps.temp_unit] || 'f';
|
|
21
|
+
const useFahrenheit = tempUnit === 'f' || tempUnit === 'F';
|
|
22
|
+
|
|
23
|
+
const rawTarget = useFahrenheit ? values[dps.target_temp] : values[dps.target_temp_c];
|
|
24
|
+
const rawCurrent = useFahrenheit ? values[dps.current_temp] : values[dps.current_temp_c];
|
|
25
|
+
|
|
26
|
+
const rawMode = values[dps.mode];
|
|
27
|
+
const rawFan = values[dps.fan_speed];
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
id: device.id,
|
|
31
|
+
name: device.name,
|
|
32
|
+
is_on: values[dps.power] === true,
|
|
33
|
+
target_temperature: rawTarget != null ? rawTarget / scale : null,
|
|
34
|
+
current_temperature: rawCurrent != null ? rawCurrent : null,
|
|
35
|
+
temp_unit: useFahrenheit ? 'F' : 'C',
|
|
36
|
+
mode: MODE_MAP[rawMode] || rawMode || null,
|
|
37
|
+
fan_speed: FAN_MAP[rawFan] || rawFan || null,
|
|
38
|
+
raw_dps: values
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function setPower(device, on) {
|
|
43
|
+
const conn = await getConnection(device);
|
|
44
|
+
const dps = getDps(device);
|
|
45
|
+
|
|
46
|
+
await conn.set({ dps: parseInt(dps.power), set: on });
|
|
47
|
+
return { success: true, device: device.name, power: on ? 'on' : 'off' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function setTemperature(device, temp) {
|
|
51
|
+
const conn = await getConnection(device);
|
|
52
|
+
const dps = getDps(device);
|
|
53
|
+
const scale = getScale(device);
|
|
54
|
+
|
|
55
|
+
const scaledTemp = Math.round(temp * scale);
|
|
56
|
+
await conn.set({ dps: parseInt(dps.target_temp), set: scaledTemp });
|
|
57
|
+
return { success: true, device: device.name, target_temperature: temp };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function setMode(device, mode) {
|
|
61
|
+
const conn = await getConnection(device);
|
|
62
|
+
const dps = getDps(device);
|
|
63
|
+
|
|
64
|
+
const tuyaMode = MODE_REVERSE[mode] || mode;
|
|
65
|
+
await conn.set({ dps: parseInt(dps.mode), set: tuyaMode });
|
|
66
|
+
return { success: true, device: device.name, mode };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function setFanSpeed(device, speed) {
|
|
70
|
+
const conn = await getConnection(device);
|
|
71
|
+
const dps = getDps(device);
|
|
72
|
+
|
|
73
|
+
const tuyaSpeed = FAN_REVERSE[speed] || speed;
|
|
74
|
+
await conn.set({ dps: parseInt(dps.fan_speed), set: tuyaSpeed });
|
|
75
|
+
return { success: true, device: device.name, fan_speed: speed };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function setDps(device, dpsIndex, value) {
|
|
79
|
+
const conn = await getConnection(device);
|
|
80
|
+
await conn.set({ dps: parseInt(dpsIndex), set: value });
|
|
81
|
+
return { success: true, device: device.name, dps: dpsIndex, value };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function scan(device) {
|
|
85
|
+
const conn = await getConnection(device);
|
|
86
|
+
|
|
87
|
+
const data = await conn.get({ schema: true });
|
|
88
|
+
let refreshData = null;
|
|
89
|
+
try {
|
|
90
|
+
refreshData = await conn.refresh({ schema: true });
|
|
91
|
+
} catch (_) {}
|
|
92
|
+
|
|
93
|
+
return { id: device.id, name: device.name, status: data, refresh: refreshData };
|
|
94
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import TuyAPI from 'tuyapi';
|
|
2
|
+
|
|
3
|
+
// Active connections keyed by device ID
|
|
4
|
+
const connections = new Map();
|
|
5
|
+
|
|
6
|
+
export async function getConnection(device) {
|
|
7
|
+
const existing = connections.get(device.id);
|
|
8
|
+
if (existing && existing.isConnected()) {
|
|
9
|
+
return existing;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Clean up stale connection
|
|
13
|
+
if (existing) {
|
|
14
|
+
try { existing.disconnect(); } catch (_) {}
|
|
15
|
+
connections.delete(device.id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const tuyaDevice = new TuyAPI({
|
|
19
|
+
id: device.id,
|
|
20
|
+
key: device.key,
|
|
21
|
+
ip: device.ip || undefined,
|
|
22
|
+
version: device.version || '3.4',
|
|
23
|
+
issueGetOnConnect: false,
|
|
24
|
+
issueRefreshOnConnect: false,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
tuyaDevice.on('error', (err) => {
|
|
28
|
+
console.error(`[tuya-api] ${device.name || device.id} error:`, err.message);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
tuyaDevice.on('disconnected', () => {
|
|
32
|
+
connections.delete(device.id);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!device.ip) {
|
|
36
|
+
await tuyaDevice.find({ timeout: 10 });
|
|
37
|
+
}
|
|
38
|
+
await tuyaDevice.connect();
|
|
39
|
+
connections.set(device.id, tuyaDevice);
|
|
40
|
+
return tuyaDevice;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function disconnect(deviceId) {
|
|
44
|
+
const conn = connections.get(deviceId);
|
|
45
|
+
if (conn) {
|
|
46
|
+
try { conn.disconnect(); } catch (_) {}
|
|
47
|
+
connections.delete(deviceId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function disconnectAll() {
|
|
52
|
+
for (const [id, device] of connections) {
|
|
53
|
+
try { device.disconnect(); } catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
connections.clear();
|
|
56
|
+
}
|
package/lib/dps.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Default DPS mapping for Tuya AC/climate devices (category: kt)
|
|
2
|
+
export const DEFAULT_DPS = {
|
|
3
|
+
power: '1',
|
|
4
|
+
target_temp: '24',
|
|
5
|
+
current_temp: '23',
|
|
6
|
+
mode: '4',
|
|
7
|
+
fan_speed: '5',
|
|
8
|
+
temp_unit: '19',
|
|
9
|
+
target_temp_c: '2',
|
|
10
|
+
current_temp_c: '3'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Default temperature scale (DPS 24 uses ×10, e.g. 690 = 69.0°F)
|
|
14
|
+
export const DEFAULT_TEMP_SCALE = 10;
|
|
15
|
+
|
|
16
|
+
// Tuya mode strings → human-readable
|
|
17
|
+
export const MODE_MAP = {
|
|
18
|
+
cold: 'cool',
|
|
19
|
+
hot: 'heat',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const MODE_REVERSE = {
|
|
23
|
+
cool: 'cold',
|
|
24
|
+
heat: 'hot',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Fan speed mapping
|
|
28
|
+
export const FAN_MAP = {
|
|
29
|
+
'1': 'low',
|
|
30
|
+
'2': 'medium',
|
|
31
|
+
'3': 'high',
|
|
32
|
+
auto: 'auto',
|
|
33
|
+
low: 'low',
|
|
34
|
+
mid: 'medium',
|
|
35
|
+
high: 'high',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const FAN_REVERSE = {
|
|
39
|
+
low: 'low',
|
|
40
|
+
medium: 'mid',
|
|
41
|
+
high: 'high',
|
|
42
|
+
auto: 'auto',
|
|
43
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@caseman72/tuya-api",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Simple Tuya API client for local LAN control of smart climate devices",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"lib/*.js",
|
|
10
|
+
"README.md",
|
|
11
|
+
".tuya-devices.example.json"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"scan": "node lib/scan.js",
|
|
15
|
+
"lint": "eslint .",
|
|
16
|
+
"lint:fix": "eslint . --fix"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"tuya",
|
|
20
|
+
"api",
|
|
21
|
+
"smart-home",
|
|
22
|
+
"iot",
|
|
23
|
+
"climate",
|
|
24
|
+
"mini-split",
|
|
25
|
+
"home-automation",
|
|
26
|
+
"turbro"
|
|
27
|
+
],
|
|
28
|
+
"author": "Casey Manion <casey@manion.com>",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/caseman72/tuya-api.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/caseman72/tuya-api/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/caseman72/tuya-api#readme",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"tuyapi": "^7.7.1"
|
|
40
|
+
}
|
|
41
|
+
}
|