중년코딩
534
2019-05-11 18:13:33 작성 2019-05-14 11:25:16 수정됨
6
1175

[중년코딩|1-3]텃밭물주기 완결편


1편: https://okky.kr/article/576810

2편: https://okky.kr/article/577316

직장생활 15년만에 Python으로 코딩을 시작한 게으른 중년 대머리 아저씨입니다. 많은 분들이 좋은 얘기를 해주셔서 게으름을 무릅쓰고 완결편을 텃밭물주기 프로젝트의 완결편을 올리게 되었습니다.

한 프로젝트를 세 편으로 진행하는 거 좋은 거 같습니다. 하나 쓰고 그만두기가 창피해서 끝까지 하게 되네요. 

원래 3편은 최종 코드 공유하고 배운 점 적고 그러려고 했는데, 2편에서 스케줄링을 완료 못해서 그거 먼저 하겠습니다.



I. 목적
1. 옥상 텃밭에 아침 6시에 자동으로 물을 10분간 준다.
2. 비가 오는 날에는 안 준다.
3. 물을 주고 있는 상태가 30분 이상 계속되면 알림을 준다.

gardenwater.py는 윈도우 서버에서 돌아갑니다. 크론탭 대신 작업스케줄러를 써야 하지요. 컴퓨터관리 -> 작업 스케줄러를 들어가는 순간, 어이쿠 내눈!! 뭘 어떻게 해야 할지 모르겠습니다. 크론탭은 간결해서 부담이 덜했는데요.

곰곰이 생각해 봅니다. 아! 얼마전에 마이크로소프트가 윈도우 터미널 발표했다는 기사에서 WSL을 봤던 기억이 납니다. 우분투 깝니다. 크하하하하. 이제 저는 crontab을 쓸 수 있습니다.

...그래도 작업스케줄러를 써볼까 싶기도 하네요.

일단 로직부터 짭니다.

원하는 건 매일 아침 특정시간에 켜지고, 켜진 후 특정시간이 지나면 끄고, 켜진 스위치들이 있는지 확인해서 알람을 보내주는 겁니다.

켜지고 끄는 거는 켠 다음에 프로그램 종료를 하지 않고 특정시간 지나는지 확인하고 있다가 시간이 되면 끌 수도 있고, 켜는 실행을 하고 종료한 뒤에 주기적으로 다시 실행해서 자기가 어떤 일을 해야 하는지 확인하고 스위치를 끌 시간이 되었으면 끄는 형태도 가능할 거 같습니다. 

보통의 프로그래머 분들은 이럴 때 어떻게 만드시는지 궁금하지만, 저는 게으르니 그냥 제 맘대로 만들어 봅니다. 제 맘대로 = 손이 덜가는 대로.

크하하하 방금 스케줄링 갖고 검색하다가 우리 회사 신입 개발자가 쓴 스케줄링 모듈 관련 글을 발견했습니다. 전에 슬랙봇 만들 때도 이분이 만든 모듈에 큰 도움을 받았는데 이분이 얘기한 모듈이면 믿음이 갑니다. 

...만, 그냥 쓰레딩을 쓰기로 합니다.ㅎㅎ 절대로 우리회사 사람을 싫어해서가 아닙니다.

프로그램을 두 개로 구분하기로 했습니다.

1. 아침에 실행 및 몇 분 뒤 종료
2. 매 10분마다 체크해서 모든 스위치 상태 점검 및 알림

구분을 한 이유는 1번과 2번의 실행 주기가 다르고, 1번의 종료시점을 1번 실행 시점에 인자로 던져 주고 싶어서입니다. 실행시 인자를 받아오려면 sys모듈을 써야 한다고 하는군요. 

control_water.py를 수정해 줍니다. 아하하하 쓰레딩이고 뭐고 복잡합니다. 그냥 time.sleep()씁니다. ㅋㅋㅋ

#control_water.py
import ewelink
import getweather
import sys
import time

