일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- Home Assistant
- MQTT
- 애플
- 나스닥
- cluster
- 티스토리챌린지
- homebridge
- Python
- 코스피
- Bestin
- 해외주식
- 홈네트워크
- Espressif
- 미국주식
- 매터
- Apple
- 공모주
- matter
- 힐스테이트 광교산
- 국내주식
- SK텔레콤
- 월패드
- 오블완
- RS-485
- ConnectedHomeIP
- 현대통신
- 배당
- 파이썬
- raspberry pi
- esp32
- Today
- Total
YOGYUI
LG ThinQ REST API::파이썬 연동 본문
Access LG ThinQ API using Python
지난 글에서 Homebridge에 LG ThinQ 디바이스를 연동하는 방법에 대해 알아본 바 있다
Homebridge - LG ThinQ 연동하기 (애플 홈 연동)
안타깝게도 내가 원하는 '로봇청소기'는 애플 홈킷의 Native 액세서리가 없어서 연동이 불가능했다 (짜증...)
결국 플러그인 소스코드를 깃허브에서 클론한 뒤에 Typescript로 짜여진 코드를 하나하나 읽으면서 ThinQ API 사용법을 분석하는 멍청한(?) 짓을 벌였다 ㅋㅋ
https://github.com/nVuln/homebridge-lg-thinq#readme
LG ThinQ 개발자로 등록하면 API 사용을 위한 ID와 KEY값들이 발급되는데, 일반 개인이 쉽게 등록할 수 없는 것 같아보여서 포기...
과정을 요약하면 다음과 같다
- API 접근을 위한 Access Token 값 얻기
- 서드파티 계정 로그인을 통해 Refresh Token값 추출
- API URI 획득
- 유저 고유번호 쿼리 및 클라이언트 ID 생성 - Home 및 Device 리스트 쿼리
- MQTT 브로커 접속 및 토픽 구독
전체 과정을 step-by-step으로 소개해보도록 한다
Python 초급자도 쉽게 따라할 수 있을 것으로 생각된다!
- 알고있어야 하는 사전 지식: dictionary 사용법, MQTT 개념
- 알아두면 (매우) 좋은 사전 지식: HTTP, REST API, request GET/POST, JSON
- 몰라도 상관없는 사전 지식: SSL/TLS, RSA, X509, HMAC, base64
※ 공식 API 문서를 참고해서 개발한 것이 아니라 다른 개발자의 코드를 포팅한 것이기 때문에 정확하지 않은 부분이 많을 것으로 추정됨 - 무지성으로 내가 원하는 기능만 동작할 수 있게 짠 코드.. ㅎㅎ;;
※ ThinQ V2는 아마존 웹 서비스(AWS)의 IoT Core 클라우드 서비스를 MQTT 브로커로 활용하고 있다 - AWS에 익숙한 개발자라면 다른 방식으로 브로커에 접속할 수 있을지도?
※ 한국(South Korea) 내 계정에 한해서 접근 방식을 다뤄보도록 한다.. 미국 등 다른 지역/언어 환경에서의 접근 방식을 알고 싶다면 댓글로 문의 ㄱㄱ (사실 지역 코드랑 언어 코드만 바꿔주면 되긴 함 ㅎㅎ)
1. 파이썬 패키지 설치 및 로드
파이썬 외부 패키지는 2개를 설치하면 된다 (OpenSSL, paho-mqtt)
$ pip install pyopenssl
$ pip install paho-mqtt
ThinQ API 사용에 필요한 패키지들을 다음과 같이 import해주자
(AWS IoT-Core 접속에 필요한 SSL 인증서들 때문에 이것저것 불러와야할 게 꽤 많긴 하다 ^^;)
import os
import hmac
import json
import base64
import random
import hashlib
import requests
import datetime
import email.utils
import urllib.parse
from OpenSSL import crypto
import paho.mqtt.client as mqtt
2. Refresh Token 값 얻기
LG 계정의 ID/비밀번호로 로그인하는 방법도 있지만, 나는 서드파티 인증 계정(구글)을 ThinQ에 물려서 사용하기 때문에 Refresh Token을 통한 Access Token을 발급받는 방법을 사용하기로 했다
아래 링크를 타고 가면 LG전자 API에 로그인할 수 있는 창이 뜬다 (한국 계정)
본인의 계정 (LG전자 계정 혹은 구글/페이스북 등 서드파티)으로 로그인하면
https://kr.m.lgaccount.com/login/iabClose?access_token={...}&refresh_token={...}&oauth2_backend_url=https://kr.lgeapi.com/
리디렉션된 URL의 파라미터로 access_token, refresh_token, oauth2_backend_url 3개의 값을 볼 수 있다
여기서 access_token이 LG ThinQ API에 접근할 때 사용되는 토큰인데, 유효기간(LG 계정은 3600초로 추정됨)이 있기 때문에 일정 주기로 토큰을 새로 발급받아야 하는데 이를 위해 사용되는 것이 바로 refresh_token 값이다
(refresh_token값은 각 개인 계정별로 고정된 값)
이 값을 글로벌 변수로 선언해두고 사용하도록 하자
REFRESH_TOKEN="url redirection에서 추출한 refresh_token 문자열"
3. 글로벌 변수 및 유틸리티 함수 선언
다음과 같이 전역(global) 문자열 상수를 선언해주도록 한다
GATEWAY_URL = "https://route.lgthinq.com:46030/v1/service/application/gateway-uri"
API_KEY = "VGhpblEyLjAgU0VSVklDRQ=="
API_CLIENT_ID = "c713ea8e50f657534ff8b9d373dfebfc2ed70b88285c26b8ade49868c0b164d9"
CLIENT_ID = "LGAO221A02"
OAUTH_SECRET_KEY = "c053c2a6ddeb7ad97cb0eed0dcb31cf8"
OAUTH_CLIENT_KEY = "LGAO722A02"
APPLICATION_KEY = '6V1V8H2BN5P9ZQGOI5DAQ92YZBDO3EK9'
COUNTRY_CODE = "KR"
LANGUAGE_CODE = "ko-KR"
이 값들은 OAuth 인증할 때 혹은 API에 접근할 때 HTTP 헤더에 쓸 파라미터로 활용된다
그리고 인증 후 발급받는 access_token과 같은 값들을 저장하기 위한 전역 문자열 변수도 선언해주자
g_client_id: str = None
g_access_token: str = None
g_expires_in: int = None
g_user_number: str = None
마지막으로 request 시 사용할 헤더 딕셔너리를 만들어주는 함수와, oauth signature 문자열을 만들어주는 유틸리티 함수도 정의해준다
def get_default_headers() -> dict:
def random_string(length: int) -> str:
characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
result = ''
for i in range(length):
result += characters[random.randint(0, len(characters) - 1)]
return result
headers = {
'x-api-key': API_KEY,
'x-thinq-app-ver': '3.6.1200',
'x-thinq-app-type': 'NUTS',
'x-thinq-app-level': 'PRD',
'x-thinq-app-os': 'ANDROID',
'x-thinq-app-logintype': 'LGE',
'x-service-code': 'SVC202',
'x-country-code': COUNTRY_CODE,
'x-language-code': LANGUAGE_CODE,
'x-service-phase': 'OP',
'x-origin': 'app-native',
'x-model-name': 'samsung/SM-G930L',
'x-os-version': 'AOS/7.1.2',
'x-app-version': 'LG ThinQ/3.6.12110',
'x-message-id': random_string(22),
'user-agent': 'okhttp/3.14.9'
}
headers['x-client-id'] = API_CLIENT_ID if g_client_id is None else g_client_id
if g_user_number is not None:
headers['x-user-no'] = g_user_number
if g_access_token is not None:
headers['x-emp-token'] = g_access_token
return headers
def get_signature(message: str, key: str):
hmac_obj = hmac.new(key.encode(encoding='utf-8'), message.encode(encoding='utf-8'), hashlib.sha1)
hashed = hmac_obj.digest()
b64_encoded = base64.b64encode(hashed)
return b64_encoded.decode(encoding='utf-8')
headers 딕셔너리 생성 구문을 보면 유추할 수 있겠지만, Homebridge의 LG ThinQ API를 깃허브에 올린 개발자는 안드로이드의 ThinQ 앱을 디컴파일링했거나 혹은 스마트폰의 네트워크 HTTP 패킷을 후킹한 후 결과물을 사용한 것으로 추측된다 ㅋㅋㅋ (나름 능력자인 셈..) 이 사람이 개발 당시에 어떤 모바일 기기를 쓰고 있었는지까지 알 수 있네 (SM-G930L은 갤럭시 S7이다)
※ 스마트폰 어플리케이션 레벨에서 만들어지는 헤더를 추출해서 사용하는 거라 크게 문제가 될 것 같지는 않은데, LG전자가 이 글을 보면 별로 좋아할 것 같지는 않다 -_- 우리는 감사하게 사용하도록 하자
Special Thanks to nVuln~
4. ThinQ API URI 가져오기
제일 먼저 ThinQ API의 URI(Uniform Resource Identifier, 통합 자원 식별자)를 알아내야 한다
전역 상수로 선언해둔 GATEWAY_URL (https://route.lgthinq.com:46030/v1/service/application/gateway-uri) 에 default header를 만들어서 GET request 해보자
thinq1Uri, thinq2Uri = None, None
response = requests.get(GATEWAY_URL, headers=get_default_headers())
if response.status_code == 200:
response_json = json.loads(response.text)
result = response_json.get('result')
thinq1Uri = result.get('thinq1Uri')
thinq2Uri = result.get('thinq2Uri')
응답(response)의 상태 코드가 200 (=정상)이라면 응답은 JSON 문자열이므로, json 패키지의 loads 함수로 딕셔너리로 변환해주자
In [1]: response_json
Out[1]:
{'resultCode': '0000',
'result': {'countryCode': 'KR',
'languageCode': 'ko-KR',
'thinq1Uri': 'https://kic.lgthinq.com:46030/api',
'thinq2Uri': 'https://kic-service.lgthinq.com:46030/v1',
'empUri': 'https://kr.m.lgaccount.com',
'empSpxUri': 'https://kr.m.lgaccount.com/spx',
'rtiUri': 'kic.lgthinq.com:47878',
'mediaUri': 'media.lgthinq.com:47800',
'appLatestVer': '4.1.26030',
'appUpdateYn': 'N',
'appLink': 'market://details?id=com.lgeha.nuts',
'nestSupportAppVer': '',
'uuidLoginYn': 'Y',
'lineLoginYn': 'N',
'lineChannelId': '',
'cicTel': '1544-7777',
'cicUri': '',
'isSupportVideoYn': 'Y',
'countryLangDescription': '대한민국/한국어',
'empTermsUri': 'https://kr.emp.lgsmartplatform.com',
'googleAssistantUri': 'https://assistant.google.com/services/invoke/uid/000000d26892b8a3',
'smartWorldUri': 'http://kr.lgworld.com/mall/mobile.main.dev?referUrl=ThinQ',
'racUri': 'kr.rac.lgeapi.com',
'cssUri': 'https://kic-common.lgthinq.com',
'cssWebUri': 'http://s3-an2-op-t20-css-web-resource.s3-website.ap-northeast-2.amazonaws.com',
'iotssUri': 'https://kic-iotservice.lgthinq.com',
'chatBotUri': 'https://chatbot.lgthinq.com',
'autoOrderSetUri': 'http://kr.lgworld.com/mall/mobile.product.RetrieveProdCategoryList.dev?referUrl=ThinQ&initCategoryType=AUTO',
'autoOrderManageUri': 'http://kr.lgworld.com/mall/mobile.mypage.RetrieveAutoRequestList.dev?referUrl=ThinQ',
'aiShoppingUri': 'https://m.prod.homeiot.lge.com/member/loginCheck.do',
'onestopCall': '1544-7777',
'onestopEngineerUri': 'https://svcthinq.com/svcthinq001.html',
'hdssUri': 'https://hdss.lgthinq.com:47040',
'amazonDrsYn': 'N',
'features': {'supportTvIoTServerYn': 'Y',
'iotHejhomeYn': 'Y',
'aiShoppingYn': 'Y',
'csVideoReserveProdCode': 'LA01,LA02,LA05,LA03,LA06,LA07,LA04,LI01,LI06,LI04,LI05,TV,LI03,KI01,KI0101,KI0102,KI0103,KI0104,KI02,KI0201,KI0202,KI10,KI03,KI04,KI08,KI07,KI05,KI12,KI06,AI01,RAC,PAC,POT,WIN,AI04,CST,DUCT,SPAC,SRAC,CVT,CSL,AI05,AWHP,LI02,AI02,AI03,AI07,AI12,SB01,SB07,SB02,SB04,SB03,SB05',
'csEngineerReserveProdCode': 'LA01,LA02,LA05,LA03,LA06,LA07,LA04,LI01,LI06,LI04,LI05,TV,LI03,KI01,KI0101,KI0102,KI0103,KI0104,KI02,KI0201,KI0202,KI10,KI03,KI04,KI08,KI07,KI05,KI12,KI06,AI01,RAC,PAC,POT,WIN,AI04,CST,DUCT,SPAC,SRAC,CVT,CSL,AI05,AWHP,LI02,AI02,AI03,AI07,AI12,SB01,SB07,SB02,SB04,SB03,SB05',
'careAirconRezSetting': 'Y',
'csContactMenuYn': 'Y',
'homeWhitePaper': 'Y',
'upgradeBetaYn': 'Y',
'androidAutoYn': 'Y',
'upgradeCenterYn': 'Y',
'searchYn': 'Y',
'careEnergyHome': 'Y',
'csMyPageYn': 'Y',
'thinqFaq': 'Y',
'careHomeApplianceLife': 'Y',
'careCardFeature': 'Y',
'upgradeIdeaYn': 'Y',
'thinqQuickguide': 'N',
'eventAutoScrollYn': 'Y',
'supportBleYn': 'Y',
'pccWarrantyProd2': '101,102,103,104,105,106,201,202,203,204,206,222,301,302,303,304,401,402,403,405,406,407,408,409,410,501,504,601,603,10000,20408',
'pccPushProd': '101,102,103,201,202,203,204,222,301,302,303,401,402,501,504',
'thinqCss': 'Y',
'pccRegisterProd': '101,102,103,104,105,106,201,202,203,204,222,301,302,303,304,401,402,403,405,406,407,408,409,501,504,601,603',
'careLabInquiryPush': 'Y',
'csCallReserveProdCode': 'LA01,LA02,LA05,LA03,LA06,LA07,LA04,LI01,LI06,LI04,LI05,TV,LI03,KI01,KI0101,KI0102,KI0103,KI0104,KI02,KI0201,KI0202,KI10,KI03,KI04,KI08,KI07,KI05,KI12,KI06,AI01,RAC,PAC,POT,WIN,AI04,CST,DUCT,SPAC,SRAC,CVT,CSL,AI05,AWHP,LI02,AI02,AI03,AI07,AI12,SB01,SB07,SB02,SB04,SB03,SB05',
'tvRcmdContentYn': 'Y',
'careSolution': 'Y',
'csVisitReserveProd': '301,302,303,408,501,504,1001,3001,4001,4003,4004,10000',
'csRemoteReserveProd': '10000',
'csVisitReserveProdCode': 'LI01,LI06,LI04,TV,LI03,KI03,KI04,KI07,LI02,AI02,AI03,AI12,SB02,SB03,BMASK,BAC,BAPMINI',
'pccQrProd': '401',
'awhpWidgetFeatureYn': 'Y',
'pccMyPageYn': 'Y',
'automationYn': 'Y',
'careWikiCard': 'Y',
'bannerYn': 'Y',
'careEnergyMonitoringKepco': 'Y',
'laboratoryYn': 'Y',
'brazeYn': 'Y',
'thinqNotice': 'Y',
'rentalCareSolution': 'Y',
'careSmartDiagCard': 'Y',
'pccNoticeAgreement': 'N',
'inAppReviewYn': 'Y',
'cicSupport': 'Y',
'pccReceiptRegister': 'Y',
'googleAssistant': 'N',
'upSuggestion': 'Y',
'qrRegisterYn': 'Y',
'careCampaign': 'Y',
'bleConfirmYn': 'Y',
'awhpWidgetYn': 'N',
'qnaSatisYn': 'N',
'chatBotSupport': 'Y',
'thinqMall': 'Y',
'hideBannerYn': 'Y',
'groupControlYn': 'Y',
'csMovingReserveProd': '101,102,103,201,202,203,221,222,401,10000',
'autoOrderYn': 'Y',
'wifiInfoFeature': 'Y',
'pccWarrantyProd': '101,102,103,104,105,106,201,202,203,204,222,301,302,303,304,401,402,403,405,406,407,408,409,501,504,601,603',
'takeATourSupport': 'Y',
'onestopCallbyProductId': '{"Common":"1544-7777","303":"1544-7777","401":"1544-7777","402":"1544-7777","201":"1544-7777","202":"1544-7777","203":"1544-7777","221":"1544-7777","222":"1544-7777","501":"1544-7777","204":"1544-7777","101":"1544-7777","102":"1544-7777","103":"1544-7777"}',
'careEnergy': 'Y',
'careModelJson': 'Y',
'careLabApiPaging': 'Y',
'disableWeatherCard': 'N',
'pccRegAPIVer': '2',
'pccModelCategory': 'Y',
'supportProductManualYn': 'N',
'smartlifeProductExtension': 'Y',
'privacyValidityYn': 'Y',
'pccRegisterProd2': '101,102,103,104,105,106,201,202,203,204,206,222,301,302,303,304,401,402,403,405,406,407,408,409,410,501,504,601,603,10000,20408',
'pccPush': 'Y',
'careContentCard': 'Y',
'clientDbYn': 'Y',
'pccWarranty': 'Y',
'careLabPhase2': 'Y',
'csRemoteReserveProdCode': 'TV,LI03',
'kakaoLoginYn': 'Y',
'careInsightCard': 'N',
'isGoqualTermsYn': 'Y',
'careSuggestion': 'N',
'dmpCollectingYn': 'N',
'smartScanWineHelpYn': 'N',
'pccWarrantyPeriodType': 'Y',
'careService': 'Y',
'careSmartDiag': 'Y',
'csMovingReserveProdCode': 'LA01,LA02,LA05,LA03,LA06,LA07,LA04,TV,LI03,KI01,KI0101,KI0102,KI0103,KI0104,KI02,KI0201,KI0202,KI05,AI01,RAC,PAC,POT,WIN,AI04,CST,DUCT,SPAC,SRAC,CVT,CSL,AI05,AWHP,AI07,SB01'},
'serviceCards': [],
'uris': {'csReserveListUri': 'https://svcthinq.com/support/kr/service-inquiry-reservation-status-plural.html',
'csVisitReserveUri': 'https://svcthinq.com/support/kr/visit-center-reservation.html',
'onboardUri': 'https://thinq-onboard-dashboard.lgthinq.com',
'csCallReserveUri': 'https://svcthinq.com/support/kr/request-call-reservation.html',
'csFindServiceCenterUri': 'https://www.lge.co.kr/support/find-service-center?Thinq_cs=y',
'gscsUri': 'https://gscs.lge.com',
'tlpUri': 'https://kic-tlp.lgthinq.com',
'hjhomeUrl': 'https://goqual.io/auth/authorize?response_type=code&scope=thinq&redirect_uri=https://kic-iotservice.lgthinq.com/redirect/externals/hejhome/accesstoken',
'productManualUri': 'https://www.lge.co.kr/support/product-manuals?title=manual',
'storeOrderUri': 'https://kr.lgworld.com',
'rewardUri': 'https://kr-front.qreward.lge.com',
'amazonDartUri': 'https://shs.lgthinq.com',
'csFindModelNumberUri': 'https://www.lge.co.kr/support/find-model-number',
'csVideoReserveUri': 'https://svcthinq.com/support/kr/video-consulting-reservation.html',
'upsUri': 'https://kic-ups.lgthinq.com',
'aiShoppingGetProductUri': 'https://m.prod.homeiot.lge.com/food/foodAppPrdRcv.do',
'takeATourUri': 'https://s3-an2-op-t20-css-contents.s3.ap-northeast-2.amazonaws.com/workexperience-new/ios/no-version/index.html',
'csReserveListManualUri': 'https://svcthinq.com/support/kr/service-inquiry-reservation-status.html',
'homecareUri': 'https://kic.hlp.lgthinq.com/homecare',
'careSmartDiagUri': 'https://kic-hcss.lgthinq.com:47040',
'hdssUri': 'https://hdss.lgthinq.com:47040',
'quickguideUri': 'https://s3-an2-op-t20-css-contents.s3.ap-northeast-2.amazonaws.com/quickguide/index.html',
'tasteUri': 'https://prod-main.thinqtaste.lge.com',
'recipeUri': 'https://www.sidechef.com/account/signup/?next=/lg/&redirect=1',
'smartFoodStoreOrderUri': 'https://api.prod.homeiot.lge.com',
'csMovingReserveUri': 'https://m.lxpantos.com/kr/lg-electronics-installation.do',
'csRemoteReserveUri': 'https://svcthinq.com/support/kr/remote-call-reservation.html',
'csReserveItemUri': 'https://svcthinq.com/support/kr/service-inquiry-reservation-status-single.html',
'storeOrderHistoryUri': 'http://kr.lgworld.com/mall/mobile.mypage.RetrieveOrderSearchList.dev?referUrl=ThinQ',
'csEngineerReserveUri': 'https://svcthinq.com/support/kr/service-engineer-reservation.html'}}}
담고있는 정보가 굉장히 많다... (어질어질)
이 중에 우리가 사용할 URI는 'thinq1Uri'과 'thinq2Uri' 2개 뿐!
In [2]: thinq1Uri
Out[2]: 'https://kic.lgthinq.com:46030/api'
In [3]: thinq2Uri
Out[3]: 'https://kic-service.lgthinq.com:46030/v1'
5. ThinQ OAuth URI 가져오기
다음으로 LG전자 API의 OAuth 인증 URI를 가져오자 (4. 에서 가져온 thinq1Uri 주소를 사용)
별도의 헤더와 파라미터를 이용한 POST request를 해야한다
oauthUri = None
url = thinq1Uri + '/common/gatewayUriList'
headers = {
'Accept': 'application/json',
'x-thinq-application-key': 'wideq',
'x-thinq-security-key': 'nuts_securitykey'
}
data = {
'lgedmRoot': {
'countryCode': COUNTRY_CODE,
'langCode': LANGUAGE_CODE
}
}
response = requests.post(url, json=data, headers=headers)
if response.status_code == 200:
response_json = json.loads(response.text)
lgemdRoot = response_json.get('lgedmRoot')
oauthUri = lgemdRoot.get('oauthUri')
In [4]: response_json
Out[4]:
{'lgedmRoot': {'returnCd': '0000',
'returnMsg': 'OK',
'thinqUri': 'https://kic.lgthinq.com:46030/api',
'empUri': 'https://kr.m.lgaccount.com',
'contentsUri': 'https://kic.lgthinq.com:46030/api',
'rtiUri': 'kic.lgthinq.com:47878',
'cicTel': '1544-7777',
'oauthUri': 'https://kr.lgeapi.com',
'appLatestVer': '3.0.1408001',
'appLinkAndroid': 'market://details?id=com.lgeha.nuts',
'appLinkIos': 'https://itunes.apple.com/app/id993504342',
'appUpdateYn': 'Y',
'empOauthErrorYn': 'N',
'empOauthDetourUri': 'null?languageCode=ko',
'imageUri': 'https://kic.lgthinq.com:46030/api/webContents/imageDownload',
'langPackIntroVer': 1.4,
'langPackIntroUri': 'https://kic.lgthinq.com:46030/api/webContents/moduleDownload?type=langPack/IP/LP2015072400000381.json&fileName=IP_LANG_KO_KR_VER_1_4_5.json&authKey=thinq',
'showYn': 'N',
'showLocalPushYn': 'N',
'mediaUri': 'media.lgthinq.com:47800',
'isSupportVideoYn': 'Y',
'langPackCommonVer': 151.8,
'langPackCommonUri': 'https://kic.lgthinq.com:46030/api/webContents/moduleDownload?type=langPack/CP/CP_LANG_KO-KR_VER_151.8_NUTS.json&fileName=CP_LANG_KO-KR_VER_151.8_NUTS&authKey=thinq',
'countryCode': 'KR',
'langCode': 'ko-KR',
'countryLangDescription': '대한민국/한국어',
'uuidLoginYn': 'Y',
'lineLoginYn': 'N'}}
response를 json dict형으로 변환한 뒤 'lgedmRoot' 값에 할당된 dict를 보면 역시나 잡다한 정보가 많이 들어있다 (thinq 앱 다운로드할 수 있는 앱스토어 링크 주소 등도 볼 수 있다)
여기서 우리가 가져올 값은 'oauthUri'
In [5]: oauthUri
Out[5]: 'https://kr.lgeapi.com'
POST시 데이터로 입력하는 국가/언어 코드에 따라 다른 URI가 리턴될 것으로 추정된다
(한국 코드를 넣었으니 당연히 도메인에 kr이 들어간다)
6. Access Token 가져오기
OAuth URI를 가져왔으니, 이제 2. 에서 저장해둔 refresh token을 활용해서 새 access token 값을 발급받을 수 있다
g_access_token, g_expires_in = None, None
tokenUrl = oauthUri + '/oauth/1.0/oauth2/token'
data = {
'grant_type': 'refresh_token',
'refresh_token': REFRESH_TOKEN
}
requestUrl = '/oauth/1.0/oauth2/token' + '?' + urllib.parse.urlencode(data)
now = datetime.datetime.utcnow()
timestamp = email.utils.format_datetime(now) # RFC2822 format
signature = get_signature(f"{requestUrl}\n{timestamp}", OAUTH_SECRET_KEY)
headers = {
'x-lge-app-os': 'ADR',
'x-lge-appkey': CLIENT_ID,
'x-lge-oauth-signature': signature,
'x-lge-oauth-date': timestamp,
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
}
response = requests.post(tokenUrl, params=data, headers=headers)
if response.status_code == 200:
response_json = json.loads(response.text)
g_access_token = response_json.get('access_token')
g_expires_in = int(response_json.get('expires_in'))
다른 과정들에 비해 중간 과정이 복잡하니 자세히 살펴보자
우선 refresh_token 정보가 포함된 oauth signature 문자열을 만들어줘야 한다
(1) 현재 시간의 타임스탬프를 RFC2822 포맷(이메일 메시지 규격)으로 만들어주자
In [6]: timestamp
Out[6]: 'Fri, 28 Oct 2022 00:24:02 -0000'
이 후 refresh token 값이 포함된 URL 인코딩된 문자열을 만들어준 뒤, 3.에서 선언한 signature 문자열 생성 함수 get_signature 를 활용하면 된다
이제 토큰 발급 URL (한국 계정에서는 https://kr.lgeapi.com/oauth/1.0/oauth2/token)에 인증관련 헤더정보를 포함하여 POST request하면
새로 발급된 access token값과 만료 기한(expires_in) 정보를 가져올 수 있다
두 값을 각각 3.에서 선언해둔 전역 변수 g_access_token 및 g_expires_in에 대입한 뒤 뒤에 이어질 과정에서 사용하도록 하자
※ access_token 발급 시점과 만료 기한을 사용해 토큰 재발급 자동화를 구현할 수 있다
이 글에서는 AWS IoT Core MQTT Broker에 접속한 후에는 토큰이 더 이상 필요하지 않기 때문에 관련 구현은 생략하도록 한다
7. 유저 고유번호 (User Number) 가져오기
6. 과정과 유사한 코드를 통해 OAuth URI에서 계정 정보(profile)를 쿼리할 수 있다 (6.에서 발급받은 access token 값 필요!)
g_user_number = None
url_profile = oauthUri + '/users/profile'
now = datetime.datetime.utcnow()
timestamp = email.utils.format_datetime(now) # RFC2822 format
signature = get_signature(f"/users/profile\n{timestamp}", OAUTH_SECRET_KEY)
headers = {
'Accept': 'application/json',
'Authorization': 'Bearer ' + g_access_token,
'X-Lge-Svccode': 'SVC202',
'X-Application-Key': APPLICATION_KEY,
'lgemp-x-app-key': CLIENT_ID,
'X-Device-Type': 'M01',
'X-Device-Platform': 'ADR',
'x-lge-oauth-date': timestamp,
'x-lge-oauth-signature': signature
}
response = requests.get(url_profile, headers=headers)
if response.status_code == 200:
response_json = json.loads(response.text)
account = response_json.get('account')
g_user_number = account.get('userNo')
LGE 홈페이지에 등록된 개인 신상정보 (이름, 나이, 성별, 생년월일, 주소, 휴대전화 번호 등등) 뿐만 아니라 계정 정보 (LG 공식 계정, 애플/구글/네이버 등 서드파티 계정 등) 등 개인정보 일체를 확인할 수 있다
blacklist 관리도 하는 걸 알 수 있다 ㄷㄷㄷ...
이 중 앞으로 필요한 값은 'userNo' (고유한 client ID를 생성하는 데 사용)
이 값을 g_user_number에 할당하도록 하자
※ userNo 값은 사용자 개별로 고유한 값이므로, 이 값을 알아냈다면 7. 과정은 두 번 다시 호출할 필요가 없다
8. 클라이언트 ID (Client ID) 생성하기
고유 클라이언트 ID는 별도의 request 없이 SHA256 해시 알고리즘으로 인코딩한 64바이트 문자열을 사용하면 된다 (7.에서 얻은 g_user_number 활용)
obj_hash = hashlib.sha256()
now = int(datetime.datetime.now().timestamp())
obj_hash.update((g_user_number + f'{now}').encode(encoding='utf-8'))
g_client_id = obj_hash.hexdigest()
이제 마침내 ThinQ API로 내 디바이스들에 접근할 수 있는 준비 완료! 헥헥...
(get_default_headers 함수를 보면 지금까지 가져온 g_client_id, g_user_number, g_access_token이 활용되는 것을 알 수 있다)
9. ThinQ Home 리스트 및 Home별 등록된 Device 리스트 조회
ThinQ 앱에 등록된 '나의 집'은 집 개별로 고유한 ID를 갖는데, 이 값을 가져와보자
(4. 에서 가져온 thing2Uri 가 필요하다)
home_id_list = list()
url_get_home = thinq2Uri + '/service/homes'
response = requests.get(url_get_home, headers=get_default_headers())
if response.status_code == 200:
response_json = json.loads(response.text)
result = response_json.get('result')
item_list = result.get('item')
home_id_list = [x.get('homeId') for x in item_list]
등록한 집이 여러개라면 'result' - 'item' 리스트에 요소가 여러 개일 것이다
각 아이템별로 집의 고유 아이디인 homeId 값이 있으므로 이를 따로 리스트로 저장해두자 (home_id_list)
이제 Home 각 개별로 내부에 등록된 기기(device) 목록을 얻어와보자
dev_list = list()
for home_id in home_id_list:
url_get_device = thinq2Uri + '/service/homes/' + home_id
response = requests.get(url_get_device, headers=get_default_headers())
if response.status_code == 200:
response_json = json.loads(response.text)
result = response_json.get('result')
dev_list.extend(result.get('devices'))
이 중 하나의 device 객체(dict)를 살펴보자
In[7]: dev_list[8]
Out[7]:
{'deviceId': 'd6c1a158-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'deviceType': 501,
'modelName': 'R48IH',
'subModelNm': None,
'sensorType': None,
'alias': '물걸레 로봇청소기',
'deviceCode': 'LI06',
'networkType': '02',
'tftYn': 'N',
'guideTypeYn': 'Y',
'guideType': 'TYPE1',
'pccModelYn': 'N',
'autoOrderYn': 'N',
'drServiceYn': 'N',
'ssid': 'SEUNGHEE_2G',
'timezoneCode': 'Asia/Seoul',
'timezoneCodeAlias': 'Korea/Seoul',
'sdsGuide': '{"deviceCode":"LI06"}',
'newRegYn': 'N',
'remoteControlType': '',
'fareTarget': None,
'area': '1850060',
'sleep': None,
'deviceState': 'E',
'rmsClientId': None,
'regDtUtc': '20210208012229000',
'regIndex': 0,
'blackboxYn': 'Y',
'groupableYn': 'N',
'controllableYn': 'N',
'combinedProductYn': 'N',
'masterYn': 'Y',
'snapshot': {'REPEAT': 'true',
'VOLUME_LEVEL': 'HIGH',
'MODE': 'EDGE',
'HOMETAILOR': {'Cmd': 'Res_VW', 'NUM': '0'},
'mid': 7078071204.0,
'CAMERA_LOCK': 'false',
'REFRESH_DIARY_LIST': '19:50:26',
'fwCount': 1.0,
'LANGUAGE_LIST': {'Description': 'Language order',
'Language': 'ko-KR',
'UI': {'Repeat': 0.0,
'Brightness': 4.0,
'Mute': 0.0,
'WaterSupply': 3.0,
'CameraLock': 1.0,
'Mode': 0.0},
'TurnDistance': 1.0,
'Volume': 3.0,
'SleepMode': 0.0,
'LWH': 0.0,
'WARNING': 'Do not modify format and json key name in this file!!!!!!!!!!!!!!!!!!!!',
'SNDDATA': ['ko-KR', 'en-US', 'zh-TW', 'ru-RU']},
'ROBOT_STATE': 'SLEEP',
'fwStatus': 'complete',
'UI_BRIGHTNESS_LEVEL': '3',
'fwCurrentCount': 0.0,
'UTC_SLEEPMODE': 1666789491.0,
'CLEANING_RSV_ALL': {'LIST': [], 'NUM': '0'},
'UPDATE': 'COMPLETE',
'MAP_UPLOAD_STATE': 'READY',
'LWH': 'false',
'timestamp': 1666789491898.0,
'LANGUAGE': 'ko-KR',
'PUBLICKEY': 'true',
'SUPPORT_SKILLS': {'HOMEGUADRD_LOC_INFO': 'false',
'HOMETAILOR_VA': 'false',
'CLEAN_LOC_INFO': 'false',
'MAP_MERGE': 'false',
'MY_VOICE': 'false',
'MAP_DIVIDE': 'false'},
'BATT': '5',
'DIAGNOSIS': {'RESPONSE': {'TOTAL_NUM': 1.0, 'RESULT_LIST': ['RESULTOK']}},
'UPDATE.DownloadProgress': '0',
'static': {'deviceType': '501', 'countryCode': 'KR'},
'SLEEP_MODE': 'true',
'fwCurrentFileSize': 0.0,
'ROBOT_EVENT': 'NO_EVENT',
'CLEANING_RSV': 'false',
'TURN_DISTANCE': 'DIST_SHORT',
'HOMEGUARD_RSV': 'false',
'HOME_MON': {'RESERVATION': {'MINUTE': '30',
'SET': 'OFF',
'DAY': 'ONCE',
'HOUR': '10'},
'ISSET': {'isLocate': 'false'},
'LOCATE': {'X': '6282', 'Y': '-3225', 'Theta': '102', 'Result': 'false'}},
'fwUpgradeMode': 'background',
'meta': {'allDeviceInfoUpdate': False,
'messageId': '8y6bOKr9TyqwQrXbTfu-Yg'},
'BGFOTA_STATE': 'IDLE',
'VERSION': '78330',
'online': True,
'WATERSUPPLY_LEVEL': 'HIGH',
'MUTE': 'false'},
'manufacture': None,
'online': True,
'platformType': 'thinq2',
'homeDeviceOrder': 5,
'roomDeviceOrder': 2,
'ownershipYn': 'Y',
'modelJsonVer': '8.2',
'modelJsonUri': 'https://objectcontent.lgthinq.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?hdnts=exp=1720941875~hmac=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'appModuleVer': '13.07',
'appModuleUri': 'https://objectcontent.lgthinq.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?hdnts=exp=1694737357~hmac=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'appRestartYn': 'Y',
'appModuleSize': '13535731',
'langPackProductTypeVer': '69.9',
'langPackProductTypeUri': 'https://objectcontent.lgthinq.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?hdnts=exp=1729670887~hmac=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'langPackModelVer': None,
'langPackModelUri': None,
'roomId': 'xxxxxxxxxxxxxxxxxx',
'fwInfoList': [{'checksum': '000131FA',
'order': 1.0,
'partNumber': 'R48IHxxxxxx'}],
'modemInfo': {'appVersion': 'clip_spi_v1.9.136',
'modelName': 'R48IH',
'modemType': 'RTK_RTL8711am_SPI',
'ruleEngine': 'y'},
'existsEntryPopup': 'N',
'fwVer': None,
'modemVer': None,
'subDeviceCount': 0,
'firebaseLogKey': 'T:RB-P:M9-SP:M9',
'cardType': 'Small',
'cardControl': 'Base',
'detailDeviceCode': None,
'upgradableYn': 'N',
'autoFwDownloadYn': 'N'}
고유값은 xxxx로 마스킹
물걸레 로봇청소기에 대한 정보를 담고 있는데... 놀랍도록 많은 정보가 담겨있다
(API 문서가 없으니 정확히 뭔지 다 파악하지도 못하겠다 ㅋㅋ)
'alias' 키의 값이 내가 ThinQ 앱에 등록한 디바이스의 명칭이니 그나마 디바이스별로 구분이 가능한 정도?
특히 'snapshot' 키에 기입된 dict형을 보면 "ROBOT_STATE", "MODE", "CAMERA_LOCK" 등 ThinQ 앱을 통해 제어가 가능한 항목들이 어떤 것들이 있는지 확인할 수 있다
- 'BATT' 값이 완충 상태에서 5인 걸 보니 배터리 용량은 5단계로 나누어둔 것 같네~
- 로봇 청소기의 deviceType 값은 501
- 'LOCATE'를 보면 청소기가 그린 집안 지도(Map)에서 청소기의 현재 상대 위치를 X, Y, Theta 3 자유도로 알 수 있다
- 이 외에도 요리조리 살펴보면 재밌는 정보가 많다...
추후 MQTT를 통해 기기를 제어 혹은 모니터링할 때 필요한 건 'deviceId' 값이니 자신의 device의 고유 ID가 뭔지 정도는 이 단계에서 확실히 기록해두고 다음 스텝으로 넘어가도록 하자
10. MQTT Broker 접속 후 구독(Subscribe)
앞서 언급했듯이 ThinQ v2 기기들은 아마존 웹 서비스의 AWS IoT Core 클라우드를 MQTT 브로커로 사용하고 있다
9. 과정까지 진행하면서 얻어 놓은 _client_id, g_user_number, g_access_token 값들로 헤더를 생성한 뒤 MQTT 서버 정보를 가져와보자
url = "https://common.lgthinq.com/route"
response = requests.get(url, headers=get_default_headers())
if response.status_code == 200:
response_json = json.loads(response.text)
result = response_json.get('result')
mqttserver = result.get('mqttServer')
mqttServer의 문자열이 바로 AWS IoT 엔드포인트(endpoint) 주소! (당연히 사용자별로 주소는 독립적이다)
AWS는 SSL/TLS 보안 접속이 필수이므로 OpenSSL로 RSA 공개 키(public key)와 개인 키(private key)를 만들고, X.509 CSR을 만들어 서버에 인증을 요청한 뒤 인증서를 받는 과정이 선행되어야 한다
AWS를 다뤄본 개발자라면 잘 알고 있는 사실
파이썬에서도 해당 과정을 코드로 짤 수 있는데, 이에 대해 알아보도록 하자
※ 과정별로 내용을 자세히 소개하면 글이 너무 길어질 것 같아 간단하게 코드와 결과물만 소개하도록 한다
(사실 OpenSSL을 포팅해온거라, OpenSSL 사용에 익숙하다면 굳이 파이썬으로 구현할 필요가 없다 ㅎㅎ...)
10.1. AWS Root CA 인증서 저장
AWS에서 Root CA 인증서를 로컬에 저장해두자 (한번만 저장하면 된다)
https://www.amazontrust.com/repository/AmazonRootCA1.pem
- 위 URL의 결과물 텍스트를 로컬에 파일로 저장
aws_root_ca_path = './aws_root_ca.pem'
if not os.path.isfile(aws_root_ca_path):
rootCAUrl = 'https://www.amazontrust.com/repository/AmazonRootCA1.pem'
response = requests.get(rootCAUrl)
if response.status_code == 200:
rootca_pem = response.text
with open(aws_root_ca_path, 'w') as fp:
fp.write(rootca_pem)
10.2. 로컬 인증서 파일 (PEM) 생성
길이 2048의 RSA 키 쌍(public - private)을 생성한 뒤, public key를 기반으로 서버에 인증 요청을 위한 X.509 CSR 파일(SHA256 알고리즘)까지 한번에 생성해주자 (매번 새로 생성할 필요 없음)
rsa_pubkey_path = './rsa_pubkey.pem'
rsa_privkey_path = './rsa_privkey.pem'
aws_csr_path = './aws_csr.pem'
if not os.path.isfile(rsa_pubkey_path) or not os.path.isfile(rsa_privkey_path):
keypair = crypto.PKey()
keypair.generate_key(crypto.TYPE_RSA, 2048)
pubkey_pem = crypto.dump_publickey(crypto.FILETYPE_PEM, keypair).decode(encoding='utf-8')
with open(rsa_pubkey_path, 'w') as fp:
fp.write(pubkey_pem)
privkey_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair).decode(encoding='utf-8')
with open(rsa_privkey_path, 'w') as fp:
fp.write(privkey_pem)
req = crypto.X509Req()
req.get_subject().CN = "AWS IoT Certificate"
req.get_subject().O = "Amazon"
req.set_pubkey(keypair)
req.sign(keypair, "sha256")
csr_pem = crypto.dump_certificate_request(crypto.FILETYPE_PEM, req).decode(encoding='utf-8')
with open(aws_csr_path, 'w') as fp:
fp.write(csr_pem)
else:
with open(aws_csr_path, 'r') as fp:
csr_pem = fp.read()
10.3. 서버 인증 및 MQTT 토픽 가져오기
10.2.에서 생성해 둔 CSR을 ThinQ API 서버에 제출해 인증을 받은 뒤 PEM 포맷으로 로컬에 저장하도록 하자
aws_cert_path = './aws_cert.pem'
url = thinq2Uri + '/service/users/client'
response = requests.post(url, headers=get_default_headers())
subscriptions = list()
if response.status_code == 200:
url = thinq2Uri + '/service/users/client/certificate'
csr_pem_string = csr_pem.replace('-----BEGIN CERTIFICATE REQUEST-----', '')
csr_pem_string = csr_pem_string.replace('-----END CERTIFICATE REQUEST-----', '')
csr_pem_string = csr_pem_string.replace('\r\n', '')
data = {
'csr': csr_pem_string
}
response = requests.post(url, json=data, headers=get_default_headers())
if response.status_code == 200:
response_json = json.loads(response.text)
result = response_json.get('result')
certificate_pem = result.get('certificatePem')
with open(aws_cert_path, 'w') as fp:
fp.write(certificate_pem)
subscriptions = result.get('subscriptions') # 구독할 Topic
뿐만 아니라, 이 과정에서 MQTT broker에 구독해야 할 토픽(topic)도 알 수 있으므로 반드시 따로 변수로 지정해두자
10.4. MQTT 접속 후 Subscribe (Paho MQTT)
앞선 과정을 통해 AWS Root CA PEM 파일, RSA Private Key PEM 파일, 서버 인증서 PEM 파일 3개가 준비되었다면 paho-mqtt 패키지를 활용해 쉽게 AWS IoT Core에 접속할 수 있다
mqtt_client = mqtt.Client(client_id=g_client_id)
def onConnect(_, userdata, flags, rc):
print('Mqtt Client Connected: {}, {}, {}'.format(userdata, flags, rc))
for topic in subscriptions:
mqtt_client.subscribe(topic)
def onDisconnect(_, userdata, rc):
print('Mqtt Client Disconnected: {}, {}'.format(userdata, rc))
def onSubscribe(_, userdata, mid, granted_qos):
print(f'Mqtt Subscribe: {userdata}, {mid}, {granted_qos}')
def onMessage(_, userdata, message):
topic = message.topic
msg_dict = json.loads(message.payload.decode('utf-8'))
print(f'Mqtt Message: {topic}: {msg_dict}')
def onLog(_, userdata, level, buf):
print(f'Mqtt Log: {userdata}, {level}, {buf}')
mqtt_client.on_connect = onConnect
mqtt_client.on_disconnect = onDisconnect
mqtt_client.on_subscribe = onSubscribe
mqtt_client.on_message = onMessage
mqtt_client.on_log = onLog
mqtt_client.tls_set(ca_certs=aws_root_ca_path, certfile=aws_cert_path, keyfile=rsa_privkey_path)
idx = mqttserver.rfind(':')
mqtt_host = mqttserver[:idx]
mqtt_port = int(mqttserver[idx+1:])
mqtt_client.connect(host=mqtt_host[6:], port=mqtt_port) # "ssl://"는 필요없다
mqtt_client.loop_start()
while True:
pass
mqtt_client.loop_stop()
mqtt_client.disconnect()
스크립트 실행 후 콘솔에 다음과 같이 로깅되면 접속 성공!
ThinQ 앱을 실행시켜보면 구독한 토픽으로 메시지들이 들어오는 것을 확인할 수 있다 (아닐 수도 있음 ㅎㅎ..)
나같은 경우에는 거실에 있는 공기청정기에서 주기적으로 온도(airState.tempState.current)와 상대습도(airState.humidity.current)를 publish하고 있는 것을 확인할 수 있었다
파이썬과 ThinQ 디바이스 연동 완료!!!
ThinQ에 등록된 기기들을 MQTT를 통해 제어할 수 있을 것 같은 느낌적인 느낌이 강하게 드는데, 글이 너무 길어졌으므로 일단 제어 방법에 대해서는 나중에 다른 글을 통해 소개해보도록 한다 ㅎㅎ
11. 마무리
그놈의 AWS 때문에 보안 접속을 위해 준비해야할 게 워낙에 많아 글이 꽤 길어졌다
다시 한번 정리하자면 핵심은 다음 4 과정이다
- ThinQ API URI를 얻어온다
- ThinQ 서버에서 유저 정보와 access token을 가져온다 (OAuth)
- ThinQ API를 통해 Home 및 Device 정보를 가져온다
- AWS IoT Core MQTT Broker에 접속한다 (SSL/TLS)
MQTT broker에 한 번 접속한 뒤로는 토큰 갱신은 필요없기 때문에 코드를 유연하게 잘 짜면 ThinQ 앱 없이도 ThinQ에 등록된 홈 IoT 기기들을 제어/모니터링할 수 있을 것으로 기대된다
글에서 다룬 예시 스크립트를 아래에 공유하니, 관심있는 사람들은 스텝-바이-스텝으로 직접 돌려보면 ThinQ API가 어떤 식으로 굴러가는지 쉽게 확인할 수 있을 것 같다
이제 로봇청소기 청소 시작 - 청소 종료 상태를 모니터링해서 싱크대 절수 페달 자동 밸브 잠금 연동할 기반 기술이 모두 갖춰졌다
빠른 시일 내에 해당 기능도 구현해보도록 하자
그나저나 MQTT 브로커로 AWS를 쓰다니... 굉장한데? ㅋㅋ
MQTT 메시지 하나하나가 결코 내용물 크기가 작지 않으니 ThinQ 앱 이용자가 많아질 수록 트래픽량이 기하급수적으로 증가할테니 비용이 만만치 않을텐데.. (달러 유출이 이런데서 일어나는거다)
그래도 장점이라면 신뢰도가 최상급인 플랫폼이란 점? ㅋㅋ 미국이 전국적으로 테러를 당하지 않는 이상 AWS는 문제없이 돌아갈거다 (AWS가 멈추면 어림잡아 절반 이상의 웹서비스는 정상적으로 동작하지 않는다..)
판교 데이터센터 화재 사건으로 인한 ㅈ카오 멸망 사태를 몸소 체험해 본 일반 사용자들도 이제는 플랫폼 신뢰도가 얼마나 중요한지 깨달았으리라 생각된다 (LG전자 화이팅...)
끝~!
'홈네트워크(IoT) > 일반' 카테고리의 다른 글
홈네트워크 파이썬 앱 Multi-platform Docker Image 만들기 (0) | 2024.02.21 |
---|---|
LG ThinQ REST API::공기청정기 제어 (5) | 2023.10.31 |
Homebridge - LG ThinQ 연동하기 (애플 홈 연동) (4) | 2022.10.21 |
Let's Encrypt로 발급받은 SSL 인증서 유효기간 확인 (0) | 2022.08.24 |
Let's Encrypt SSL 인증서 갱신(renew) 중 webroot 문제 발생 시 해결 방법 (0) | 2022.08.24 |