튼튼발자 개발 성장기🏋️

푸르지오 스마트홈 가전제어를 언어로 손쉽게 (ios) 본문

프로젝트/토이프로젝트

푸르지오 스마트홈 가전제어를 언어로 손쉽게 (ios)

시뻘건 튼튼발자 2025. 1. 7. 11:09
반응형

 

나는 순수 전기차를 사용하고 있기 때문에 전기차 충전소를 자주 이용한다. 집밥이라고 불리는 집 충전소에서도 거의 매일 이용하고 있는데, 공용 충전소인 만큼 최대 충전 시간이 법적으로 정해져 있다. 여기서 이웃주민들끼리 문제가 생기기 시작한다.
충전을 사용하지 않더라도 충전소에 주차를 해놓거나 충전이 다 되어도 이동주차를 하지 않는 비매너 행동과 법적으로 정해진 시간 이상 주차를 해놓는 경우가 매일 생긴다. 새벽출근에 저녁퇴근하는 나는 평일에 충전을 할 수 없는 경우가 종종 생겨 스트레스가 이만저만이 아니다.
거기다 푸르지오 스마트홈을 통해 전기차 충전소 각 자리의 상태(충전 중 혹은 충전 가능)를 알 수 있는데, 매번 주차장 앞에서 앱을 키고 조회하기가 번거롭기도 하고 푸르지오 서버가 이슈가 많고 하루에 몇 번이나 장애가 발생하는 경우를 자주 볼 수 있기 때문에 이에 대한 스트레스를 최소화 하고자 배치를 만드려고 한다.
그 외에도 Iphone의 siri(시리)로 편하게 엘리베이터 호출이라던가, 최근 차량 출입 조회, 전등 On/Off 등을 컨트롤 하는 것이 목표다.

 

 

Step 1. 패킷 감청 및 분석

[그림 1] 패킷 감청

[그림 1]과 같이 푸르지오 앱에서 특정 액션을 취했을 때 어떤 통신을 하는지, 어떤 데이터가 오고 가는지 알기 위해서 proxy를 통해 패킷을 감청하고 분석한다. proxy는 trio_http_proxy를 입맛대로 수정하여 최종적으로 .pcap 파일로 export하는 로직을 추가했다. 나는 개인 서버 PC에서 proxy를 실행했지만 로컬에서 실행해도 된다.

scp -P {port}  {username}@{ip}:/your/proxy/home/prugio.pcap .

이렇게 얻은 패킷(pcap)을 위와 같이 서버에서 로컬로 가지고온다. 이제 이 패킷을 분석하기만하면 된다. 분석은 wireshark 툴을 사용했다. 무료버전이더라도 가장 많은 기능을 제공해주고 인터넷 정보도 많아서 유용하게 사용할 수 있을 것 같다.

wireshark를 다운 받았다면 [File] -> [Open] 을 통해서 내 pcap를 열면 패킷 데이터가 우수수하게 보인다.

[그림 2] 패킷 분석

패킷을 분석하다보면 알 수 있는 사실은 모든 데이터가 암호화되어 있다는 사실이었다. 사실 2년 전까지만해도 암호화하지 않았다는 이야기를 들은 적이 있었는데...어느새 암호화를 한 모양이다. 이를 해석하려면 복호화 키를 가지고 있어야하는데, 복호화 키는 앱에 내장되어 있을거라는 확신을 가지고 앱을 까보기 시작한다.

 

Step 2. 앱 백업 및 분석

 

앱을 까보기 위해서 먼저 백업을 해야한다. 아이폰을 로컬에 연결하여 백업한다.나는 iMazing을 사용하여 백업했다.

[그림 3] iMazing

[그림 3]과 같이 백업하고난 뒤에 푸르지오 스마트홈 앱을 IPA 파일로 export하면 디컴파일할 수 있는 파일을 얻을 수 있다. IPA파일은 class-dump 혹은 hopper를 사용하여 디컴파일할 수 있다. 이제 해보.....잠깐.

더보기

