Initial commit: tischlerctrl home automation project

This commit is contained in:
sebseb7
2025-12-22 23:32:55 +01:00
commit f3cca149f9
31 changed files with 3243 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
/**
* AC Infinity API Client
* Ported from TypeScript homebridge-acinfinity plugin
*/
const API_URL_LOGIN = '/api/user/appUserLogin';
const API_URL_GET_DEVICE_INFO_LIST_ALL = '/api/user/devInfoListAll';
export class ACInfinityClientError extends Error {
constructor(message) {
super(message);
this.name = 'ACInfinityClientError';
}
}
export class ACInfinityClientCannotConnect extends ACInfinityClientError {
constructor() {
super('Cannot connect to AC Infinity API');
}
}
export class ACInfinityClientInvalidAuth extends ACInfinityClientError {
constructor() {
super('Invalid authentication credentials');
}
}
export class ACInfinityClient {
constructor(host, email, password) {
this.host = host;
this.email = email;
this.password = password;
this.userId = null;
}
async login() {
try {
// AC Infinity API does not accept passwords greater than 25 characters
const normalizedPassword = this.password.substring(0, 25);
const response = await fetch(`${this.host}${API_URL_LOGIN}`, {
method: 'POST',
headers: {
'User-Agent': 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2',
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
},
body: new URLSearchParams({
appEmail: this.email,
appPasswordl: normalizedPassword, // Note: intentional typo in API
}),
});
const data = await response.json();
if (data.code !== 200) {
if (data.code === 10001) {
throw new ACInfinityClientInvalidAuth();
}
throw new ACInfinityClientError(`Login failed: ${JSON.stringify(data)}`);
}
this.userId = data.data.appId;
console.log('[AC] Successfully logged in to AC Infinity API');
return this.userId;
} catch (error) {
if (error instanceof ACInfinityClientError) {
throw error;
}
throw new ACInfinityClientCannotConnect();
}
}
isLoggedIn() {
return this.userId !== null;
}
getAuthHeaders() {
if (!this.userId) {
throw new ACInfinityClientError('Client is not logged in');
}
return {
token: this.userId,
phoneType: '1',
appVersion: '1.9.7',
};
}
async getDevicesListAll() {
if (!this.isLoggedIn()) {
throw new ACInfinityClientError('AC Infinity client is not logged in');
}
try {
const response = await fetch(`${this.host}${API_URL_GET_DEVICE_INFO_LIST_ALL}`, {
method: 'POST',
headers: {
'User-Agent': 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2',
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
...this.getAuthHeaders(),
},
body: new URLSearchParams({
userId: this.userId,
}),
});
const data = await response.json();
if (data.code !== 200) {
throw new ACInfinityClientError(`Request failed: ${JSON.stringify(data)}`);
}
return data.data || [];
} catch (error) {
if (error instanceof ACInfinityClientError) {
throw error;
}
throw new ACInfinityClientCannotConnect();
}
}
/**
* Extract sensor readings from device list
* @returns {Array} Array of {device, channel, value} objects
*/
async getSensorReadings() {
const devices = await this.getDevicesListAll();
const readings = [];
for (const device of devices) {
const devId = device.devId;
const devName = device.devName || `device-${devId}`;
// Normalize device name for use as identifier
const deviceId = devName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
// Extract sensor data from device settings or sensor fields
// Temperature is stored as Celsius * 100
if (device.devSettings?.temperature !== undefined) {
readings.push({
device: deviceId,
channel: 'temperature',
value: device.devSettings.temperature / 100,
});
} else if (device.temperature !== undefined) {
readings.push({
device: deviceId,
channel: 'temperature',
value: device.temperature / 100,
});
}
// Humidity is stored as % * 100
if (device.devSettings?.humidity !== undefined) {
readings.push({
device: deviceId,
channel: 'humidity',
value: device.devSettings.humidity / 100,
});
} else if (device.humidity !== undefined) {
readings.push({
device: deviceId,
channel: 'humidity',
value: device.humidity / 100,
});
}
// VPD if available
if (device.devSettings?.vpdnums !== undefined) {
readings.push({
device: deviceId,
channel: 'vpd',
value: device.devSettings.vpdnums / 100,
});
}
// Check for port-level sensors (some controllers have multiple ports)
if (device.devPortList && Array.isArray(device.devPortList)) {
for (const port of device.devPortList) {
const portId = port.portId || port.port;
const portDeviceId = `${deviceId}-port${portId}`;
if (port.temperature !== undefined) {
readings.push({
device: portDeviceId,
channel: 'temperature',
value: port.temperature / 100,
});
}
if (port.humidity !== undefined) {
readings.push({
device: portDeviceId,
channel: 'humidity',
value: port.humidity / 100,
});
}
}
}
}
return readings;
}
}
export default ACInfinityClient;

View File

@@ -0,0 +1,25 @@
import { config } from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load environment variables
config({ path: join(__dirname, '..', '.env') });
export default {
// WebSocket server connection
serverUrl: process.env.SERVER_URL || 'ws://localhost:8080',
apiKey: process.env.API_KEY || '',
// AC Infinity credentials
acEmail: process.env.AC_EMAIL || '',
acPassword: process.env.AC_PASSWORD || '',
// Polling interval (default: 60 seconds)
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || '60000', 10),
// AC Infinity API
acApiHost: process.env.AC_API_HOST || 'https://www.acinfinity.com',
};