rss = getweather.rss_read()
rain = getweather.get_rain(rss)
device_id=ewelink.device_status()['옥상물주기']['deviceid']

if len(sys.argv)==1:
    offtime=20
else:
    offtime=int(sys.argv[1])
    if offtime>60:
        print('물을! 아끼자! 최대 시간은 60분입니다.')
        offtime=60

if rain < 20:
    ewelink.turn_onoff('on',device_id)
    print(f'빗물이 흐른다~! 샤랄랄랄라~ {offtime}분 동안 물을 줍니다.')
time.sleep(offtime*60)
ewelink.turn_onoff('off',device_id)
print('꺼져!')

1분으로 설정하고 돌리니 돌아갑니다. 아규먼트에 숫자가 아니라 다른 거 던져주면 오류가 나겠지만, 저 혼자 쓰는 거니까 그냥 둡니다.

이제 10분마다 한 번씩 돌아가면서 디바이스 상태를 기록할 모듈을 만듭니다. 데몬 형태로 만들면 터미널을 계속 띄워놓아야 할 거 같아서 그냥 작업 스케줄러로(혹은 새로 깐 우분투에서 크론탭!) 돌리기로 합니다. 아... 작업 스케줄러로 데몬 띄워도 되겠구나 싶은 생각이 스치지만, 작업스케줄러도 데몬도 공부해야 하므로 그냥 아무 거나 되는 걸로 빨리 합니다.

벌써 토요일에 안 놀아준다고 아이가 보채고 있습니다. 미안, 아빠가 노느라 바빠.

ewelink의 device_status를 받아와서 켜져있던 시간이 얼마나 되는지 봐야 합니다. 희안하게 응답값에는 켜진 시간, 켜져 있던 시간을 표시해주는 값이 없네요. 외부 스케줄러로 수행하기로 했으니 실행상태 및 시간을 오프라인 파일로 저장해야 합니다. 디바이스 아이디별로 상태를 dictionary로 저장하기로 합니다.

{디바이스이름: {
  디바이스id:
  현재상태변경시간:
  상태:

   }

}

로 만들고, 기존 dictionary의 상태와 지금 상태가 다르면 현재상태변경시간을 지금시간으로 업데이트합니다. 상태가 on인 경우 on인 상태로 변경된 시간이 지금부터 1시간 이전이면 슬랙으로 메시지를 날려줄 겁니다.

파일로 작업할 때 crontab돌리면 홈 디렉토리에 파일이 생성되던데, 이건 해결법을 모르니 일단 넘깁니다.

코딩을 하는데 위의 로직을 말로 적어 놓으니 계속 헷갈립니다. 으아아아악.

한 번 그려봅니다. 엑셀이 최고죠.


changetime이...

있다없다
switch상태가

같다

changetime유지

   ->switch가 on인 시간 비교

changetime신설
다르다changetime신설
changetime신설

전에 ewelink에서 만든 device_status()함수를 받아올 겁니다. 여기에는 changetime 즉, 상태가 바뀌었던 시간은 없습니다. 없으면 만들어주고, 있으면 그냥 놔둔채로 현재 시간과 비교합니다.

즉, changetime이 없거나 switch상태가 다르면 현재시간을 changetime에 넣어주고, 아니면(else) 원래 있던 changetime을 changetime에 넣어준 뒤 파일에 써 줍니다.

이 경우 상태가 on이면 changetime간의 시간을 비교합니다. timedelta를 사용했는데, json에 넣었던 값을 다시 datetime포맷으로 바꿔줘야 해서 고생을 했습니다. json에 적을 때는 strftime을 쓰고(datetime -> str), datetime으로 바꿔 줄 때는 strptime을 씁니다(str -> datetime) 엑셀을 할 때도 그렇고 시간, 날짜 포맷 맞추는 게 제일 짜증나고 시간소모가 많습니다. 시간을 아까워하라는 의미인지...