확실하지 않지만 디컴파일하고 앱을 까보는 행위는 법적인 문제가 될 수 있을 것이라는 이야기를 들었다. 따라서 다른 방법을 찾아 보자. (내 간은 콩알만하니까...절대 내 간 지켜!!!)

 

Step 3. 앱의 Tree 분석 / 또 다시 패킷 감청

어떤 프로그램이든 실행하기 위한 메타데이터가 같이 설치된다. 디컴파일과 같이 법적으로 문제되지 않게 메타데이터를 볼 수 있는 방법이있다. ibackup viewer로 아이폰을 백업하면 쉽게 열람할 수 있다. 잘 찾아보면 salt나 public key와 같은 암/복호화에 사용되는 데이터를 . 볼 수 있었다.

[그림 4] iBackup viewer

 

또 다른 방법은 charles라는 강력한 proxy를 사용하는 것이다. 푸르지오 스마트홈 서버 통신은 https, wss protocol을 사용하기 때문에 인증서를 등록해주어야한다. 아이폰과 맥북에 각각 인증서를 등록해주고 charles을 실행해주면 된다.(주의: 보안을 위해 사용이 끝나고 인증서를 삭제하자.)

- 인증서 등록 방법: [Help] -> {SSL Proxying] -> [Install Charles Root Certificate]

각 디바이스에 인증서를 등록했다면 이제 SSL Proxy 설정을 해주어야한다. [그림 5]와 같이 [Proxy] -> [SSL Proxying Settings]에서 푸르지오 스마트홈 서버를 include해준다. port를 모르겠다면 아스트리스크(*)를 사용해도 된다.

[그림 5] SSL proxy setting

 

모든 설정은 마쳤다. 이제 charles를 재시작해주고 푸르지오 스마트홈을 실행하여 원하는 액션을 취하면 된다. [그림 6]와 같이 전기차 충전소 상태, 방문자 조회, 조명, 엘리베이터 호출에 대한 api 정보를 볼 수 있다. contents를 보면 인증토큰과 basic 토큰이 그대로 노출되는 것을 볼 수 있다.

[그림 6] 패킷 감청

 

이제 우리는 애플홈킷을 이용해서 시리에게 가전제어를 시킬 수 있게되었다!!!

 

 

더보기

[참고]

이것 저것 시도해보다가 푸르지오 스마트홈에서 사용하는 javascript module을 찾았다. 아래 코드는 해당 모듈에서 사용하는 모든 api의 path를 나타낸다.

