index.js

//////////////////////////////////////////////////////////////////////
//	Copyright (C) Hiroshi SUGIMURA 2020.09.10
//////////////////////////////////////////////////////////////////////
'use strict'

const v3 = require('node-hue-api').v3;
const axios = require('axios');
const os = require('os');


// Default constants
const DEFAULT_REQUEST_TIMEOUT = 5000;
const DEFAULT_SEARCH_TIMEOUT = 20000;
const DEFAULT_RETRY_COUNT = 3;
const DEFAULT_POLL_INTERVAL = 5000;


//////////////////////////////////////////////////////////////////////
/**
 * Philips Hue管理用オブジェクト
 * @namespace Hue
 */
let Hue = {
	// member
	// user config
	appName: 'hueHandler',
	deviceName: 'hostname',
	userName: 'sugilab',
	deviceType: '', // = appName + '#' + deviceName + ' ' + userName,
	userKey: '',  // default
	userFunc: {},  // callback function
	config: {
		requestTimeout: DEFAULT_REQUEST_TIMEOUT,
		searchTimeout: DEFAULT_SEARCH_TIMEOUT,
		maxRetries: DEFAULT_RETRY_COUNT,
		pollInterval: DEFAULT_POLL_INTERVAL
	},

	// private
	bridge: {},
	gonnaInitialize: false,
	canceled: false,
	retryRemain: 3,     // リトライ回数
	debugMode: false,

	// public
	facilities: {},	// 全機器情報リスト
};

////////////////////////////////////////
// inner functions

/**
 * 指定時間スリープする
 * @memberof Hue
 * @param {number} ms 待機時間(ミリ秒)
 * @returns {Promise<void>}
 */
Hue.sleep = function (ms) {
	return new Promise(function (resolve) {
		setTimeout(function () { resolve() }, ms);
	})
};


/**
 * オブジェクトをキーでソートして返す
 * @memberof Hue
 * @param {object} obj ソート対象オブジェクト
 * @returns {object} ソート済みオブジェクト
 */
Hue.objectSort = function (obj) {
	// まずキーのみをソートする
	let keys = Object.keys(obj).sort();

	// 返却する空のオブジェクトを作る
	let map = {};

	// ソート済みのキー順に返却用のオブジェクトに値を格納する
	keys.forEach(function (key) {
		map[key] = obj[key];
	});

	return map;
};


//////////////////////////////////////////////////////////////////////
// Hue特有の手続き
//////////////////////////////////////////////////////////////////////

/**
 * Hue Bridgeをネットワーク内から検索する
 * @memberof Hue
 * @param {number} timeout タイムアウト時間(ミリ秒)
 * @returns {Promise<Array>} 発見されたブリッジのリスト
 * @throws {Error} 検索エラー時
 */
Hue.searchBridge = async function (timeout) {
	return await v3.discovery.upnpSearch(timeout);
}

/**
 * ダミーコールバック関数
 * @memberof Hue
 */
Hue.dummy = function () {
};

//////////////////////////////////////////////////////////////////////
/**
 * Hueハンドラの初期化
 * @memberof Hue
 * @param {string} userKey 既存のHueユーザーキー(なければ空文字)
 * @param {function} userFunc 状態変更時に呼ばれるコールバック関数 (ip, response, error) => {}
 * @param {object} [Options] オプション設定
 * @param {string} [Options.appName='hueManager'] アプリケーション名
 * @param {string} [Options.deviceName=hostname] デバイス名
 * @param {string} [Options.userName='sugilab'] ユーザー名
 * @param {boolean} [Options.debugMode=false] デバッグモード有効化
 * @param {string} [Options.bridgeIp] ブリッジのIP指定(指定時は自動検索スキップ)
 * @param {number} [Options.requestTimeout=5000] HTTPリクエストタイムアウト(ms)
 * @param {number} [Options.searchTimeout=20000] ブリッジ検索タイムアウト(ms)
 * @param {number} [Options.maxRetries=3] ブリッジ検索リトライ回数
 * @param {number} [Options.pollInterval=5000] Linkボタン監視等のポーリング間隔(ms)
 * @returns {Promise<string>} 取得または確認されたUserKey
 */
