homebridge-petlibro
Version:
Homebridge plugin for PetLibro smart feeders. Control your pet feeder through Apple HomeKit.
402 lines (336 loc) • 15.1 kB
JavaScript
// Unofficial plugin, not affiliated with PetLibro
// Use at your own risk
// Check PetLibro's ToS before use
const axios = require('axios');
const crypto = require('crypto');
let Service, Characteristic;
module.exports = function(homebridge) {
Service = homebridge.hap.Service;
Characteristic = homebridge.hap.Characteristic;
homebridge.registerPlatform("homebridge-petlibro", "PetLibroPlatform", PetLibroPlatform);
};
class PetLibroPlatform {
constructor(log, config, api) {
this.log = log;
this.config = config;
this.api = api;
this.accessories = [];
this.api.on('didFinishLaunching', () => {
this.discoverDevices();
});
}
configureAccessory(accessory) {
this.log.info('Loading accessory from cache:', accessory.displayName);
this.accessories.push(accessory);
}
discoverDevices() {
try {
// Create a single feeder accessory
const uuid = this.api.hap.uuid.generate('petlibro-feeder-' + (this.config.name || 'default'));
const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);
if (existingAccessory) {
this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);
new PetLibroFeeder(this, existingAccessory);
} else {
this.log.info('Adding new accessory:', this.config.name || 'Pet Feeder');
const accessory = new this.api.platformAccessory(this.config.name || 'Pet Feeder', uuid);
new PetLibroFeeder(this, accessory);
this.api.registerPlatformAccessories("homebridge-petlibro", "PetLibroPlatform", [accessory]);
}
} catch (error) {
this.log.error('Failed to discover/create devices:', error.message);
// Don't throw - let Homebridge continue with other plugins
}
}
}
class PetLibroFeeder {
constructor(platform, accessory) {
this.platform = platform;
this.accessory = accessory;
this.log = platform.log;
this.config = platform.config;
this.name = this.config.name || 'Pet Feeder';
// PetLibro API configuration
this.email = this.config.email;
this.password = this.config.password;
this.deviceId = this.config.deviceId;
// Use the correct API endpoint
this.baseUrl = this.config.apiEndpoint || 'https://api.us.petlibro.com';
// Authentication tokens
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
// Set accessory information
this.accessory.getService(this.platform.api.hap.Service.AccessoryInformation)
.setCharacteristic(this.platform.api.hap.Characteristic.Manufacturer, 'PetLibro')
.setCharacteristic(this.platform.api.hap.Characteristic.Model, 'Smart Feeder')
.setCharacteristic(this.platform.api.hap.Characteristic.SerialNumber, this.deviceId || 'Unknown')
.setCharacteristic(this.platform.api.hap.Characteristic.FirmwareRevision, '1.0.0');
// Get or create the switch service
this.switchService = this.accessory.getService(this.platform.api.hap.Service.Switch)
|| this.accessory.addService(this.platform.api.hap.Service.Switch);
this.switchService.setCharacteristic(this.platform.api.hap.Characteristic.Name, this.name);
this.switchService.getCharacteristic(this.platform.api.hap.Characteristic.On)
.onGet(this.getOn.bind(this))
.onSet(this.setOn.bind(this));
// Auto-authenticate on startup (with error handling)
this.authenticate().catch(error => {
this.log.error('Failed to authenticate during startup:', error.message);
this.log.error('Plugin will continue to retry authentication when used');
});
}
// Hash password like the HomeAssistant plugin does
hashPassword(password) {
return crypto.createHash('md5').update(password).digest('hex');
}
async authenticate() {
if (!this.email || !this.password) {
throw new Error('Email and password are required in config');
}
try {
this.log('Authenticating with PetLibro API using HomeAssistant format...');
// Use the exact constants from the HomeAssistant plugin
const payload = {
appId: 1, // APPID = 1 from the HomeAssistant code
appSn: 'c35772530d1041699c87fe62348507a8', // APPSN from the HomeAssistant code
country: this.config.country || 'US',
email: this.email,
password: this.hashPassword(this.password), // Hash the password like HomeAssistant does
phoneBrand: '',
phoneSystemVersion: '',
timezone: this.config.timezone || 'America/New_York',
thirdId: null,
type: null
};
this.log('Using exact HomeAssistant format');
this.log('Endpoint:', `${this.baseUrl}/member/auth/login`);
this.log('Payload:', JSON.stringify({
...payload,
password: payload.password.substring(0, 8) + '...' // Don't log full hashed password
}, null, 2));
const response = await axios.post(`${this.baseUrl}/member/auth/login`, payload, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'PetLibro/1.3.45',
'Accept': 'application/json',
'Accept-Language': 'en-US',
'source': 'ANDROID',
'language': 'EN',
'timezone': payload.timezone,
'version': '1.3.45'
},
timeout: 10000
});
this.log('Response status:', response.status);
this.log('Response data:', JSON.stringify(response.data, null, 2));
// Check for success - HomeAssistant expects token in data.token
const data = response.data;
if (data && data.code === 0) {
this.log('🎉 Authentication successful with exact HomeAssistant format!');
// Look for token in data.token like HomeAssistant does
if (data.data && data.data.token) {
this.accessToken = data.data.token;
this.refreshToken = data.data.refresh_token || null;
const expiresIn = data.data.expires_in || 3600;
this.tokenExpiry = Date.now() + (expiresIn * 1000);
this.log('Authentication successful!');
this.log('Token (first 20 chars):', this.accessToken.substring(0, 20) + '...');
// Get device list if deviceId not specified
if (!this.deviceId) {
await this.getDevices();
}
return; // Success!
} else {
this.log('⚠️ Success response but no token found in data.token');
this.log('Full data object:', JSON.stringify(data, null, 2));
throw new Error('Authentication succeeded but no token found in data.token');
}
} else if (data && data.code) {
const errorMsg = data.msg || data.message || 'Unknown error';
this.log(`❌ Authentication failed: ${errorMsg} (code: ${data.code})`);
throw new Error(`Authentication failed: ${errorMsg} (code: ${data.code})`);
} else {
this.log(`❌ Unexpected response format`);
throw new Error('Unexpected response format');
}
} catch (error) {
this.log.error('Authentication failed:', error.message);
if (error.response) {
this.log.error(' Status:', error.response.status);
this.log.error(' Data:', JSON.stringify(error.response.data, null, 2));
}
throw error;
}
}
async refreshAuthToken() {
if (!this.refreshToken) {
return this.authenticate();
}
try {
const response = await axios.post(`${this.baseUrl}/member/auth/refresh`, {
refresh_token: this.refreshToken
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
}
});
if (response.data && response.data.access_token) {
this.accessToken = response.data.access_token;
this.tokenExpiry = Date.now() + (response.data.expires_in * 1000);
this.log('Token refreshed successfully');
}
} catch (error) {
this.log.warn('Token refresh failed, re-authenticating...');
return this.authenticate();
}
}
async getDevices() {
try {
this.log('🔍 Fetching device list from PetLibro API...');
await this.ensureAuthenticated();
// Use the correct endpoint from HomeAssistant integration
const endpoint = '/device/device/list';
this.log(`🔄 Trying devices endpoint: ${this.baseUrl}${endpoint}`);
const response = await axios.post(`${this.baseUrl}${endpoint}`, {}, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
'token': this.accessToken, // HomeAssistant uses 'token' header
'source': 'ANDROID',
'language': 'EN',
'timezone': this.config.timezone || 'America/New_York',
'version': '1.3.45'
},
timeout: 10000
});
this.log(`📊 Devices response status: ${response.status}`);
this.log(`📋 Devices response data:`, JSON.stringify(response.data, null, 2));
// Check for successful response
if (response.data && response.data.code === 0 && response.data.data) {
const devices = response.data.data;
if (Array.isArray(devices) && devices.length > 0) {
const device = devices[0];
// Look for device ID in different possible fields
this.deviceId = device.deviceSn || device.device_id || device.deviceId || device.id || device.serial;
const deviceName = device.deviceName || device.device_name || device.name || 'Unknown Device';
this.log(`✅ Found device: ${deviceName} (ID: ${this.deviceId})`);
this.log(`📱 Device details:`, JSON.stringify(device, null, 2));
return;
} else {
this.log('⚠️ No devices found in response data array');
}
} else if (response.data && response.data.code !== 0) {
const errorMsg = response.data.msg || 'Unknown error';
this.log(`❌ Device list API error: ${errorMsg} (code: ${response.data.code})`);
} else {
this.log('❌ Unexpected response format from device list endpoint');
}
} catch (error) {
this.log.error('💥 Failed to get devices:', error.message);
if (error.response) {
this.log.error(' Status:', error.response.status);
this.log.error(' Data:', JSON.stringify(error.response.data, null, 2));
}
}
}
async ensureAuthenticated() {
if (!this.accessToken || Date.now() >= this.tokenExpiry) {
await this.refreshAuthToken();
}
}
async getOn() {
this.log('Switch state requested - returning false (momentary switch)');
// Always return false since this is a momentary switch for feeding
return false;
}
async setOn(value) {
this.log(`Switch setOn called with value: ${value}`);
if (value) {
this.log('🍽️ Feed button tapped! Triggering manual feeding...');
try {
await this.triggerFeeding();
this.log('✅ Feeding command completed successfully');
// Reset switch to off after 1 second (momentary behavior)
setTimeout(() => {
this.log('🔄 Resetting switch to OFF state');
this.switchService
.getCharacteristic(this.platform.api.hap.Characteristic.On)
.updateValue(false);
}, 1000);
} catch (error) {
this.log.error('❌ Failed to trigger feeding:', error.message);
// Reset switch to off immediately on error
setTimeout(() => {
this.log('🔄 Resetting switch to OFF state (due to error)');
this.switchService
.getCharacteristic(this.platform.api.hap.Characteristic.On)
.updateValue(false);
}, 100);
// Don't throw the error - just log it to prevent breaking HomeKit
}
} else {
this.log('Switch turned OFF (ignored - this is a momentary switch)');
}
}
async triggerFeeding() {
try {
this.log('🔐 Ensuring authentication before feeding...');
await this.ensureAuthenticated();
if (!this.deviceId) {
throw new Error('Device ID not found - cannot send feed command');
}
this.log(`📡 Sending manual feed command to device: ${this.deviceId}`);
this.log(`🥘 Portions to dispense: ${this.config.portions || 1}`);
// Use the exact endpoint and format from HomeAssistant
const feedData = {
deviceSn: this.deviceId,
grainNum: parseInt(this.config.portions || 1), // Ensure it's an integer
requestId: this.generateRequestId() // Generate unique request ID like HomeAssistant
};
this.log('📤 Feed request payload:', JSON.stringify(feedData, null, 2));
this.log(`🔄 Using HomeAssistant endpoint: ${this.baseUrl}/device/device/manualFeeding`);
const response = await axios.post(`${this.baseUrl}/device/device/manualFeeding`, feedData, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
'token': this.accessToken, // HomeAssistant uses 'token' header
'source': 'ANDROID',
'language': 'EN',
'timezone': this.config.timezone || 'America/New_York',
'version': '1.3.45'
},
timeout: 15000 // 15 second timeout for feed commands
});
this.log(`📊 Feed response status: ${response.status}`);
this.log(`📋 Feed response data:`, JSON.stringify(response.data, null, 2));
// Check for success based on HomeAssistant code
if (response.status === 200) {
// HomeAssistant expects the response to be an integer or success code
if (typeof response.data === 'number' ||
(response.data && response.data.code === 0) ||
response.data === 0) {
this.log('✅ Manual feeding triggered successfully!');
return;
} else {
this.log(`⚠️ Feed command sent but unexpected response:`, JSON.stringify(response.data, null, 2));
}
}
throw new Error(`Feed command failed with status ${response.status}`);
} catch (error) {
this.log.error('💥 Failed to trigger feeding:', error.message);
if (error.response) {
this.log.error(' Response status:', error.response.status);
this.log.error(' Response data:', JSON.stringify(error.response.data, null, 2));
}
throw error;
}
}
// Generate unique request ID like HomeAssistant does
generateRequestId() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
getServices() {
return [this.informationService, this.switchService];
}
}