var n = {
        kakaoAuthorize: 'https://kauth.kakao.com/oauth/authorize',
        kakaoToken: 'https://kauth.kakao.com/oauth/token',
        kakaoProfile: 'https://kapi.kakao.com/v2/user/me',
        naverAuthorize: 'https://nid.naver.com/oauth2.0/authorize',
        naverToken: 'https://nid.naver.com/oauth2.0/token',
        naverProfile: 'https://openapi.naver.com/v1/nid/me',
        facebookProfile: 'https://graph.facebook.com/me',
        user: '/user',
        userSns: '/user/sns',
        userInit: '/user/init',
        logIn: '/login',
        logInSns: '/login/sns',
        userToken: '/user/token',
        userCheck: '/user/{user_id}/check',
        userComplex: '/user/complex',
        userFindId: '/user/find-id',
        userCert: '/user/cert',
        userCertConfirm: '/user/cert/confirm',
        userPassword: '/user/{user_id}/password',
        logOut: '/logout',
        userAddress: '/user/address',
        userPushToken: '/user/push/token',
        setting: '/setting',
        settingMy: '/setting/my',
        settingFamily: '/setting/family',
        settingFamilyCurrent: '/setting/family/current',
        settingFamilyRequest: '/setting/family/request',
        settingFamilyRepresent: '/setting/family/represent',
        settingFamilyResident: '/setting/family/resident',
        settingFamilyResidentCancel: '/setting/family/resident_cancel',
        settingPush: '/setting/push',
        settingPassword: '/setting/password',
        settingBanner: '/setting/banner',
        settingManual: '/setting/manual',
        main: '/main',
        mainNotice: '/main/notice',
        mainNoticeRead: '/main/notice',
        communityNotice: '/community/notice',
        communitySurvey: '/community/survey',
        communitySurveyQuestion: '/community/survey/question',
        communitySurveyAnswer: '/community/survey/answer',
        communityComplaint: '/community/complaint',
        communityComplaintBlltNo: '/community/complaint/{bllt_no}',
        communityOffice: '/community/office',
        communityAppDrawers: '/community/appdrawer',
        communityLinkApps: '/community/link/apps',
        convenienceElevator: '/convenience/elevator',
        convenienceParcelBox: '/convenience/parcel-box',
        convenienceVisitor: '/convenience/visitor',
        convenienceCar: '/convenience/car',
        convenienceParking: '/convenience/parking',
        convenienceCarVisit: '/convenience/car/visit',
        convenienceCctv: '/convenience/cctv',
        convenienceFamily: '/convenience/family',
        convenienceChargingPoint: '/convenience/charging/point',
        convenienceComplexLayout: '/convenience/complex/layout',
        convenienceComplexOutLine: '/convenience/complex/overview/{houscplx_cd}',
        convenienceComplexPlan: '/convenience/complex/plan',
        convenienceComplexInfo: '/convenience/complex/info',
        convenienceWeather: '/convenience/weather',
        convenienceWeatherWarning: '/convenience/weatherWarning',
        PerformanceList: '/community/performance/list',
        PerformanceDetail: '/community/performance/detail',
        convenienceMsg: '/convenience/msg',
        convenienceBusStations: '/convenience/bus_stations/{houscplx_cd}',
        convenienceBusArrivals: '/convenience/bus_stations',
        control: '/control',
        controlDevice: '/control/device',
        controlUser: '/control/user',
        controlUserControlId: '/control/user/{user_set_ctl_id}',
        controlUserDevice: '/control/userdevice',
        controlGuidingText: '/control/guidingtext',
        controlDeviceLastResult: '/control/last_result',
        statsEnergyMonth: '/stats/energy/month/{yyyymm}',
        statsEnergyPast: '/stats/energy/past',
        statsEnergyGoal: '/stats/energy/goal',
        statsMaintenanceFee: '/stats/maintenance-fee',
        userDeviceStatus: '/user/device/status',
        reserveInfo: '/control/reserve',
        reserveInfoDelete: '/control/reserve_delete',
        reserveInfoModify: '/control/reserve_modify',
        reserveInfoList: '/control/reserve/list',
        reserveInfoDetail: '/control/reserve/detail',
        systemPopup: '/community/notice/main',
        systemPopupRead: '/community/notice/checkRead',
        smartEleList: '/SmartApp/StDeviceList',
        smartDeviceDetail: '/SmartApp/StDeviceStatus',
        controlSmartEleDevice: '/SmartApp/StDeviceExecute',
        getInstallAppId: '/SmartApp/StAppId',
        LG: {
            list: '/LgThinQ/LgDeviceList',
            profile: '/LgThinQ/LgDeviceProfile',
            status: '/LgThinQ/LgDeviceStatus',
            control: '/LgThinQ/LgDeviceExecute',
            tokenRefresh: '/LgThinQ/LgUserToken'
        },
        LG_Temp: {
            list: '/id',
            profile: '/profile',
            status: '/status',
            control: '/status'
        },
        convenienceComplexFacilities: '/convenience/complex/{houscplx_cd}/facilities',
        communityNoticeBlltNo: '/community/notice/{bllt_no}',
        faq: '/cm/faq',
        vocLanding: "/app/mobile",
        vocList: "/app/request/list",
        vocRegister: "/app/request/register",
        vocDetial: "/app/request/{request_id}/detail",
        vocFeedback: "/app/request/{request_id}/feedback",
        mallLanding: "/app/mobile?complexId={complexId}",
        mallHome: "/app/mall/{mall-id}",
        mallImage: "/app/mall/{mall-id}/image",
        mallSearch: "/app/mall/{mall-id}/search",
        mallEvent: "/app/mall/{mall-id}/coupon",
        mallNotice: "/app/mall/{mall-id}/notice",
        mallNoticeDetail: "/app/mall/{mall-id}/notice/{notice-id}",
        mallStoreOverview: "/app/mall/{mall-id}/store/{store-id}/overview",
        mallStoreImage: "/app/mall/{mall-id}/store/{store-id}/image",
        mallStoreMenu: "/app/mall/{mall-id}/store/{store-id}/menu",
        mallStoreMenuImage: "/app/mall/{mall-id}/store/{store-id}/menu/{menu-id}/image",
        mallStoreEvent: "/app/mall/{mall-id}/store/{store-id}/coupon",
        mallStoreCoupon: "/app/mall/{mall-id}/store/{store-id}/coupon/{coupon-id}"
    };

 

 

 

 

