@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.
@@ -0,0 +1,9 @@
1
+ [
2
+ {
3
+ "id": "DEVICE_ID",
4
+ "name": "Living Room Mini-Split",
5
+ "key": "LOCAL_KEY",
6
+ "ip": "192.168.1.100",
7
+ "version": "3.4"
8
+ }
9
+ ]
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
+ }