webOS 프로젝트

webOS를 활용한 HomeIoT : 스마트 가습기3 - 서비스

하이하정 2021. 11. 27. 13:40

  • 전체 시스템 소개
  • 조명 제어
  • 스마트 가습기
  • 수면 패턴 분석
  • 시스템 연동

 

서비스 개요


1. 서비스 기능

스마트 가습기는 사용자로부터 목표 습도를 받아오고 목표 습도에 도달할 수 있도록 가습기를 자동으로 제어합니다.

 

이러한 기능을 제공하기 위해 필요한 서비스의 기능은 다음과 같습니다.

  1. Web Socket Server를 연다. (heartbeat를 통해 지속적인 서비스가 되도록 한다.)
  2. Enact App에서 목표 습도 값을 받는다.
  3. Enact App에서 스마트 가습기 상태 on/off 값을 받는다.
  4. ESP 디바이스로부터 현재 습도와 온도 값을 받아온다.
  5. 가습기 상태가 on일 경우 습도 값이 목표 습도보다 낮으면 가습기를 켜고, 목표 습도보다 높으면 가습기를 끈다.
  6. 가습기 상태가 off일 경우 목표 습도에 관계없이 가습기는 동작하지 않는다.

 

2. 서비스 함수

sendHumdOnOff
on/off 정보가 담긴 command를 device에 보낸다.
controlHumd
가습기 상태와 습도 값을 얻어와 목표 습도와 비교한다.
handleMessage
디바이스로부터 현재 습도와 온도를 받는다.
startServer
서버 서비스를 등록한다. 이때, 서버 서비스가 끊기지 않게 하기 위해 heartbeat를 구독해야한다.
getHumdControlData
현재 습도 제어 정보를 얻어온다.
setHumdOn
가습기 동작 여부를 설정하는 서비스를 등록한다.
setTargetHum
목표 습도를 설정하는 서비스를 등록한다.
heartbeat
서비스가 지속적으로 동작하기 위해 heartbeat 서비스를 등록한다.

 

서비스 개발


1. humdControlData

let humdControlData = {
    currentHum : 45,
    currentTemp : 18,
    autoHumdOn : false,
    isHumdOn : false,
    wasHumdOn : false,
    targetHum : 45
};

humdControlData에 data들을 저장합니다.

 

2. setHumdOnOff

const sendHumdOnOff = (value) => {
    let statusString = value ? "on" : "off";
    let command = {
        msgType : "command",
        deviceType : "humd",
        // deviceID : message.payload.deviceID,
        status : statusString
    };
    broadCastMessage(JSON.stringify(command));
};

디바이스에 가습기 상태 ON/OFF command를 보냅니다.

상태 설정 value 를 얻어 statusString 변수에 저장하고 JSON형식으로 디바이스에 전달합니다.

 

3. controlHumd

const controlHumd = () => {
    let alarmText;
    if (humdControlData.autoHumdOn) {
        if(humdControlData.currentHum <= humdControlData.targetHum) {
            humdControlData.isHumdOn = true;
            sendHumdOnOff(true);
            alarmText = '[가습기 자동 제어 모드] 실내가 건조하여 가습기를 실행합니다.';
        }
        else {
            humdControlData.isHumdOn = false;
            sendHumdOnOff(false);
            alarmText = '[가습기 자동 제어 모드] 실내가 습하여 가습기를 종료합니다.';
        }
    }
    else {
        humdControlData.isHumdOn = false;
        sendHumdOnOff(false);
        alarmText = '가습기 동작을 종료합니다.';
    }
    
    if (humdControlData.isHumdOn != humdControlData.wasHumdOn) {
        service.call("luna://com.webos.notification/createToast", {message:alarmText}, function(m2) {
            console.log(logHeader, "SERVICE_METHOD_CALLED:com.webos.notification/createToast");
        });
        humdControlData.wasHumdOn = humdControlData.isHumdOn;
    }
};

현재 가습기 동작 상태와 현재습도 값을 얻어와 목표습도보다 크면 가습기를 끄고, 목표습도보다 작으면 가습키를 켜는 코드를 작성합니다.

 

이때, alarmText변수를 사용하여 가습기가 켜고 꺼질 때 ToastMessage가 뜨도록 합니다.

 

4. handleMessage

const handleMessage = (message) => {    
    console.log('Recieved: ', message);
    let msg = JSON.parse(message);
    if (msg.msgType == 'sendValue') {
        if(msg.deviceType == 'humd') {
            humdControlData.currentHum = msg.currHum;
            humdControlData.currentTemp = msg.currTemp;
            controlHumd();
        }
    }
};

msgType이 'sendValue'이고, deviceType이 'humd'인 msg가 도착하면 현재 온도와 습도 값을 받아와 변수에 저장합니다..

 

5. getHumdControlData

// get 습도 제어 정보
service.register('getHumdControlData', function(message){
    // console.log('getTargetHum: ', humdControlData);

    message.respond({
        returnValue : true,
        data:humdControlData
    });
});