device_status()에서 가져온 값은 딕셔너리입니다. 이거랑 json에서 잃어온 값을 비교해야 합니다. for key, value in device_status.items()를 할지, for device in device_status를 할지 고민했는데, 아무 거나 오류 안나는 걸로 합니다. device로 가져왔더니 튜플형태인지 뭐가 복잡합니다. 이런 거 맞추는 거에서 시간이 훅훅 갑니다... 기계적으로 적응이 되어야 하는데...

얼른 코드 적고 넘어갑시다.


#device_check.py
import ewelink
from datetime import datetime,timedelta
import json
import pprint
import slackpost

try:
    with open("status.json") as f:
        last_status = json.load(f)
        pprint.pprint(last_status)

except:
    pass

device_status = ewelink.device_status()
d={}
for key,value in device_status.items():
    if 'changetime' not in last_status[key] or last_status[key]['switch'] != value['switch']:
        value['changetime'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f')
        print(value['changetime'])
    else:
        value['changetime'] = last_status[key]['changetime']
        if value['switch'] == "on":
            print(key+'on')
            timeon = datetime.strptime(value['changetime'],'%Y-%m-%dT%H:%M:%S.%f')
            timenow=datetime.now()
            td = timenow - timeon
            if td.seconds > 60*60:
                body = f'`{key}`스위치가 {int(td.seconds/60)}분째 켜져 있습니다. 잊으신 건 아니지요?'
                slackpost.slackpost('homealert',body)
     
f=open("status.json","w")    
f.write(json.dumps(device_status))

json 파일을 읽어와서 로드하고, 그걸 현재 상태와 비교합니다. device_status에서 필요한 놈은 일부지만 그거 일부 골라내는 게 더 복잡해질 거 같아 가져온 거에 그대로 'changetime' 값만 추가해 주는 걸로 만듭니다.

60분이 넘으면 slackpost를 날립니다. slackpost는 slackclient 모듈 사용합니다.

#slackpost.py
from slackclient import SlackClient

SLACK_BOT_TOKEN = '                                                '
SLACK_BOT_NAME = 'GDWJ'
POST_MESSAGE_ENDPOINT = 'https://slack.com/api/chat.postMessage'

def slackpost(channel,text,touser=None):
    s = SlackClient(SLACK_BOT_TOKEN)
    s.api_call(
        "chat.postMessage",
        channel=channel,
        text=text
    )

if __name__ == "__main__":
    pass

슬랙봇이름은 GDWJ입니다.

GDWJ는 아이가 좋아하는 갈색 곰 인형 이름입니다.

"갈똥왕자"입니다.

별다른 의미는 없고 똥색이라...

딸아~ 미안하닥!!!!

왠지 마무리가 순식간에 된 것 같지만 기분 탓입니다. 테스트를 위해 시간제한을 1분으로 바꾸고 한 번 돌려봅니다.


갈똥왕자는 네... 19금TED곰입니다. 저걸 왜 애한테 사줬는지...

...51분째 글을 쓰고 있었군요. 게으름에는 시간이 듭니다. 위쪽 한 줄은 int를 넣어서 다시 고쳐줬습니다.

끝! crontab은 1. control_water를 매일 아침 6시, 2. check_device를 매10분마다 실행시킵니다.


최종 코드들입니다. 뭘하든 일관성이 중요하다고 생각해서, 파일 이름을 일관적으로 동사->명사 형태로 바꿨습니다. -_-a 코딩스타일의 일관성을 추구하기에는 실력이 미천하니...


<작성 전 능력치>
딕셔너리 써 본 적 있음(쓸 때마다 헷갈림). 클래스 써 본 적 있음(왜 쓰는지 아직 잘 이해 안 됨). 크롤링은 좀 해봄.

<추가 능력치>
rss 파싱, *args구문 사용, json 조작, datetime 변환, sys.argv사용, 딕셔너리는 이제 더 헷갈림


이상으로 중년코딩, 텃발물주기 프로젝트를 마칩니다. 읽어주신 분들께 감사드립니다. 덧글 하나하나가 큰 힘이 되었습니다.

적는 게 코드 짜는 것보다 힘들지만, 다음 프로젝트는 뭐를 할지 고민중입니다. 파이썬은 아니지만 코드나 SQL 하나도 안 쓰고 구글 시트와 구글 설문지, zapier로만 만드는 사내 도서 대여 프로세스를 해볼까요..?

# ewelink.py SONOFF 모듈 상태 확인 및 점멸제어
import sonoff
import config
import pprint

# https://github.com/lucien2k/sonoff-python

def update_config():
    s = sonoff.Sonoff(config.username, config.password, config.api_region)
    config.user_apikey = s.get_user_apikey
    config.bearer_token = s.get_bearer_token

def device_status():
    s = sonoff.Sonoff(config.username, config.password, config.api_region)
    devices = s.get_devices()
    response={}
    d={}
    for device in devices:
        d={
            'deviceid':     device['deviceid'],
            'name':         device['name'],
            'offlineTime':  device['offlineTime'],
            'onlineTime':   device['onlineTime'],
            'online':       device['online'],
            'switch':       device['params']['switch']
        }
        response[device['name']]=d
    return response    

def turn_onoff(onoff,*args):
    print(onoff)
    print(args)
    s = sonoff.Sonoff(config.username, config.password, config.api_region)
    for arg in args:
        s.switch(onoff,arg,None)
    
if __name__ == "__main__":
    update_config()
#config.py
username='+821011111111'
password='***********'
api_region='as'
user_apikey=None
bearer_token=None
grace_period=600
#get_weather.py
from bs4 import BeautifulSoup
import urllib.request as MyURL
import pprint

# http://www.kma.go.kr/images/weather/lifenindustry/timeseries_XML.pdf

CODE_CONV = {
    'sky': ['맑음','구름조금','구름많음','흐림'],
    'pty': ['없음','비','비/눈','눈/비','눈'],
    'wd' : ['북','북동','동','남동','남','남서','서','북서'], 
    'day': ['오늘','내일','모레']
}
CODE_DICT = {
    'tm':'발표시간',
    'hour':'시간',
    'day':'날짜',
    'temp':'현재온도',
    'tmx':'최고온도',
    'tmn':'최저온도',
    'sky':'하늘',
    'pyt':'강수상태',
    'wfkor':'날씨',
    'pop':'강수확률',
    'r06':'6시간강수량',
    's06':'6시간적설량',
    'r12':'12시간강수량',
    's12':'12시간적설량',
    'wdkor':'풍향',
    'reh':'습도'
}

def rss_read():
    RSS_URL='http://www.kma.go.kr/wid/queryDFSRSS.jsp?zone=ZZZZZZZZZZ'
    response = MyURL.urlopen(RSS_URL)
    weather = BeautifulSoup(response,"html.parser")
    return weather

def print_weather(weather, *args):
    print(weather.find('pubdate').string)

    for data in weather.findAll('data'):
        # xml에서 태그명은 대소문자 구분을 하지 않음
        for arg in args:
            if arg in CODE_CONV.keys():
                print(f"{CODE_DICT[arg]}: {CODE_CONV[arg][int(data.find(arg).string)]}")
            else:
                print(f"{CODE_DICT[arg]}: {data.find(arg).string}")
        print("")
        #print("시간:",data.tmef.string)

def get_rain(weather):
    # 6시간 강수량
    response=float(weather.find('data',{'seq':0}).r06.string)
    return response

def main():
    weather = rss_read()

if __name__ == "__main__":
    main()
#control_water.py
import ewelink
import get_weather
import sys
import time

rss = get_weather.rss_read()
rain = get_weather.get_rain(rss)
device_id=ewelink.device_status()['옥상물주기']['deviceid']

if len(sys.argv)==1:
    offtime=20
else:
    offtime=int(sys.argv[1])
    if offtime>60:
        print('물을! 아끼자! 최대 시간은 60분입니다.')
        offtime=60

if rain < 20:
    ewelink.turn_onoff('on',device_id)
    print(f'빗물이 흐른다~! 샤랄랄랄라~ {offtime}분 동안 물을 줍니다.')
time.sleep(offtime*60)
ewelink.turn_onoff('off',device_id)
print('낮부터 내린비는 이저녁 유리창에\
    \n이슬만 뿌려 놓고서\
    \n밤이되면 더욱 커지는 시계 소리처럼\
    \n내 마음을 흔들고 있네\
    \n이밤 빗줄기는 언제나\
    \n숨겨놓은 내 맘에 비를 내리네\
    \n떠오른 아주 많은 시간들 속을\
    \n헤매이던 내 맘은 비에 젖는데\
    \n이젠 젖은 우산을 펼 수는 없는걸~~~\
    \n낮부터 내린비는 이저녁 유리창에\
    \n슬픔만 뿌리고 있네')
#check_device.py
import ewelink
from datetime import datetime,timedelta
import json
import pprint
import slackpost

device_status = ewelink.device_status()
try:
    with open("status.json") as f:
        last_status = json.load(f)
        pprint.pprint(last_status)
except:
    last_status = device_status

d={}
for key,value in device_status.items():
    if 'changetime' not in last_status[key] or last_status[key]['switch'] != value['switch']:
        value['changetime'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f')
    else:
        value['changetime'] = last_status[key]['changetime']
        if value['switch'] == "on":
            timeon = datetime.strptime(value['changetime'],'%Y-%m-%dT%H:%M:%S.%f')
            timenow=datetime.now()
            td = timenow - timeon
            if td.seconds > 60*60:
                body = f'`{key}`스위치가 {int(td.seconds/60)}분째 켜져 있습니다. 잊으신 건 아니지요?'
                slackpost.slackpost('homealert',body)
     
f=open("status.json","w")    
f.write(json.dumps(device_status))
#slackpost.py
from slackclient import SlackClient

SLACK_BOT_TOKEN = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
SLACK_BOT_NAME = 'GDWJ'
POST_MESSAGE_ENDPOINT = 'https://slack.com/api/chat.postMessage'

def slackpost(channel,text,touser=None):
    s = SlackClient(SLACK_BOT_TOKEN)
    s.api_call(
        "chat.postMessage",
        channel=channel,
        text=text
    )

if __name__ == "__main__":
    pass
4
2
  • 댓글 6

  • 하두
    10k
    2019-05-11 18:43:30

    Ec  ph  co2  측정 센서도 ㅋ

    0
  • 중년코딩
    534
    2019-05-11 19:19:25

    @하두 온습도 센서를 사놓은 게 있긴 한데... 방수가 안 되는 놈을 샀네요.

    0
  • sbroh
    8k
    2019-05-14 00:57:53

    'NLP를 이용한 야식 주문 취합 슬랙 챗봇' 해주세요!!! ㅎㅎㅎ 

    글 너무 재밌게 잘 보고 있습니다. ^^

    0
  • 중년코딩
    534
    2019-05-14 07:14:06 작성 2019-05-14 07:34:29 수정됨

    @sbroh 선생님께서 좋은 얘기를 해주시니 영광입니다. NLP챗봇은 제 실력상 시간이 좀 걸릴 주제이긴 한데요... 천천히 해 보겠습니다. ㅎㅎ

    0
  • sbroh
    8k
    2019-05-14 08:13:20

    중년코딩

    완전 팬입니다. ㅎㅎㅎ 어느 회사에서 인사/총무 하시는 지는 모르지만 그 회사 분들 참 좋겠녜요. 인사 정책도 훌륭할 듯요. ㅎㅎㅎ

    NLP챗봇 어려우시면 가장 편하게 사용하실 스프레드시트 이용한 무엇인가 좋을 것 같습니다. :) 걍 편하게 해주세요~ 

    0
  • 중년코딩
    534
    2019-05-14 09:40:48

    ㅎㅎ 구글시트부터 먼저 써 보겠습니다.

    1
  • 로그인을 하시면 댓글을 등록할 수 있습니다.