그래서 전기차 충전소 상태 조회는?

가장 필요했던 전기차 충전소 상태 조회 부분은 어떻게 사용되는지도 알 수 있었다. Charles를 통해서도 알 수 있었지만, 전기차 충전소 상태 조회가 특이하게도 POST method를 사용하고 있다. 더 이상한 것은 응답으로 201 Created를 리턴하며 body에는 ctl_id라는 값을 json 형식으로 주고있다는 점이다. 그런데 앱에서는 각 충전소들의 상태가 최신화된다. 분명 데이터를 받지 못했는데 상태가 최신화된다는 이야기는 event-driven 혹은 socket 통신, SSE 방식 등을 사용한다고 예측을 할 수 있었다.

아래 js module 중 전기차 충전소 상태 조회 관련 코드를 보면 Charles에서 확인했던 api를 비동기로 호출하는 postConvenienceChargingPointAsync를 볼 수 있다. 그러나 정작 상태를 받아 처리하는 로직은 receivePushMessage로 따로 정의되어있다. 그렇다면 receivePushMessage를 사용하는 부분에서 cp_sts_list를 어떻게 받아오는지를 확인해야하는데... 그 방법을 모르겠다.

__d(function (g, r, i, a, m, e, d) {
    var t = r(d[0]);
    Object.defineProperty(e, "__esModule", {
        value: !0
    }),
    e.default = void 0;
    var n,
        o,
        s,
        c = t(r(d[1])),
        u = t(r(d[2])),
        p = t(r(d[3])),
        l = t(r(d[4])),
        f = t(r(d[5])),
        h = (t(r(d[6])), r(d[7])),
        v = r(d[8]),
        P = t(r(d[9])),
        y = t(r(d[10])),
        b = t(r(d[11])),
        C = r(d[12]),
        _ = (
            n = (function () {
                function t() {
                    (0, p.default)(this, t),
                    (0, u.default)(this, "chargingPoints", o, this),
                    (0, u.default)(this, "isPending", s, this)
                }
                return (0, l.default)(t, [
                    {
                        key: "receivePushMessage",
                        value: function (t) {
                            var n = t.data.cp_sts_list,
                                o = [];
                            if (n) {
                                var s = (0, v.decode)(n);
                                o = JSON.parse(decodeURIComponent(escape(s)))
                            }
                            return this.chargingPoints = o,
                            this.isPending = !1,
                            o
                        }
                    }, {
                        key: "postConvenienceChargingPointAsync",
                        value: function () {
                            var t,
                                n,
                                o = this;
                            return c
                                .default
                                .async (function (s) {
                                    for (;;) 
                                        switch (s.prev = s.next) {
                                            case 0:
                                                return this.chargingPoints = [],
                                                this.isPending = !0,
                                                t = P
                                                    .
                                            default
                                                    .get(),
                                                n = t.certf_tp_cd,
                                                s.prev = 3,
                                                s.next = 6,
                                                c
                                                    .
                                            default
                                                    .awrap(y.default.post(b.default.convenienceChargingPoint, {certf_tp_cd: n}));
                                            case 6:
                                                setTimeout(function () {
                                                    return o.isPending = !1
                                                }, C.pushMessageTimeOut),
                                                s.next = 13;
                                                break;
                                            case 9:
                                                s.prev = 9,
                                                s.t0 = s.catch(3),
                                                this.isPending = !1,
                                                console.info('postConvenienceChargingPointAsync error :: ', s.t0);
                                            case 13:
                                            case "end":
                                                return s.stop()
                                        }
                                    }, null, this, [
                                    [3, 9]
                                ], Promise)
                        }
                    }
                ]),
                t
            })(),
            o = (0, f.default)(n.prototype, "chargingPoints", [h.observable], {
                configurable: !0,
                enumerable: !0,
                writable: !0,
                initializer: function () {
                    return []
                }
            }),
            s = (0, f.default)(n.prototype, "isPending", [h.observable], {
                configurable: !0,
                enumerable: !0,
                writable: !0,
                initializer: function () {
                    return !1
                }
            }),
            (0, f.default)(
                n.prototype,
                "receivePushMessage",
                [h.action],
                Object.getOwnPropertyDescriptor(n.prototype, "receivePushMessage"),
                n.prototype
            ),
            (0, f.default)(
                n.prototype,
                "postConvenienceChargingPointAsync",
                [h.action],
                Object.getOwnPropertyDescriptor(n.prototype, "postConvenienceChargingPointAsync"),
                n.prototype
            ),
            n
        );
    e.default = _
}, 1601, [
    1,
    2,
    1516,
    15,
    16,
    1517,
    1518,
    676,
    1507,
    1468,
    1506,
    1530,
    1595
]);

 