Hue.initialize = async function (userKey, userFunc, Options = {}) {

	// 二重初期化起動の禁止(初期化は何回やってもよいが、初期化中だけは初期化を受け付けない)
	if (Hue.gonnaInitialize) {
		if (Hue.debugMode) console.log('-- Hue.initialize, prohibit double initialize (hue-hundler.js) ');
		return Hue.userKey;
	}
	Hue.gonnaInitialize = true;

	Hue.userKey = userKey == undefined ? '' : userKey;
	Hue.userFunc = userFunc == undefined ? Hue.dummy : userFunc;

	Hue.debugMode = Options.debugMode == undefined || Options.debugMode == false ? false : true;   // true: show debug log

	Hue.appName = Options.appName == undefined || Options.appName === '' ? 'hueManager' : Options.appName;
	Hue.deviceName = Options.deviceName == undefined || Options.deviceName === '' ? os.hostname() : Options.deviceName;
	Hue.userName = Options.userName == undefined || Options.userName === '' ? 'sugilab' : Options.userName;

	// Config options
	Hue.config.requestTimeout = Options.requestTimeout || DEFAULT_REQUEST_TIMEOUT;
	Hue.config.searchTimeout = Options.searchTimeout || DEFAULT_SEARCH_TIMEOUT;
	Hue.config.maxRetries = Options.maxRetries || DEFAULT_RETRY_COUNT;
	Hue.config.pollInterval = Options.pollInterval || DEFAULT_POLL_INTERVAL;

	Hue.deviceType = Hue.appName + '#' + Hue.deviceName + ' ' + Hue.userName;

	Hue.canceled = false; // 初期化のキャンセルシグナル
	Hue.retryRemain = Hue.config.maxRetries;  // リトライ回数

	if (Hue.debugMode) console.log('==== hue-hundler.js ====');
	if (Hue.debugMode) console.log('userKey:', Hue.userKey);
	if (Hue.debugMode) console.log('deviceType:', Hue.deviceType);
	if (Hue.debugMode) console.log('debugMode:', Hue.debugMode);
	if (Hue.debugMode) console.log('config:', Hue.config);
	if (Hue.debugMode) console.log('-- Hue.initialize, getBridge');

	//==========================================================================
	// ブリッジの発見
	let bridges = [];
	if (Options.bridgeIp) {
		if (Hue.debugMode) console.log('-- Hue.initialize, use bridgeIp:', Options.bridgeIp);
		bridges = [{ ipaddress: Options.bridgeIp }];
		Hue.bridge = bridges[0];
	}

	while (bridges.length == 0) {
		try {
			if (Hue.canceled) { // 初期化のキャンセルシグナルが来たので終わる
				Hue.userFunc(Hue.bridge.ipaddress, 'Canceled', null);
				Hue.gonnaInitialize = false;
				return Hue.userKey; // cancelの時はkeyを何も返さない
			}

			bridges = await Hue.searchBridge(Hue.config.searchTimeout); // timeout from config
			if (bridges.length == 0) {
				// 失敗した
				Hue.userFunc(null, null, "Can't find bridge.");
				Hue.retryRemain -= 1;
				if (Hue.retryRemain === 0) { return Hue.userKey; } // リトライ限界が来たのでkey無しで返却
			}
		} catch (e) {
			console.error("Exception! Hue.searchBridge.", e);
			if (Hue.canceled) {
				Hue.gonnaInitialize = false;
				throw e; // キャンセルされていたら抜ける
			}
			// エラーでもリトライする (gonnaInitializeはfalseにしない)
		}
	}
	Hue.retryRemain = Hue.config.maxRetries;  // リトライ回数復帰
	Hue.bridge = bridges[0];  // 一つしか管理しない

	if (Hue.debugMode) console.log('-- Hue.initialize, connect:', Hue.bridge.ipaddress);

	//==========================================================================
	// Link
	if (Hue.userKey === '') {  // 新規Link
		if (Hue.debugMode) console.log('-- Hue.initialize, new userKey and authorize.');

		if (Hue.canceled) { // 初期化のキャンセルシグナルが来たので終わる
			Hue.userFunc(Hue.bridge.ipaddress, 'Canceled', null);
			Hue.gonnaInitialize = false;
			return Hue.userKey; // cancelの時はkeyを何も返さない
		}

		let hueurl = 'http://' + Hue.bridge.ipaddress + '/api/newdeveloper';
		let res = 'unauthorized user';
		try {
			const resData = await axios.get(hueurl, { timeout: Hue.config.requestTimeout });
			let body = resData.data;
			// console.log('----');
			if (body[0].error) {
				// errorとはいえ,こちらが正規ルート = ユーザがいない,だからこれから登録処理という流れ
			} else {
				res = body[0].description;
			}
		} catch (err) {
			console.error(err);
			Hue.gonnaInitialize = false;
			throw err;
		}

		if (Hue.debugMode) console.log('-- Hue.initialize, get Hue.userKey');
		while (Hue.userKey == '' && Hue.canceled == false) {  // keyを獲得するか、ユーザーがキャンセルするまで無限に実行
			if (Hue.canceled) { // 初期化のキャンセルシグナルが来たので終わる
				Hue.userFunc(Hue.bridge.ipaddress, 'Canceled', null);
				Hue.gonnaInitialize = false;
				return Hue.userKey; // cancelの時はkeyを何も返さない
			}

			hueurl = 'http://' + Hue.bridge.ipaddress + '/api';
			if (Hue.debugMode) console.log('-- Hue.initialize, deviceType:', Hue.deviceType);

			// await axios.post( hueurl, {timeout: 5000, json: { devicetype: Hue.deviceType }} )
			const reqjson = { devicetype: Hue.deviceType };
			try {
				const resData = await axios.post(hueurl, reqjson, { timeout: Hue.config.requestTimeout });
				let body = resData.data;
				if (body[0] && body[0].success) {
					if (Hue.debugMode) console.log('Hue.initialize, Link is succeeded.');
					Hue.userKey = body[0].success.username;
				} else {
					// console.log(body);
					Hue.userFunc(Hue.bridge.ipaddress, 'Linking', null);
					// if( Hue.debugMode == true ) {
					// console.log('Please push Link button.');
					// }
				}
			} catch (err) {
				console.error(err);
				if (Hue.canceled) {
					Hue.gonnaInitialize = false;
					throw err;
				}
				// エラーでもリトライする
			}
			await Hue.sleep(Hue.config.pollInterval); // 待機
		}

	} else {
		Hue.debugMode ? console.log('Hue.initialize, use userKey: ', Hue.userKey) : 0;
	}

	await Hue.getState();

	Hue.gonnaInitialize = false;  // 初期化中フラグ、初期化中キャンセルに利用
	return Hue.userKey;
};