service.register를 사용하여 getHumdControlData 서비스를 등록합니다.

디바이스의 message에서 humdControlData를 받아 respond합니다.

 

6. setHumdOn

// set 가습기 동작 여부
service.register('setHumdOn', function(message){
    console.log('setHumdOn, Received: ', humdControlData.isHumdOn);

    humdControlData.autoHumdOn = message.payload.value;
    controlHumd();

    message.respond({
        returnValue : true
    });  
});

service.register를 사용하여 setHumdOn 서비스를 등록합니다.

가습기 동작 여부 value를 받아 isHumdOn에 저장합니다.

 

7. setTargetHum

// set 목표 습도
service.register('setTargetHum', function(message){
    console.log('setTargetHum, Received: ', message.payload.value);
    humdControlData.targetHum = message.payload.value;

    message.respond({
        returnValue : true
    });
});

service.register를 사용하여 setTargetHum 서비스를 등록합니다.

목표 습도 value를 받아 targetHum에 저장합니다.

 

+ startServer

app.ws('/', function(ws, req) {
    console.log('Recieved something');
    ws.on('message', handleMessage);
});

const broadCastMessage = (message) => {
    aWss.clients.forEach(function (client) {
        client.send(message);
    });
}

service.register("startServer", function(message) {
    console.log('WebSocketServer on?');
    app.listen(port);
    console.log('WebSocketServer on!');

    // heartbeat 구독
    const sub = service.subscribe(`luna://${pkgInfo.name}/heartbeat`, {subscribe:true});
    const max = 120;
    let count = 0;
    sub.addListener("response", function(msg) {
        console.log(JSON.stringify(msg.payload));
        if (++count >= max) {
            sub.cancel();
            setTimeout(function(){
                // console.log(max+" responses recieved, exiting...");
                process.exit(0);
            }, 1000);
        }
    });

    message.respond({
        returnValue: true,
        Response: "Start Server!"
    });
});

service.register를 사용하여 startServer서비스를 등록합니다.

 

webSocketServer는 다음 사이트를 참고하여 작성되었습니다.

https://www.npmjs.com/package/express-ws

 

++ heatbeat

webSocketServer 서비스가 계속해서 동작하기 위해 heartbeat를 구독해야 합니다.

자세한 내용은 heatbeat 사용하기 포스팅을 통해 확인해주시기 바랍니다.

더보기
// heartbeat
// handle subscription requests
const subscriptions = {};
let heartbeatinterval;
let x = 1;
function createHeartBeatInterval() {
    if (heartbeatinterval) {
        return;
    }
    console.log(logHeader, "create heartbeatinterval");
    heartbeatinterval = setInterval(function() {
        sendResponses();
    }, 1000);
}

// send responses to each subscribed client
function sendResponses() {
    console.log(logHeader, "send_response");
    console.log("Sending responses, subscription count=" + Object.keys(subscriptions).length);
    for (const i in subscriptions) {
        if (Object.prototype.hasOwnProperty.call(subscriptions, i)) {
            const s = subscriptions[i];
            s.respond({
                returnValue: true,
                event: "beat " + x
            });
        }
    }
    x++;
}

var heartbeat = service.register("heartbeat");
heartbeat.on("request", function(message) {
    console.log(logHeader, "SERVICE_METHOD_CALLED:/heartbeat");
    message.respond({event: "beat"}); // initial response 
    if (message.isSubscription) { 
        subscriptions[message.uniqueToken] = message; //add message to "subscriptions" 
        if (!heartbeatinterval) {
            createHeartBeatInterval();
        }
    } 
}); 
heartbeat.on("cancel", function(message) { 
    delete subscriptions[message.uniqueToken]; // remove message from "subscriptions" 
    var keys = Object.keys(subscriptions); 
    if (keys.length === 0) { // count the remaining subscriptions 
        console.log("no more subscriptions, canceling interval"); 
        clearInterval(heartbeatinterval);
        heartbeatinterval = undefined;
    } 
});

 

전체 서비스 코드


1. service.js

더보기
const pkgInfo = require('./package.json');
const Service = require('webos-service');

const service = new Service(pkgInfo.name); // Create service by service name on package.json
const logHeader = "[" + pkgInfo.name + "]";

var express = require('express');
var app = express();
var expressWs = require('express-ws')(app);
var aWss = expressWs.getWss('/');
const port = 9999;

// ***************************** Websocket Server *****************************
app.use(function (req, res, next) {
  console.log('middleware');
  req.testing = 'testing';
  return next();
});

app.ws('/', function(ws, req) {
    console.log('Recieved something');
    ws.on('message', handleMessage);
});

const handleMessage = (message) => {    
    console.log('Recieved: ', message);
    let msg = JSON.parse(message);
    if (msg.msgType == 'sendValue') {
        if(msg.deviceType == 'humd') {
            humdControlData.currentHum = msg.currHum;
            humdControlData.currentTemp = msg.currTemp;
            controlHumd();
        }
    }
};