View File

@@ -0,0 +1,89 @@
import config from './config.js';
import ACInfinityClient from './ac-client.js';
import WSClient from './ws-client.js';
console.log('='.repeat(50));
console.log('AC Infinity Agent');
console.log('='.repeat(50));
// Validate configuration
if (!config.apiKey) {
console.error('Error: API_KEY is required');
process.exit(1);
}
if (!config.acEmail || !config.acPassword) {
console.error('Error: AC_EMAIL and AC_PASSWORD are required');
process.exit(1);
}
// Initialize clients
const acClient = new ACInfinityClient(
config.acApiHost,
config.acEmail,
config.acPassword
);
const wsClient = new WSClient(config.serverUrl, config.apiKey);
// Polling function
async function pollSensors() {
try {
const readings = await acClient.getSensorReadings();
if (readings.length > 0) {
console.log(`[Poll] Sending ${readings.length} readings`);
wsClient.sendReadings(readings);
} else {
console.log('[Poll] No readings available');
}
} catch (err) {
console.error('[Poll] Error:', err.message);
// Re-login if authentication failed
if (err.message.includes('not logged in')) {
console.log('[Poll] Attempting re-login...');
try {
await acClient.login();
} catch (loginErr) {
console.error('[Poll] Re-login failed:', loginErr.message);
}
}
}
}
// Main function
async function main() {
try {
// Login to AC Infinity
await acClient.login();
// Connect to WebSocket server
await wsClient.connect();
// Start polling
console.log(`[Main] Starting polling every ${config.pollIntervalMs / 1000}s`);
// Poll immediately
await pollSensors();
// Then poll at interval
setInterval(pollSensors, config.pollIntervalMs);
} catch (err) {
console.error('[Main] Fatal error:', err.message);
process.exit(1);
}
}
// Graceful shutdown
function shutdown() {
console.log('\n[Agent] Shutting down...');
wsClient.close();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Start
main();

View File

@@ -0,0 +1,194 @@
import WebSocket from 'ws';
/**
* WebSocket client with auto-reconnect and authentication
*/
export class WSClient {
constructor(url, apiKey, options = {}) {
this.url = url;
this.apiKey = apiKey;
this.options = {
reconnectBaseMs: options.reconnectBaseMs || 1000,
reconnectMaxMs: options.reconnectMaxMs || 60000,
pingIntervalMs: options.pingIntervalMs || 30000,
...options,
};
this.ws = null;
this.authenticated = false;
this.devicePrefix = null;
this.reconnectAttempts = 0;
this.reconnectTimer = null;
this.pingTimer = null;
this.messageQueue = [];
this.onReadyCallback = null;
}
/**
* Connect to the WebSocket server
* @returns {Promise} Resolves when authenticated
*/
connect() {
return new Promise((resolve, reject) => {
this.onReadyCallback = resolve;
this._connect();
});
}
_connect() {
console.log(`[WS] Connecting to ${this.url}...`);
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
console.log('[WS] Connected, authenticating...');
this.reconnectAttempts = 0;
// Send authentication
this._send({ type: 'auth', apiKey: this.apiKey });
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this._handleMessage(message);
} catch (err) {
console.error('[WS] Error parsing message:', err.message);
}
});
this.ws.on('ping', () => {
this.ws.pong();
});
this.ws.on('close', (code, reason) => {
console.log(`[WS] Connection closed: ${code} ${reason}`);
this._cleanup();
this._scheduleReconnect();
});
this.ws.on('error', (err) => {
console.error('[WS] Error:', err.message);
});
}
_handleMessage(message) {
switch (message.type) {
case 'auth':
if (message.success) {
console.log(`[WS] Authenticated as ${message.name}`);
this.authenticated = true;
this.devicePrefix = message.devicePrefix;
// Start ping timer
this._startPingTimer();
// Flush queued messages
this._flushQueue();
// Resolve connect promise
if (this.onReadyCallback) {
this.onReadyCallback();
this.onReadyCallback = null;
}
} else {
console.error('[WS] Authentication failed:', message.error);
}
break;
case 'ack':
// Data acknowledged
break;
case 'error':
console.error('[WS] Server error:', message.error);
break;
default:
console.log('[WS] Unknown message type:', message.type);
}
}
_startPingTimer() {
this._stopPingTimer();
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this._send({ type: 'pong' });
}
}, this.options.pingIntervalMs);
}
_stopPingTimer() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
_cleanup() {
this._stopPingTimer();
this.authenticated = false;
}
_scheduleReconnect() {
if (this.reconnectTimer) return;
const delay = Math.min(
this.options.reconnectBaseMs * Math.pow(2, this.reconnectAttempts),
this.options.reconnectMaxMs
);
console.log(`[WS] Reconnecting in ${delay}ms...`);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._connect();
}, delay);
}
_send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
_flushQueue() {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
this._send(message);
}
}
/**
* Send sensor readings to the server
* @param {Array} readings - Array of {device, channel, value} objects
*/
sendReadings(readings) {
const message = { type: 'data', readings };
if (this.authenticated) {
this._send(message);
} else {
// Queue for later
this.messageQueue.push(message);
}
}
/**
* Close the connection
*/
close() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this._cleanup();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
export default WSClient;