/**
 * 初期化処理をキャンセルする
 * @memberof Hue
 */
Hue.initializeCancel = function () {
	if (Hue.debugMode) console.log('Hue.initializeCancel(). Please wait.');
	Hue.canceled = true;
};


/**
 * 現在の状態を取得する
 * @memberof Hue
 * @returns {Promise<void>} 完了時にコールバックが呼ばれる
 */
Hue.getState = async function () {
	// 状態取得
	if (Hue.debugMode) console.log('Hue.getState()');

	let hueurl = 'http://' + Hue.bridge.ipaddress + '/api/' + Hue.userKey + '/lights';
	try {
		const res = await axios.get(hueurl, { timeout: Hue.config.requestTimeout });
		let rep = res.data;
		rep = Hue.objectSort(rep);

		if (rep.error) {  // Linkしていない、keyが違うなど、受信エラー
			Hue.userFunc(Hue.bridge.ipaddress, rep, rep.error.description);
		} else {
			Hue.facilities[Hue.bridge.ipaddress] = { bridge: Hue.bridge, devices: rep };
			Hue.userFunc(Hue.bridge.ipaddress, rep, null);
		}
	} catch (err) {
		// Hue.userFunc( Hue.bridge.ipaddress, null, err);
		throw err;
	}
};


/**
 * 状態を設定(制御)する
 * @memberof Hue
 * @param {string} url 制御対象のAPIエンドポイント (e.g. '/lights/1/state')
 * @param {object|string} bodyObj 制御内容のJSONオブジェクトまたは文字列
 * @returns {Promise<void>} 完了時にコールバックが呼ばれる
 */
Hue.setState = async function (url, bodyObj) {
	// 状態セット
	if (Hue.debugMode) console.log('Hue.setState() url:', url, 'bodyObj:', bodyObj);

	// 引数がObjectだっつってるのにobject入れてくる場合がある。
	if (typeof bodyObj == 'string') {
		bodyObj = JSON.parse(bodyObj);
	}

	let hueurl = 'http://' + Hue.bridge.ipaddress + '/api/' + Hue.userKey + url;
	let rep;
	try {
		const res = await axios.put(hueurl, bodyObj, { headers: { "Content-Type": "application/json" }, timeout: Hue.config.requestTimeout });
		rep = res.data;
	} catch (err) {
		// Hue.userFunc( Hue.bridge.ipaddress, null, err);
		// console.error(err);
		throw err;
	}

	if (Hue.debugMode) console.log('Hue.setState() rep:', rep);
	rep = Hue.objectSort(rep);
	Hue.userFunc(Hue.bridge.ipaddress, rep, null);
};


module.exports = Hue;
//////////////////////////////////////////////////////////////////////
// EOF
//////////////////////////////////////////////////////////////////////