그래서 하나씩 시도해보기로 했다.

1. websocket: 401 unauthorized.

import asyncio
import aiohttp
import logging
import json
from typing import List

logging.basicConfig(level=logging.INFO)

class ChargingService:
    def __init__(self):
        self.charging_points = []
        self.is_pending = False

    def set_charging_points(self, new_points: List[dict]):
        self.charging_points = new_points
        logging.info(f"Charging points updated: {self.charging_points}")

    async def post_convenience_charging_point_async(self):
        self.is_pending = True
        try:
            async with aiohttp.ClientSession() as session:
                ws_url = "wss://svc.smartprugio.com:18888/v1/convenience/charging/point"
                headers = {
                    "authorization": {Basic token},
                    "token": {token}
                }
                async with session.ws_connect(ws_url, heartbeat=5, headers=headers) as ws:
                    logging.info("Connected to WSS and sent request.")

                    async for msg in ws:
                        if msg.type == aiohttp.WSMsgType.TEXT:
                            data = msg.json()
                            await self.receive_push_message(data)
                        elif msg.type == aiohttp.WSMsgType.ERROR:
                            logging.error(f"WebSocket error: {msg.data}")
                            break
        except Exception as e:
            logging.error(f"post_convenience_charging_point_async error: {e}")
            raise e
        finally:
            self.is_pending = False

    async def receive_push_message(self, message: dict):
        if 'data' in message and 'cp_sts_list' in message['data']:
            cp_sts_list = message['data']['cp_sts_list']
            decoded_data = await self.decode_data(cp_sts_list)
            self.set_charging_points(decoded_data)
            self.is_pending = False

    async def decode_data(self, data: str) -> List[dict]:
        return json.loads(data)


async def main():
    service = ChargingService()
    await service.post_convenience_charging_point_async()

if __name__ == "__main__":
    asyncio.run(main())

 

2. SSE: 400 Bad Request

import json
from sseclient import SSEClient

headers = {
    "Host": "svc.smartprugio.com:18888",
    "app_version": "1.2.0-v14",
    "content-type": "application/json",
    "Authorization": {Basic token},
    "accept-language": "ko-KR,ko;q=0.9",
    "user-agent": "Smart%20Home/212 CFNetwork/1568.200.51 Darwin/24.1.0",
    "token": {token}
}

data = {
    "certf_tp_cd": "KAKAO"
}

url = "https://svc.smartprugio.com:18888/v1/convenience/charging/point"

sse_client = SSEClient(
    url,
    headers=headers,
    data=json.dumps(data)
)

for event in sse_client:
    print(f"Received event: {event.data}")

 

 

 

대체 cp_sts_list는 어디서 어떻게 받아오는걸까..................

반응형