const broadCastMessage = (message) => {
    aWss.clients.forEach(function (client) {
        client.send(message);
    });
}

service.register("startServer", function(message) {
    console.log('WebSocketServer on?');
    app.listen(port);
    console.log('WebSocketServer on!');

    // heartbeat 구독
    const sub = service.subscribe(`luna://${pkgInfo.name}/heartbeat`, {subscribe:true});
    // const max = 120;
    // let count = 0;
    sub.addListener("response", function(msg) {
        console.log(JSON.stringify(msg.payload));
        // if (++count >= max) {
        //     sub.cancel();
        //     setTimeout(function(){
        //         // console.log(max+" responses recieved, exiting...");
        //         process.exit(0);
        //     }, 1000);
        // }
    });

    message.respond({
        returnValue: true,
        Response: "Start Server!"
    });
});

// ***************************** Heartbeat *****************************
// send responses to each subscribed client
function sendResponses() {
    // console.log(logHeader, "send_response");
    // console.log("Sending responses, subscription count=" + Object.keys(subscriptions).length);
    for (const i in subscriptions) {
        if (Object.prototype.hasOwnProperty.call(subscriptions, i)) {
            const s = subscriptions[i];
            s.respond({
                returnValue: true,
                event: "beat " + x
            });
        }
    }
    x++;
}

// handle subscription requests
const subscriptions = {};
let interval;
let x = 1;
function createInterval() {
    if (interval) {
        return;
    }
    console.log(logHeader, "create_interval");
    console.log("create new interval");
    interval = setInterval(function() {
        sendResponses();
    }, 1000);
}

// ***************************** Create Toast *****************************
function createToast(message) {
    service.call("luna://com.webos.notification/createToast", {message:message}, function(m2) {
        console.log(logHeader, "SERVICE_METHOD_CALLED:com.webos.notification/createToast");
    });
}

const heartbeat2 = service.register("heartbeat");
heartbeat2.on("request", function(message) {
    console.log(logHeader, "SERVICE_METHOD_CALLED:/heartbeat/request");
    console.log("heartbeat callback");
    message.respond({event: "beat"});
    if (message.isSubscription) {
        subscriptions[message.uniqueToken] = message;
        if (!interval) {
            createInterval();
        }
    }
});
heartbeat2.on("cancel", function(message) {
    console.log(logHeader, "SERVICE_METHOD_CALLED:/heartbeat/cancel");
    console.log("Canceled " + message.uniqueToken);
    delete subscriptions[message.uniqueToken];
    const keys = Object.keys(subscriptions);
    if (keys.length === 0) {
        console.log("no more subscriptions, canceling interval");
        clearInterval(interval);
        interval = undefined;
    }
});

// ***************************** 스마트 가습기 *****************************
let humdControlData = {
    currentHum : 45,
    currentTemp : 18,
    autoHumdOn : false,
    isHumdOn : false,
    wasHumdOn : false,
    targetHum : 45
};

const sendHumdOnOff = (value) => {
    let statusString = value ? "on" : "off";
    let command = {
        msgType : "command",
        deviceType : "humd",
        // deviceID : message.payload.deviceID,
        status : statusString
    };
    broadCastMessage(JSON.stringify(command));
};

const controlHumd = () => {
    let alarmText;
    if (humdControlData.autoHumdOn) {
        if(humdControlData.currentHum <= humdControlData.targetHum) {
            humdControlData.isHumdOn = true;
            sendHumdOnOff(true);
            alarmText = '[가습기 자동 제어 모드] 실내가 건조하여 가습기를 실행합니다.';
        }
        else {
            humdControlData.isHumdOn = false;
            sendHumdOnOff(false);
            alarmText = '[가습기 자동 제어 모드] 실내가 습하여 가습기를 종료합니다.';
        }
    }
    else {
        humdControlData.isHumdOn = false;
        sendHumdOnOff(false);
        alarmText = '가습기 동작을 종료합니다.';
    }
    
    if (humdControlData.isHumdOn != humdControlData.wasHumdOn) {
        service.call("luna://com.webos.notification/createToast", {message:alarmText}, function(m2) {
            console.log(logHeader, "SERVICE_METHOD_CALLED:com.webos.notification/createToast");
        });
        humdControlData.wasHumdOn = humdControlData.isHumdOn;
    }
};

// get 습도 제어 정보
service.register('getHumdControlData', function(message){
    // console.log('getTargetHum: ', humdControlData);

    message.respond({
        returnValue : true,
        data:humdControlData
    });
});

// set 가습기 동작 여부
service.register('setHumdOn', function(message){
    console.log('setHumdOn, Received: ', humdControlData.isHumdOn);

    humdControlData.autoHumdOn = message.payload.value;
    controlHumd();

    message.respond({
        returnValue : true
    });  
});

// set 목표 습도
service.register('setTargetHum', function(message){
    console.log('setTargetHum, Received: ', message.payload.value);
    humdControlData.targetHum = message.payload.value;

    message.respond({
        returnValue : true
    });
});