├── keilib ├── worker.py ├── __init__.py ├── uploader.py ├── recorder.py ├── serial.py └── broute.py ├── LICENSE.txt ├── keiconf_serial.py ├── php └── upload.php ├── keiconf_broute.py ├── kei.py └── README.md /keilib/worker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """スレッドで動作するオブジェクトの雛形 5 | """ 6 | 7 | import threading 8 | 9 | class Worker ( threading.Thread ): 10 | """Woker はスレッドを停止するためのイベントを設定するための抽象クラス。 11 | """ 12 | def __init__( self ): 13 | super().__init__() 14 | self.stopEvent = threading.Event() 15 | 16 | def stop ( self ): 17 | """ストップイベントをセットする。 18 | """ 19 | self.stopEvent.set() 20 | self.join() 21 | 22 | def run(self): 23 | """ストップイベントがセットされていない限り、繰り返し実行する。 24 | この関数はオーバーライドする。 25 | """ 26 | while not self.stopEvent.is_set(): 27 | # do something 28 | pass 29 | -------------------------------------------------------------------------------- /keilib/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | '''Bルート、シリアル通信、各種センサーからのデータを取得し、整理して保存するライブラリ 4 | 5 | 機能と特徴: 6 | * シリアルポートからのデータ読み込み 7 | - 不正データの除外 8 | - 重複データの除外 9 | - 外れ値の除外 10 | 11 | * スマートメーターから電力情報の取得 12 | - RL7023 Stick-D/DSS への対応 13 | 14 | * ファイルへの保存 15 | - タイムスタンプを付加する 16 | - 10分平均を計算し、別ファイルとして保存 17 | - 日付ごとにファイルを作る 18 | 19 | ToDo: 20 | * 他の WiSUN ドングルへの対応 21 | - 特に Bルート専用の片面タイプ 22 | 23 | * データの処理方法を柔軟に設定できるように。 24 | - ファイルに保存するデータ 25 | - アップロードするデータ 26 | − 表示機に送信するデータ 27 | - ツイートするデータ 28 | - udpで垂れ流すデータ 29 | 30 | * センサーのクラスを充実 31 | - DS18B20 温度センサー 32 | - sht21 温度・湿度センサー 33 | - mcp3208 ADC 34 | - パルスセンサー 35 | ''' 36 | 37 | __author__ = "MATSUDA, Koji " 38 | __version__ = "0.1.1" 39 | __date__ = "2019-12-14" 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 MATSUDA, Koji 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /keiconf_serial.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | """シリアルポートから入力されるデータを読み込み、ファイルに記録する設定 4 | 5 | keiconf.py にリネームして使用 6 | 7 | SerialReader: 8 | データフォーマット 9 | [UNITID],[SENSORID],[VALUE],[DATAID]<改行> 10 | UNITID: Auduino などのユニットを識別するID 11 | SENSORID: UNIT上でセンサを識別するID 12 | VALUE: センサの読み取り値 13 | DATAID: 重複データを排除するためのID(無線の場合、複数のコピーを受信することがある) 14 | 15 | IDはいずれも半角英数字とハイフン'-'のみ、VALUEは数値として解釈できる文字列であること 16 | """ 17 | 18 | import queue 19 | from keilib.recorder import FileRecorder 20 | from keilib.serial import SerialReader 21 | 22 | #スレッド間でデータを共有する Queue 23 | record_que = queue.Queue(50) 24 | # 保存するファイル名のベースを指定 25 | fname_base = 'mylogfile' 26 | # シリアルポート情報 27 | serial_port = '/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_YYYYYYYY-if00-port0' 28 | baud_rate = 115200 29 | 30 | # ここで指定したクラスインスタンスが作成され、スレッドで並列動作する 31 | # オブジェクト間で共有する queue を使って互いにデータを交換する 32 | # 同一クラスの複数のインスタンスを作成することも可能 33 | worker_def = [ 34 | { 35 | 'class': FileRecorder, 36 | 'args': { 37 | 'record_que': record_que, 38 | 'fname_base': fname_base, 39 | } 40 | }, 41 | 42 | { 43 | 'class': SerialReader, 44 | 'args': { 45 | 'port': serial_port, 46 | 'baudrate': baud_rate, 47 | 'record_que': record_que, 48 | } 49 | }, 50 | ] 51 | -------------------------------------------------------------------------------- /php/upload.php: -------------------------------------------------------------------------------- 1 | 1000) { 27 | exit("Error: Too big data."); 28 | } 29 | 30 | // ファイル名長さチェック 31 | // 以下の例は 20 Byte 未満 32 | $fname = $_POST["fname"]; 33 | $length = strlen($fname); 34 | if ($length > 20) { 35 | exit("Error: Too long file name."); 36 | } 37 | 38 | // ファイル名チェック、上書きして良いファルだけ 39 | // 以下の例は、パス区切り文字/(スラッシュ)を含めない。想定外のディレクトリに書き込まれないように。 40 | if ( ! preg_match("/^[A-Za-z0-9_]+(\.[A-Za-z0-0_]+)*$/", $fname)) { 41 | exit("Error: Invalid file name."); 42 | } 43 | 44 | // その他のチェックを行う 45 | // 大切なファイルを破壊されないように 46 | // 以下の例は update_ok.txt というファイルだけ通す 47 | # if ( ! preg_match("/^update_ok\.txt$/", $fname)) { 48 | # exit("Error: The file name is not allowd."); 49 | # } 50 | 51 | // 指定されたファイルにデータを追記 52 | $fp = fopen("data/" . $fname, 'a'); 53 | fwrite($fp, $data); 54 | fclose($fp); 55 | file_put_contents('ptime.txt', $ctime); 56 | print "OK."; 57 | ?> 58 | -------------------------------------------------------------------------------- /keilib/uploader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """リモートへデータをアップロードする機能をもつクラスを定義する 4 | 5 | ToDo: 6 | http POST 以外でアップロードするもの 7 | ツイッターへ投稿など(必要があれば) 8 | """ 9 | import threading 10 | import requests 11 | import sys 12 | import queue 13 | from keilib.worker import Worker 14 | 15 | from logging import getLogger, StreamHandler, DEBUG 16 | logger = getLogger(__name__) 17 | 18 | class HttpPostUploader ( Worker ): 19 | """upload_queに入っているデータを取り出して、httpサーバーにPOSTする 20 | """ 21 | 22 | def __init__( self , upload_que, target_url, upload_key): 23 | """コンストラクタ 24 | 引数: 25 | upload_que (Queue): データ受け取るための queue 26 | target_url (str): サーバーURL 27 | upload_key (str): アップロードキー(サーバー側で認証に使う) 28 | """ 29 | super().__init__() 30 | self.upload_que = upload_que 31 | self.target_url = target_url 32 | self.upload_key = upload_key 33 | 34 | def run ( self ): 35 | logger.info('[START]') 36 | # self.upload_que.put(['test.txt', 'This is test data\n']) 37 | while not self.stopEvent.is_set(): 38 | # get data from upload_que(queueからデータの取得) 39 | try: 40 | filename, data = self.upload_que.get(timeout=3) 41 | except: 42 | # logger.debug('upload que is empty') 43 | continue 44 | 45 | payload = { 46 | 'type' : 'text', 47 | 'key' : self.upload_key, 48 | 'fname': filename, 49 | 'data' : data 50 | } 51 | logger.debug(payload) 52 | 53 | # POST execution(POST実行) 54 | try: 55 | response = requests.post(self.target_url, payload) 56 | except: 57 | logger.error('requests post error') 58 | continue 59 | 60 | logger.info('[STOP]') 61 | -------------------------------------------------------------------------------- /keiconf_broute.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | '''Bルート経由で電力情報を取得し、ファイルに記録すると同時に、サーバーにアップロードする設定 5 | 6 | keiconf.py にリネームして使用 7 | ''' 8 | 9 | import queue 10 | from keilib.uploader import HttpPostUploader 11 | from keilib.recorder import FileRecorder 12 | from keilib.broute import BrouteReader 13 | 14 | 15 | # settings for FileRecorder 16 | record_que = queue.Queue(50) 17 | fname_base = 'mylogfile' 18 | 19 | # settings for HttpPostUploader 20 | upload_que = queue.Queue(50) 21 | 22 | # upload.php のサンプルは php フォルダにある 23 | target_url = 'https://example.com/upload.php' 24 | upload_key = 'xxxxxxxxxxxxxxxx' 25 | 26 | # settings for BrouteReader 27 | broute_port = '/dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_xxxxxxxx-if00-port0' 28 | broute_baudrate = 115200 29 | 30 | wisundev = WiSunRL7023 ( 31 | port=broute_port, 32 | baud=broute_baudrate, 33 | type=WiSunRL7023.IPS # Bルート専用タイプ 34 | # type=WiSunRL7023.DSS # デュアルスタックタイプ 35 | ) 36 | 37 | broute_id = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' 38 | broute_pwd = 'xxxxxxxxxxxx' 39 | requests = [ 40 | { 'epc':['D3','D7','E1'], 'cycle': 3600 }, # 係数(D3),有効桁数(D7),単位(E1),3600秒ごと 41 | { 'epc':['E7'], 'cycle': 10 }, # 瞬時電力(E7),10秒ごと 42 | { 'epc':['E0'], 'cycle': 300 }, # 積算電力量(E0),300秒ごと 43 | ], 44 | # definition fo worker objects 45 | 46 | worker_def = [ 47 | { 48 | 'class': HttpPostUploader, 49 | 'args': { 50 | 'upload_que': upload_que, 51 | 'target_url': target_url, 52 | 'upload_key': upload_key 53 | } 54 | }, 55 | 56 | { 57 | 'class': FileRecorder, 58 | 'args': { 59 | 'record_que': record_que, 60 | 'fname_base': fname_base, 61 | 'upload_que': upload_que 62 | } 63 | }, 64 | 65 | { 66 | 'class': BrouteReader, 67 | 'args': { 68 | 'wisundev': wisundev, 69 | 'broute_id': broute_id, 70 | 'broute_pwd': broute_pwd, 71 | 'requests': requests, 72 | 'record_que': record_que, 73 | } 74 | }, 75 | ] 76 | -------------------------------------------------------------------------------- /kei.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | 設定ファイルで指定したワーカクラスのインスタンスを作成し、それぞれスレッドで並列動作させる 5 | 定期的にスレッドの状態を確認し、停止しているものがあればインスタンスを再度作成し再起動する 6 | オブジェクト間のデータ受け渡しはスレッドセーフな Queue オブジェクトを介して行う 7 | """ 8 | __author__ = "MATSUDA, Koji " 9 | __version__ = "0.1.1" 10 | __date__ = "2019-12-14" 11 | 12 | import signal 13 | import atexit 14 | import logging 15 | from logging.handlers import TimedRotatingFileHandler 16 | import os 17 | import time 18 | import sys 19 | import keiconf 20 | 21 | # import configuretion file. 22 | # (設定ファイルをインポートする) 23 | worker_def = keiconf.worker_def 24 | 25 | # If the environment variable DEBUG is defined, specify DEBUG as the log level. 26 | # DEBUG 環境変数が定義されていたらログレベルをDEBUGとする 27 | if 'DEBUG' in os.environ: 28 | debug = os.environ['DEBUG'] 29 | else: 30 | debug = 0 31 | 32 | # Preparing log files(ログファイルの準備) 33 | fname = os.path.basename(__file__).split('.') 34 | 35 | if debug: 36 | LOGLEVEL = logging.DEBUG 37 | handler = logging.StreamHandler() 38 | else: 39 | LOGLEVEL = logging.INFO 40 | LOGFILE = os.getcwd() + '/' + fname[0] + '.log' 41 | handler = TimedRotatingFileHandler(LOGFILE, when='D', interval=1, backupCount=3) 42 | 43 | # Set formatter in handler(ハンドラにフォーマッタをセット) 44 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 45 | handler.setFormatter(formatter) 46 | 47 | # Setting the root logger(ルートロガーの設定) 48 | logger = logging.getLogger('') 49 | logger.setLevel(LOGLEVEL) 50 | logger.addHandler(handler) 51 | 52 | # Defining signal handlers(シグナルハンドラの定義) 53 | # 1. End process(終了処理) 54 | def exit_handler(signal, frame): 55 | logger.info('Stopping all threads. Please wait ...') 56 | # 動作中の woker にストップイベントをセット 57 | for wdef in worker_def: 58 | wdef['instance'].stop() 59 | sys.exit(0) 60 | 61 | # 2. End process(終了処理) 62 | # def goodbye(): 63 | # logger.info('stop ' + fname[0]) 64 | 65 | # 3. Changing the log level(ログレベルの変更) 66 | def change_loglevel( signal, frame ): 67 | current_log_level = logging.getLogger('').getEffectiveLevel() 68 | if current_log_level == logging.DEBUG: 69 | logger.info('change loglevel to INFO') 70 | logging.getLogger('').setLevel(logging.INFO) 71 | else: 72 | logger.info('change loglevel to DEBUG') 73 | logging.getLogger('').setLevel(logging.DEBUG) 74 | 75 | # Registering signal handlers(シグナルハンドラの登録) 76 | signal.signal( signal.SIGHUP, exit_handler ) 77 | signal.signal( signal.SIGINT, exit_handler ) 78 | signal.signal( signal.SIGTERM, exit_handler ) 79 | signal.signal( signal.SIGUSR1, change_loglevel ) 80 | # atexit.register( goodbye ) 81 | 82 | # Launch each worker instance(各ワーカーインスタンスの起動) 83 | for wdef in worker_def: 84 | wdef['instance'] = wdef['class']( **wdef['args'] ) 85 | wdef['instance'].start() 86 | logger.info('start ' + fname[0]) 87 | 88 | # Check if the threads have stopped at intervals, and restart them if stopped. 89 | # 一定間隔でスレッドが停止したかどうかを確認し、停止していた場合は再起動 90 | while True: 91 | for wdef in worker_def: 92 | if not wdef['instance'].is_alive(): 93 | logger.warning(wdef['class'].__name__ + ' worker object is stoped. restart again.') 94 | wdef['instance'] = wdef['class']( ** wdef['args']) 95 | wdef['instance'].start() 96 | 97 | time.sleep(10) 98 | -------------------------------------------------------------------------------- /keilib/recorder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """データを保存するためのクラスを定義 4 | 5 | ToDo: 6 | * リレーショナルデータベースへの保存 7 | * データの流れをもっと細かく制御するクラスなど 8 | 9 | """ 10 | 11 | import threading 12 | import datetime 13 | import time 14 | from keilib.worker import Worker 15 | 16 | from logging import getLogger, StreamHandler, DEBUG 17 | logger = getLogger(__name__) 18 | 19 | class FileRecorder ( Worker ): 20 | """record_queからデータを取り出し、それをファイルに保存する。 21 | 22 | * 2つのファイルに保存する 23 | - すべてのデータの記録: [YYYYMMDD]-[fnameBase].txt 24 | - 10分ごとの平均を記録: sum[YYYYMMDD]-[fnameBase].txt 25 | - ファイルは日毎に作成。ファイル名には日付の情報が含まれる 26 | 27 | * 保存に際して 28 | - record_que から取り出したときのタイムスタンプを追加 29 | − 保存形式は、TIMESTAMP、UNIT_ID, SENSOR_ID, VALUE, DATA_ID 30 | - タイムスタンプは YYYY/MM/DD hh:mm:ss の形式 31 | 例) 2019/12/01 19:12:03,A,T1,12.3,0F 32 | 33 | * upload_que が指定されていれば 10分平均データを追加 34 | 35 | * disp_def に指定されたデータは disp_queに追加 36 | 37 | ToDo: 38 | * 機能が固定的で柔軟性がない、もっと柔軟かつシンプルに設定できればよい 39 | * アップロードやディスプレイへ送信するなどの機能は、他のクラスに担当させるべき 40 | * 10分平均の計算なども別クラスがよいかも、さらに柔軟に5分平均などへの対応も 41 | """ 42 | 43 | def __init__( self , record_que, fname_base='data', upload_que=None, disp_def=[] ,disp_que=None): 44 | """コンストラクタ 45 | 46 | 引数: 47 | record_que (Queue): 保存するデータをここから取り出す 48 | fname_base (str): ファイル名の基本文字列(これに日付情報が付加される) 49 | upload_que (Queue): アップロードする場合に指定。アップロードは HttpPostUploader オブジェクトが担当 50 | -- 以下未実装機能 -- 51 | disp_def (list): 外部表示機 Displayer に送るためのデータを定義。 52 | disp_queue (Queue): Displayer オブジェクトにデータを送るための Queue 53 | """ 54 | super().__init__() 55 | self.fileNameBase = fname_base 56 | self.record_que = record_que 57 | self.upload_que = upload_que 58 | self.disp_que = disp_que 59 | 60 | self.sum10m = {} 61 | now = datetime.datetime.today() 62 | self.datePre = now.strftime('%Y/%m/%d') 63 | self.key10mPre = now.strftime('%Y%m%d%H%M%S')[:11] + '0' 64 | self.disp_def = disp_def 65 | self._update_timestamp() 66 | 67 | def _update_timestamp( self ): 68 | """タイムスタンプ値のアップデート""" 69 | now = datetime.datetime.today() 70 | self.date = now.strftime('%Y/%m/%d') 71 | self.mytime = now.strftime('%H:%M:%S') 72 | self.key01m = now.strftime('%Y%m%d%H%M%S') 73 | self.key10m = self.key01m[:11] + '0' 74 | 75 | def _write10m( self ): 76 | """10分ごとの処理 77 | 78 | 10分平均をファイルに書き出す 79 | upload_queにデータを送る 80 | """ 81 | data = '' 82 | for u in self.sum10m: 83 | for s in self.sum10m[u]: 84 | count = self.sum10m[u][s]['count'] 85 | mysum = self.sum10m[u][s]['sum'] 86 | avr = mysum / count 87 | d = self.key10mPre 88 | date10m = d[:4]+'/'+d[4:6]+'/'+d[6:8]+' '+d[8:10]+':'+d[10:12] 89 | outtext = date10m + ',' + u + ',' + s + ',' + str(avr) + '\n' 90 | data += outtext 91 | 92 | if data != '': 93 | #filename = 'sum'+self.key01m[:8]+'-'+self.fileNameBase+'.txt' 94 | filename = 'sum'+self.key10mPre[:8]+'-'+self.fileNameBase+'.txt' 95 | with open(filename, 'a') as f: 96 | f.write(data) 97 | 98 | if self.upload_que is not None: 99 | try: 100 | self.upload_que.put([filename, data], block=False) 101 | except: 102 | #print ('upload queue is full') 103 | pass 104 | 105 | self.sum10m = {} 106 | self.key10mPre = self.key10m 107 | 108 | def _writeline( self, unit, sensor, value, id='x' ): 109 | """データにタイムスタンプを追加してファイルに書き出す 110 | 111 | 引数: 112 | unit (str): ユニットID 113 | sensor (str): センサーID 114 | value (number): センサー値 115 | id (str): VALUEID 116 | 117 | CSV形式で一行追加される。 118 | 119 | [timestamp],[unit],[sensor],[value],[id] 120 | """ 121 | #print(self.disp_que) 122 | if unit not in self.sum10m: 123 | self.sum10m[unit] = {} 124 | if sensor not in self.sum10m[unit]: 125 | self.sum10m[unit][sensor] = {'count':0, 'sum':0.0} 126 | self.sum10m[unit][sensor]['count'] += 1 127 | self.sum10m[unit][sensor]['sum'] += value 128 | # ファイルへの書き出し(1行) 129 | linedata = self.date+' '+self.mytime+','+unit+','+sensor+','+str(round(value,4))+','+id+'\n' 130 | filename = self.key01m[:8]+'-'+self.fileNameBase+'.txt' 131 | 132 | with open(filename, 'a') as f: 133 | f.write(linedata) 134 | 135 | self._send_disp( unit, sensor, value ) 136 | 137 | def _send_disp( self, unit, sensor, value ): 138 | """データが disp_def に一致するとき disp_queue に送信する 139 | """ 140 | if type(self.disp_def) != list: 141 | return False 142 | 143 | for disp in self.disp_def: 144 | if unit == disp['unit'] and sensor == disp['sensor']: 145 | 146 | if self.disp_que is not None: 147 | try: 148 | self.disp_que.put([disp['filenumber'], unit, sensor, value], block=False) 149 | except: 150 | logger.debug('disp queue is full') 151 | pass 152 | else: 153 | logger.debug('disp queue is None') 154 | pass 155 | 156 | filename = '/tmp/DISP' + disp['filenumber'] + '.txt' 157 | try: 158 | with open(filename, mode='w') as f: 159 | f.write(unit + ',' + sensor + ',' + str(value) + '\n') 160 | return True 161 | break 162 | except: 163 | return False 164 | return False 165 | 166 | def run( self ): 167 | logger.info('[START]') 168 | while not self.stopEvent.is_set(): 169 | # タイムスタンプ更新 170 | self._update_timestamp() 171 | # 10分ごとに平均値を書き出す 172 | if self.key10m != self.key10mPre: 173 | self._write10m() 174 | 175 | # queueからデータの取得 176 | try: 177 | unit, sensor, value, dataid = self.record_que.get(timeout=3) 178 | except: 179 | # logger.debug('file queue is empty') 180 | continue 181 | 182 | # もう一度タイムスタンプ更新 183 | self._update_timestamp() 184 | # ファイルへの書き込み 185 | self._writeline(unit, sensor, value, dataid) 186 | 187 | logger.info('[STOP]') 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | スマートメーター&計測ロガー 2 | ==================== 3 | 4 | スマートメーターの情報を取得し、ファイルに記録します。 5 | 6 | 変更履歴: 7 | -------------------- 8 | * 2023/09/22 `[ver 0.1.2]` Python3.9 __file__ の仕様変更、および isAlive() 廃止に対応 9 | * 2019/12/14 `[ver 0.1.1]` RL7023 Stick-D/IPS シングルスタックに対応、WiSunDevice オブジェクトを BrouteReader 内で作成していたが、設定ファイル内で作成してから BrouteReaderのコンストラクタにわたす形式に変更 10 | * 2019/12/11 `[ver 0.1.0]` 公開時バージョン 11 | 12 | 特徴: 13 | -------------------- 14 | - Raspberry Pi + Python3 で動作 15 | - Raspbian Lite (デスクトップが入ってない。ヘッドレスで運用) 16 | - SDカード 4G以上(データ量に応じて。16Gもあれば十分) 17 | - ネットワーク接続(時刻同期のためとかいろいろ) 18 | - python3, python3-serial, python3-requests 19 | - (最新ディストリビューションだと serial だけ別途インストール) 20 | - ラズパイでなくても大丈夫だと思いますが、前提としてつけっぱなしになります。 21 | - スマートメーターBルートデータ取得機能 22 | - WiSun モジュール RL7023 Stick-D/DSS(デュアルスタック)に対応 23 | - ~~(シングルスタックの RL7023 Stick-D/IPS を使うには修正が必要。持ってないので未実装です)~~ 24 | - Bルート専用の RL7023 Stick-D/IPS にも対応しました(Ver 0.1.1)、設定ファイル内で指定します 25 | - 取得したいスマートメーターのプロパティと取得間隔を定義できる。 26 | * 対応プロパティ = D3,D7,E0,E1,E3,E7,E8,(EA,EB) 27 | * D3: 係数「積算電力量計測値」を実使用量に換算する係数 28 | * E7: 積算電力量計測値の有効桁数 29 | * E1: 単位「積算電力量計測値」の単位 (乗率) 30 | * E0: 積算電力量 計測値 (正方向計測値) 31 | * E3: 積算電力量 計測値 (逆方向計測値) 32 | * E7: 瞬時電力計測値(逆潮流のときは負の値) 33 | * E8: 瞬時電流計測値(T/R相別の電流) 34 | * EA: 定時 積算電力量 計測値 (正方向計測値) 35 | * EB: 定時 積算電力量 計測値 (逆方向計測値) 36 | - 状態遷移による振る舞いの管理 → 接続が切れても自動的に再接続 37 | - シリアルポートからのデータ取得機能 38 | - USBに接続した Arduino などの周辺機器から入力されたデータも記録可能 39 | - Arduinoはセンサのライブラリや作例が豊富で使いやすいし消費電力も少ない 40 | - XbeeやTWELiteDIPなどで無線化すれば、離れた場所のセンサも記録できる 41 | - TWELiteDIPにセンサを直結した場合、電池で数年持つセンサノードが作れる 42 | - リモートの Http サーバーにデータを POST する機能 43 | - 遠隔地に置いたラズパイのデータを(そこそこ)リアルタイムに取得するため 44 | - Raspi + Soracom(SIM) + AK020(3Gモデム) で安定動作 45 | - マルチスレッド(シンプルなフレームワーク) 46 | - 不慮のエラーによる停止からの回復、長期連続運用が可能 47 | - 機能の追加が容易 48 | 49 | 設定例: 50 | -------------------- 51 | (Bルートのみ記録する構成) 52 | ```python 53 | # keiconf.py 54 | 55 | import queue 56 | from keilib.recorder import FileRecorder 57 | from keilib.broute import BrouteReader, WiSunRL7023 58 | 59 | # オブジェクト(スレッド)間で通信を行うための Queue 60 | record_que = queue.Queue(50) 61 | 62 | # WiSunデバイスの定義 63 | # type 引数でドングルの種類(DSS/IPS)を指定する 64 | wisundev = WiSunRL7023(port='/dev/serial/by-id/xxxxx', 65 | baud=115200, 66 | type=WiSunRL7023.IPS 67 | #type=WiSunRL7023.DSS 68 | ) 69 | 70 | # 動作させるオブジェクトの構成 71 | worker_def = [ 72 | { 73 | 'class': FileRecorder, # FileRecorderオブジェクトを作成 74 | 'args': { # 引数 75 | 'fname_base': 'mydatafile', # 記録ファイルの名前に使われる文字列 76 | 'record_que': record_que # 記録するデータをやり取りする Queue 77 | } 78 | }, 79 | { 80 | 'class': BrouteReader, # BrouteReaderオブジェクトを作成 81 | 'args': { # 引数 82 | 'wisundev': wisundev # デバイスドライバの指定 83 | 'broute_id': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 84 | # BルートID(電力会社に申請) 85 | 'broute_pwd': 'xxxxxxxxxxxx', # Bルートパスワード(電力会社に申請) 86 | 'requests':[ # 取得するプロパティ値の定義 87 | { 'epc':['D3','D7','E1'], 'cycle': 3600 }, 88 | # 積算電力量用 係数(D3),有効桁数(D7),単位(E1),3600秒ごと 89 | { 'epc':['E7'], 'cycle': 10 }, # 瞬時電力(E7),10秒ごと 90 | { 'epc':['E0','E3'], 'cycle': 300 }, 91 | # 積算電力量(E0,E3),300秒ごと 92 | ], 93 | 'record_que': record_que # 記録するデータをやり取りする Queue 94 | } 95 | }, 96 | ] 97 | ``` 98 | 上記の設定例では、`worker_def` リストに `FileRecorder` と `BrouteReader` 99 | の2つのクラスがコンストラクタへの引数の構成とともに定義されています。 100 | `key.py` はこの設定ファイルを読み込み、そこに定義されているクラスのインスタンスを作成し管理します。 101 | 作成されたオブジェクトはそれぞれ別のスレッドで動作を開始しますが、 102 | スレッド間でデータを受け渡すために `queue.Queue` オブジェクトを利用しています。 103 | 上記の `record_que` は2つのオブジェクト間でファイルに保存するデータを受け渡すために共有されています。 104 | `BrouteReader` はスマートメーターから情報を得ると体裁を整えて `record_que` に投入(put)します。 105 | 一方 `FileRecorder` は `recoed_que` に流れてきたデータを取得(get)してファイルに保存します。 106 | Queue オブジェクトはスレッドセーフであるため、排他制御を意識せずに使用できます。 107 | 108 | こうした `Queue` を介した連携の中に `SerialReader` など、 109 | また別のスレッドで動作するオブジェクトを加えてやれば、 110 | Arduino 等に接続したセンサーのデータもシリアルポートを通して同様に記録することができます。 111 | また、`HttpPostUploader` を追加すれば、遠隔地のウェブサーバーにデータを送信することもできます。 112 | さらに例えば、ファイルではなくデータベースへ記録する `SqlRecorder?` クラス(未実装)や、 113 | 測定値を監視して一定の条件を満たすとメールなどでアラートを通知する `Watcher?` クラス(未実装)など、 114 | 様々な機能を簡単に追加することができます。 115 | 機能ごとにスレッドを分けて実行することにより、シンプルで柔軟なフレームワークとなっています。 116 | 117 | 起動方法: 118 | -------------------- 119 | Raspbian Lite にあらかじめ pyserial をインストールしておきます。 120 | ```sh 121 | $ sudo apt install python3-serial 122 | ``` 123 | 適当な作業ディレクトリを作成し、以下のようにファイルを配置し、 124 | `keiconf.py` には構成を定義しておきます。 125 | 126 | ```text 127 | workdir/ 128 | |-- keilib/ 129 | | |-- __init__.py 130 | | |-- broute.py 131 | | +-- .... 132 | | 133 | |-- keiconf.py 134 | +-- kei.py 135 | ``` 136 | 137 | プログラムの実行は次のように行います。 138 | ```sh 139 | $ python3 kei.py 140 | ``` 141 | 実行すると `workdir/` ディレクトリ内に、計測データを保存するファイルが2つと、 142 | プログラムの実行時の情報を出力するログファイル`kei.log`が作成されます。 143 | ログについては、 144 | 145 | ```sh 146 | $ DEBUG=0 python3 kei.py 147 | ``` 148 | のように起動すると、ログレベル = DEBUG となりログの出力先が標準出力になります。 149 | ログレベルは USR1 シグナルを受け取ると INFO <-> DEBUG で反転します。 150 | 151 | プログラムの終了については、HUP, INT, TERM シグナルによって各オブジェクトにストップイベントを送っています。 152 | ストップイベントを受けとったオブジェクトのスレッドはリソースを開放してから終了します。 153 | 154 | 出力ファイルの形式: 155 | -------------------- 156 | 2つのファイルが作られます。 157 | 158 | ``` 159 | 1. [YYYYMMDD]-[mydatafile].txt 160 | 2. sum[YYYYMMDD]-[mydatafile].txt 161 | ``` 162 | 1.は1行につき1件のデータが記録されており、行のフォーマットは以下の形をとります。 163 | ```text 164 | [YYYY/MM/DD hh:mm:ss],[UnitID],[SensorID],[Value],[DataID]<改行> 165 | ``` 166 | なお BrouteReader の場合、出力するデータは以下の通りです。 167 | ``` 168 | [UnitID] = BR(固定) 169 | [SensorID] = スマートメーターのプロパティコード(EPC): E7, E0 等 170 | [Value] = 測定値(数値) 171 | [DataID] = x(固定) 172 | ``` 173 | 2.は1行に各センサーの10分ごとの平均値が記録されます。 174 | ``` 175 | [YYYY/MM/DD hh:m0],[UnitID],[SensorID],[AverageValue]<改行> 176 | ``` 177 | 日付のフォーマットに秒がない点に注意です。 178 | 179 | 参考: 180 | -------------------- 181 | * [エコーネット規格](https://echonet.jp/spec_g/) 182 | - [ECHONET Lite規格書 Ver.1.13(日本語版)](https://echonet.jp/spec_v113_lite/) 183 | - [第2部 ECHONET Lite 通信ミドルウェア仕様](https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/ECHONET_lite_V1_13_jp/ECHONET-Lite_Ver.1.13_02.pdf) 184 | - [APPENDIX ECHONET機器オブジェクト詳細規定Release L Revised](https://echonet.jp/spec_object_rl_revised/) 185 | - [Appendix_Release_L_revised.pdf](https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/Release/Release_L_jp/Appendix_Release_L_revised.pdf) 186 | * [経済産業省 スマートハウス・ビル標準・事業促進検討会](https://www.meti.go.jp/committee/kenkyukai/mono_info_service.html#smart_house) 187 | - [【第9回配布資料】HEMS-スマートメーターBルート(低圧電力メーター)運用ガイドライン[第4.0版]](https://www.meti.go.jp/committee/kenkyukai/shoujo/smart_house/pdf/009_s03_00.pdf) 188 | * blog記事など 189 | - [スマートメーターの情報を最安ハードウェアで引っこ抜く](https://qiita.com/rukihena/items/82266ed3a43e4b652adb) 190 | - [Bルートやってみた - スカイリー・ネットワークス](http://www.skyley.com/products/b-route.html) 191 | -------------------------------------------------------------------------------- /keilib/serial.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ シリアルポートを通して送られてきたデータを読み取る 4 | 5 | データの内容をチェックして、OKならファイルへ送る。チェック内容は 6 | * データフォーマット、型 7 | * 重複受信(無線通信での再送を検出) 8 | * 外れ値(チェッカー) 9 | """ 10 | 11 | import re 12 | import io 13 | import os 14 | import time 15 | import threading 16 | import serial 17 | import queue 18 | from abc import ABCMeta, abstractmethod 19 | 20 | from keilib.worker import Worker 21 | 22 | from logging import getLogger, StreamHandler, DEBUG 23 | logger = getLogger(__name__) 24 | 25 | class Checker (metaclass=ABCMeta): 26 | """センサー値をチェックするクラス(アブストラクト) 27 | """ 28 | @abstractmethod 29 | def check(self, unit, sensor, value): 30 | """ユニット、センサー、その値を受け取りチェックしてTrueまたはFalseを返す 31 | オーバーライドする 32 | """ 33 | pass 34 | 35 | class OutlierChecker ( Checker ): 36 | """センサーごとに必要があれば外れ値を定義しチェックする 37 | 38 | 外れ値とは、 39 | * 定義域を外れた値 40 | * 一定値以上に大きな変動を示した値 41 | """ 42 | 43 | def __init__(self): 44 | """コンストラクタ""" 45 | super().__init__() 46 | self.check_list = {} 47 | 48 | def check(self, unit, sensor, value): 49 | """引数に与えたセンサーと値の組について、 50 | 51 | 外れ値であればFalseを返す 52 | 引数: 53 | unit (str): ユニットの識別子 54 | sensor (str): センサーの識別子 55 | value (number): データの値 56 | """ 57 | checkid = sensor + '_' + unit 58 | if checkid in self.check_list.keys(): 59 | sensorData = self.check_list[checkid] 60 | max = sensorData['max'] 61 | min = sensorData['min'] 62 | variation = sensorData['variation'] 63 | 64 | if not(min <= value <= max): 65 | return False 66 | 67 | # 前回値との比較 68 | if 'prev' in sensorData.keys(): 69 | prev = sensorData['prev'] 70 | if abs(value - prev) > variation: 71 | sensorData['count'] += 1 72 | 73 | if sensorData['count'] < 3: 74 | return False 75 | 76 | sensorData['prev'] = value 77 | sensorData['count'] = 0 78 | return True 79 | 80 | else: 81 | return True 82 | 83 | def add(self, unit, sensor, min, max, variation): 84 | """チェックリストにセンサーの定義域と変動範囲を指定する 85 | 引数; 86 | unit (str): ユニット識別子 unitid 87 | sensor (str): センサーの識別子 sensorid 88 | min (number): 最小値、これより小さい値は外れ値とみなす 89 | max (number): 最大値、これより大きい値は外れ値とみなす 90 | variation (number): 変動範囲、これより大きな変動は外れ値とみなす。 91 | ただし、3回目以上変動の外れ値が続いたら、それを新しい基準とする。 92 | """ 93 | checkid = sensor + '_' + unit 94 | self.check_list[checkid] = {'min': min, 'max': max, 'variation': variation} 95 | logger.debug('add ' + checkid) 96 | return True 97 | 98 | class SerialReader( Worker ): 99 | """シリアルポートからデータ(1行)を読み取り、内容をチェックした上で record_queに送信する。 100 | 101 | * 無効な形式のデータを破棄 102 | * checkerが指定されている場合、それを使用して外れ値を確認 103 | * 再送されたデータの受信の破棄(無線の場合に起こる) 104 | """ 105 | 106 | def __init__(self, port, baudrate, record_que=None, checker=None ): 107 | """コンストラクタ 108 | 109 | 引数: 110 | port (str): 記録するシリアルポートのデバイス文字列 111 | baudrate (int): ボーレート(9600、19200、... ) 112 | record_que (Queue): FileRecorderオブジェクトにデータを送信する 113 | cheker ( Checker ): 値をチェックする 114 | 115 | シリアル通信の他のパラメータは以下固定 116 | - データビット: 8bit 117 | - パリティビット: なし 118 | - ストップビット: 1bit 119 | - タイムアウト: 0.1秒 120 | − フロー制御(ソフト/ハード): OFF 121 | """ 122 | super().__init__() 123 | self.fileNameBase = port.split('/').pop(-1) 124 | self.record_que = record_que 125 | self.port = port 126 | self.baudrate = baudrate 127 | self.recent = [] 128 | # self.buff = [] 129 | self.rechkline = re.compile(r'^[a-zA-Z0-9_;:., -]*$') 130 | self.rechkid = re.compile(r'^[a-zA-Z0-9-_]+$') 131 | self.dataID = 0 132 | self.checker = checker 133 | self.errorcount = 0 134 | if os.path.exists(self.port): 135 | ser = serial.Serial( 136 | port = self.port, 137 | baudrate = self.baudrate, 138 | bytesize = serial.EIGHTBITS, 139 | parity = serial.PARITY_NONE, 140 | stopbits = serial.STOPBITS_ONE, 141 | timeout = 0.1, 142 | xonxoff = False, 143 | rtscts = False, 144 | dsrdtr = False 145 | ) 146 | self.ser = ser 147 | 148 | # テキストIOの作成 149 | self.ser_io = io.TextIOWrapper(io.BufferedRWPair(ser, ser, 1), 150 | newline = '\n', 151 | line_buffering = False 152 | ) 153 | 154 | 155 | def run(self): 156 | """スレッド処理""" 157 | 158 | while not os.path.exists(self.port): 159 | # ポートが見つかるまで待機 160 | logger.warning("port not found : " + self.port) 161 | time.sleep(60) 162 | 163 | logger.info('[START] port=' + self.port + ', boudrate=' + str(self.baudrate)) 164 | 165 | 166 | while not self.stopEvent.is_set(): 167 | # ストップイベントが設定されるまで繰り返す 168 | 169 | try: 170 | line = self.ser_io.readline(); 171 | self.errorcount = 0 172 | 173 | except UnicodeDecodeError as err: 174 | logger.warning('Unicode Decode Error in ser_io.readline(), port=' + self.fileNameBase) 175 | time.sleep(5) 176 | self.errorcount += 1 177 | if self.errorcount > 10: 178 | break 179 | continue 180 | 181 | except: 182 | logger.error('Unknown Error in ser_io.readline(), port=' + self.fileNameBase) 183 | time.sleep(5) 184 | self.errorcount += 1 185 | if self.errorcount > 10: 186 | break 187 | continue 188 | 189 | # 空行の場合スキップ 190 | line = line.strip() 191 | if line == '': 192 | continue 193 | 194 | # 文字化け等の不正データのスキップ 195 | if not self.rechkline.match(line): 196 | logger.warning('Receiving a invalid data, port=' + str(self.fileNameBase)) 197 | continue 198 | 199 | # Data extraction(データ抽出) 200 | line_list = line.split(',') 201 | # unit, sensor, valueの3つが必要 202 | if len(line_list) < 3: 203 | logger.warning('incomplete data, port=' + str(self.fileNameBase) + ' data=' + line) 204 | continue 205 | 206 | unit = line_list.pop(0).strip() 207 | sensor = line_list.pop(0).strip() #[:5] 208 | valueStr = line_list.pop(0).strip() 209 | 210 | # When dataID exists(さらにdataIDがある場合) 211 | if len(line_list) > 0: 212 | dataID = line_list.pop(0).strip() 213 | else: 214 | dataID = str(self.dataID) 215 | self.dataID += 1 216 | if self.dataID > 100: 217 | self.dataID = 0 218 | 219 | # unitのチェック 220 | if not self.rechkid.match(unit): 221 | logger.warning('invarid unit id. "' + unit + '" port=' + self.fileNameBase) 222 | continue 223 | 224 | # sensorのチェック 225 | if not self.rechkid.match(sensor): 226 | logger.warning('invarid sensor id. "' + sensor + '" port=' + self.fileNameBase) 227 | continue 228 | 229 | # valueが有効な数値であるか 230 | try: 231 | value = float(valueStr) 232 | except: 233 | logger.warning('invalid numeric value ="' + valueStr + '", port=' + str(self.fileNameBase)) 234 | continue 235 | 236 | # 重複データのチェック(無線の再送処理等で同じデータを受信した場合) 237 | line = unit + ',' + sensor + ',' + valueStr + ',' + dataID 238 | if line in self.recent: 239 | logger.debug('Receiving a duplicated data, port=' + str(self.fileNameBase) + ', data=' + line) 240 | continue 241 | 242 | # 受信データの記録(過去10件分) 243 | self.recent.insert(0, line) 244 | if len(self.recent) > 10: 245 | self.recent.pop() 246 | 247 | # 外れ値のチェック 248 | if not self.checker is None: 249 | if not self.checker.check(unit, sensor, value): 250 | logger.error('sensor value outlier error ' + sensor + '_' + unit + ': ' + str(value)) 251 | continue 252 | 253 | # 一行書き出す 254 | if self.record_que is None: 255 | logger.error('file queue does not exist.') 256 | else: 257 | try: 258 | self.record_que.put([unit, sensor, value, dataID], block=False) 259 | except queue.Full: 260 | logger.error('record_queue is full') 261 | continue 262 | 263 | self.ser.close() 264 | logger.info('[STOP] port=' + self.port) 265 | -------------------------------------------------------------------------------- /keilib/broute.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """WiSUNドングル RL7023 Stick-D/DSS または IPS を用いてスマートメーターから電力情報を取得する。 4 | """ 5 | 6 | import sys 7 | import serial 8 | import time 9 | import datetime 10 | import os 11 | import json 12 | import threading 13 | import queue 14 | from abc import ABCMeta, abstractmethod 15 | 16 | from keilib.worker import Worker 17 | 18 | from logging import getLogger, StreamHandler, DEBUG 19 | logger = getLogger(__name__) 20 | 21 | reginfo = { 22 | 'S01': 'MAC address', #'MACアドレス(64bit)', 23 | 'S02': 'channel number', #'チャンネル番号(33-60)', 24 | 'S03': 'PAN ID', #'PAN ID(16bit)', 25 | 'S07': 'frame counter', # 'フレームカウンタ(32bit)', 26 | 'S0A': 'Pairing ID', #(8文字)', 27 | 'S0B': 'Pairing ID(HAN)', #(16文字)', 28 | 'S15': 'beacon response flag', #'ビーコン応答フラグ(0:無視、1:応答)', 29 | 'S16': 'PANA session life time', #'PANAセッションライフタイム(秒:0x60〜0xFFFFFFFF)', 30 | 'S17': 'auto rejoin flag', #'自動再認証フラグ(0:無効、1:再認証自動実行)', 31 | 'S1C': 'PAA key update cycle time', #'PAA鍵更新周期(HAN)(秒:0x60〜0x2592000)', 32 | 'S1F': 'relay device MAC address', # 'リレーデバイスアドレス', 33 | #'S64': 'アンテナ切り替え', 34 | 'SA1': 'ICMP response flag', # 'ICMPメッセージ処理制御(平文のメッセージ 0:破棄、1:受入処理)', 35 | 'SA2': 'ERXUDP event style', # 'ERXUDPイベント形式フラグ(1:RSSI含める, 0:含めない)', 36 | 'SA9': 'transmition and receive enabled', #'送受信有効フラグ(0:無効、1:有効)', 37 | 'SF0': 'active side', #'アクティブMAC面の指定(0:B面、1:H面)', 38 | 'SFB': 'transmition restriction flag', # '送信時間制限中フラグ(1:制限中)', 39 | 'SFD': 'transmition total time', #'無線送信の積算時間(ms)', 40 | 'SFE': 'echo back flag',#'エコーバックフラグ(0:なし、1:あり)', 41 | 'SFF': 'auto load',#'オートロード(0:無効、1:有効)' 42 | } 43 | 44 | def is_hex( data, length=0 ): 45 | """妥当な16進数かどうかをチェック 46 | 引数: 47 | data (str):チェックする文字列 48 | length (int) = 0: 指定すると長さチェックも行う 49 | 戻り値: 50 | True/False 51 | """ 52 | if length: 53 | if len(data) != length: 54 | return False 55 | for ch in data: 56 | if not ch in '0123456789ABCDEF': 57 | return False 58 | return True 59 | 60 | def is_ipv6_address( addr ): 61 | """妥当なipv6アドレスであるかチェック 62 | 引数: 63 | addr (str): チェックするアドレス文字列 64 | 戻り値: 65 | True/False 66 | """ 67 | lst = addr.split(':') 68 | if len(lst) != 8: 69 | return False 70 | for word in lst: 71 | if len(word) != 4: 72 | return False 73 | if not is_hex(word): 74 | return False 75 | return True 76 | 77 | def hex_to_signed_int(value, digit=0): 78 | """16進文字列データを2の補数による unsigned int として整数に変換する 79 | 引数: 80 | value (str): 変換する16進表記文字列 81 | digit (int)=0: 16進数の桁数。省略するとvalueの長さを桁数とみなす 82 | 戻り値: 83 | 変換結果の整数 84 | """ 85 | if digit == 0: 86 | digit = len(value) 87 | digit2 = digit * 4 88 | fmtstr = '{:0' + str(digit2) + 'b}' 89 | bits = fmtstr.format(int(value,16)) 90 | return -int(bits[0]) << digit2 | int(bits, 2) 91 | 92 | class WiSunDevice ( metaclass=ABCMeta ): 93 | """WiSUN デバイスドライバ抽象クラス 94 | 95 | 以下のメソッドをインプリメントすると BrouteReader から利用できる 96 | """ 97 | @abstractmethod 98 | def open( self ): 99 | """デバイスのオープン,ハードウェアの認識 100 | 戻り値: True(成功)/False(失敗) 101 | """ 102 | pass 103 | 104 | @abstractmethod 105 | def reset( self ): 106 | """デバイスのリセット,レジスタ等を初期化 107 | 戻り値: True(成功)/False(失敗) 108 | """ 109 | pass 110 | 111 | @abstractmethod 112 | def setup( self, id, password ): 113 | """アクティブスキャンの準備(必要な情報をセット) 114 | 引数: id/password BルートID/パスワード 115 | 戻り値: True(成功)/False(失敗) 116 | """ 117 | pass 118 | 119 | @abstractmethod 120 | def scan( self ): 121 | """アクティブスキャンの実行、結果の保持 122 | 戻り値: True(成功)/False(失敗) 123 | """ 124 | pass 125 | 126 | @abstractmethod 127 | def join( self ): 128 | """PANA認証の実行 129 | 戻り値: True(成功)/False(失敗) 130 | """ 131 | pass 132 | 133 | @abstractmethod 134 | def rejoin( self ): 135 | """PANA再認証の実行 136 | 戻り値: True(成功)/False(失敗) 137 | """ 138 | pass 139 | 140 | @abstractmethod 141 | def sendto( self, dataframe ): 142 | """Echonet Lite データフレームの送信 143 | 引数: 144 | dataframe: Echonet 電文(DataFrame オブジェクト) 145 | 戻り値: True(成功)/False(失敗) 146 | """ 147 | pass 148 | 149 | @abstractmethod 150 | def receive( self ): 151 | """Echonet Lite データフレームの受信 152 | 戻り値: 受信した電文からつくられた DataFrame オブジェクト 153 | """ 154 | pass 155 | 156 | @abstractmethod 157 | def term( self ): 158 | """PANAセッションの終了 159 | 戻り値: True(成功)/False(失敗) 160 | """ 161 | pass 162 | 163 | @abstractmethod 164 | def close( self ): 165 | """デバイスのクローズ,デバイスの開放 166 | 戻り値: True(成功)/False(失敗) 167 | """ 168 | pass 169 | 170 | class WiSunRL7023 ( WiSunDevice ): 171 | """RL7023 Stick-D 用デバイスドライバ 172 | 173 | テセラテクノロジーの HEMS 用 Wi-SUN モジュール Route-B/HAN デュアル対応 174 | RL7023 Stick-D/DSS または シングルタイプの D/IPS を制御するクラス。 175 | """ 176 | IPS=0 177 | DSS=1 178 | def __init__( self, port, baud , type=DSS): 179 | """コンストラクタ 180 | 引数: 181 | port (str): RL7023 のシリアルポートを示すファイルパス 182 | baud (int): 通信ボーレート 183 | """ 184 | self.port = port 185 | self.baud = baud 186 | self.type = type 187 | self.register = {} 188 | self.scanresult = {} 189 | 190 | self._TIMEOUT_MAX = 20 191 | self._TIMEOUT_SCAN = 300 192 | 193 | def _wait_ok( self ): 194 | """デバイスから返り値 OK を待つ 195 | 戻り値: 196 | True: OKを得られた 197 | False: OK以外の文字を得たかタイムアウトした 198 | """ 199 | toc = 0 200 | while True: 201 | res = self.ser.readline() 202 | if res[:2] == b'OK': 203 | logger.debug('OK') 204 | return True 205 | elif res == b'': 206 | toc += 1 207 | if toc > self._TIMEOUT_MAX: 208 | logger.debug('time out') 209 | return False 210 | else: 211 | # それ以外は読み飛ばし。エコーバック等が来る 212 | pass 213 | 214 | def _set_panid( self, pan_id ): 215 | """デバイスのレジスタ(S3)に Pan ID をセットする 216 | 217 | 戻り値: 218 | True: 成功 219 | False: 失敗 220 | """ 221 | cmd = 'SKSREG S3 ' + pan_id + '\r\n' 222 | logger.info(cmd.strip()) 223 | self.ser.write(cmd.encode('ascii')) 224 | if self._wait_ok(): 225 | return True 226 | else: 227 | return False 228 | 229 | def _set_channel( self, channel ): 230 | """デバイスのレジスタ(S2)に Channel をセットする 231 | 232 | 戻り値: 233 | True: 成功 234 | False: 失敗 235 | """ 236 | cmd = 'SKSREG S2 ' + channel + '\r\n' 237 | logger.info(cmd.strip()) 238 | self.ser.write(cmd.encode('ascii')) 239 | if self._wait_ok(): 240 | return True 241 | else: 242 | return False 243 | 244 | def _readline( self ): 245 | """SKデバイスから1行読み込む 246 | 247 | 戻り値: 248 | 読み込みデータ 249 | """ 250 | return self.ser.readline() 251 | 252 | def _parse_event( self, line ): 253 | """読み取った一行のイベントデータを、スペースを区切り文字として分解し、チェックして辞書に登録 254 | 255 | 戻り値: 256 | 変換した結果辞書(空の文字列に対しては空の辞書を返す) 257 | 258 | ToDo: 259 | ERXUDP, EVENT 以外のイベントへの対応 260 | """ 261 | # 有効でないASCII文字コードを含む場合は終了 262 | for b in line: 263 | if b >= 0x80: 264 | return {'NAME': 'INVALID_EVENT'} 265 | 266 | # 行をリストに変換 267 | list = line.decode('ascii').strip().split() 268 | if len(list) == 0: 269 | return {} 270 | 271 | 272 | # 1. ERXUDP イベントの場合 273 | # ERXUDP 274 | # 0 1 2 3 4 5 6 7 8 9 275 | """ 276 | 【 SKコマンドにおける ERXUDP イベント の構成 】 277 | 0 ERXUDP 278 | 1 XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX 279 | 2 XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX 280 | 3 0E1A 281 | 4 0E1A 282 | 5 XXXXXXXXXXXXXXXX 283 | 6 1 284 | 7 0 285 | 8 0012 286 | 9 1081000102880105FF017201E704000004A5 287 | 部が Echonet データフレーム 288 | 289 | 参考文献: 290 | SKIP_Command_dse_v1_02a.pdf(商品を購入して、製品登録すれば入手できる) 291 | """ 292 | if list[0] == 'ERXUDP': 293 | idx = 0 294 | if self.type == self.DSS: 295 | length = 10 296 | else: 297 | length = 9 298 | 299 | # データ数のチェック 300 | if len(list) != length: 301 | return {'NAME': 'INVALID_ERXUDP', 'LIST': list} 302 | 303 | # IPV6 アドレスとして正しいかどうかチェック 304 | for i in [1,2]: 305 | if not is_ipv6_address(list[i]): 306 | logger.debug('invalid IPV6') 307 | return {'NAME': 'INVALID_ERXUDP', 'LIST': list} 308 | 309 | # ポート番号、4byte であるか 310 | for i in [3,4]: 311 | if not is_hex(list[i], length=4): 312 | logger.debug('invalid PORT') 313 | return {'NAME': 'INVALID_ERXUDP', 'LIST': list} 314 | 315 | # SENDERLLA ローカルアドレスが 16byte であるか 316 | if not is_hex(list[5], length=16): 317 | logger.debug('invalid SENDERLLA') 318 | return {'NAME': 'INVALID_ERXUDP', 'LIST': list} 319 | 320 | # SECURED、が 1byte であるか 321 | if not is_hex(list[6], length=1): 322 | logger.debug('invalid SECURED') 323 | return {'NAME': 'INVALID_ERXUDP', 'LIST': list} 324 | 325 | idx = 7 326 | if self.type == self.DSS: 327 | # SIDE が 1byte であるか 328 | if not is_hex(list[7], length=1): 329 | logger.debug('invalid SIDE') 330 | return {'NAME': 'INVALID_ERXUDP', 'LIST': list} 331 | idx = 8 332 | else: 333 | idx = 7 334 | 335 | # DATALEN が 4byte であるか 336 | if not is_hex(list[idx], length=4): 337 | logger.debug('invalid DATALEN') 338 | return {'NAME': 'INVALID_ERXUDP', 'LIST': list} 339 | 340 | # DATAが HEX 値として読み取れる文字列か 341 | if not is_hex(list[idx+1]): 342 | logger.debug('invalid DATA') 343 | return {'NAME': 'INVALID_ERXUDP', 'LIST': list} 344 | 345 | dict = {'NAME':list[0], 'SENDER': list[1], 'DEST': list[2], 'RPORT': list[3], 346 | 'LPORT': list[4], 'SENDERLLA': list[5], 'SECURED': list[6], 347 | 'DATALEN': list[idx], 'DATA': list[idx+1]} 348 | 349 | return dict 350 | 351 | # 2. EVENT の場合 352 | elif list[0] == 'EVENT': 353 | if len(list) < 3: 354 | return {} 355 | 356 | #dict = {'NAME': list[0], 'NUM': list[1], 'SENDER': list[2], 'SIDE': list[3]} 357 | dict = {'NAME': list[0], 'NUM': list[1], 'SENDER': list[2]} 358 | #if len(list) > 3: 359 | # dict['PARAM'] = list[4] 360 | # return dict 361 | return dict 362 | 363 | # 3. その他のイベントの場合(EPONG, EADDR, ENEIGHBOR, EPANDESC, EEDSCAN, ESEC, ENBR) 364 | else: 365 | return {'NAME': 'OTHER_EVENT', 'LIST': list} 366 | 367 | return dict 368 | 369 | def _get_event( self ): 370 | """デバイスからイベントを読み取る 371 | 372 | 戻り値: 373 | 読み込んだイベント辞書(タイムアウトの場合は空の辞書) 374 | """ 375 | # SKデバイスから一行読み取る。タイムアウト設定あり。self._readline() 376 | line = self._readline().strip() 377 | event = self._parse_event(line) 378 | 379 | if event: 380 | logger.debug('---- event info -----') 381 | logger.debug(line.decode('ascii')) 382 | logger.debug(event) 383 | 384 | return event 385 | 386 | def _set_password( self, broute_pwd ): 387 | """デバイスにパスワードを登録する 388 | 戻り値: 389 | True: 成功 390 | False: 失敗 391 | """ 392 | cmd = 'SKSETPWD C ' + broute_pwd + '\r\n' 393 | logger.debug(cmd.strip()) 394 | self.ser.write(cmd.encode('ascii')) 395 | if self._wait_ok(): 396 | return True 397 | else: 398 | return False 399 | 400 | def _set_id( self, broute_id ): 401 | """デバイスにBルート認証IDを登録する 402 | 戻り値: 403 | True: 成功 404 | False: 失敗 405 | """ 406 | cmd = 'SKSETRBID ' + broute_id + '\r\n' 407 | logger.debug(cmd.strip()) 408 | self.ser.write(cmd.encode('ascii')) 409 | if self._wait_ok(): 410 | return True 411 | else: 412 | return False 413 | 414 | def _get_registers( self ): 415 | """デバイスのレジスタの値を読み出して記憶する。""" 416 | for key, description in sorted(reginfo.items()): 417 | cmd = 'SKSREG ' + key + '\r\n' 418 | self.ser.write(cmd.encode('ascii')) 419 | 420 | toc = 0 421 | while True: 422 | res = self.ser.readline() 423 | if res[:6] == b'ESREG ': 424 | val = res.decode('ascii').strip().split()[1] 425 | self.register[key] = val 426 | logger.info(key + ' ' + description + ' : ' + val ) 427 | 428 | else: 429 | pass 430 | 431 | if res[:2] == b'OK': 432 | break 433 | 434 | toc += 1 435 | if toc > 5: 436 | break 437 | 438 | def _scancache( self ): 439 | """スキャン結果のキャッシュが一時間以内であれば、それを使う""" 440 | if os.path.exists('scancache.json'): 441 | mtime = os.stat('scancache.json').st_mtime 442 | now = datetime.datetime.now().timestamp() 443 | json_data = {} 444 | if now - mtime < 3600: 445 | try: 446 | with open("scancache.json", 'r') as f: 447 | json_data = json.load(f) 448 | if {'Pan ID','Channel','Addr'} <= json_data.keys(): 449 | return json_data 450 | else: 451 | os.remove('scancache.json') 452 | return {} 453 | except: 454 | return {} 455 | else: 456 | return{} 457 | else: 458 | return {} 459 | 460 | def _scanexec( self ): 461 | """アクティブスキャンを実施する 462 | 463 | ToDo: 464 | sideを省略すれば片面用になるか? 465 | """ 466 | 467 | # SKSCANコマンドの設定 468 | mode = 2 # アクティブスキャン(Information Element あり) 469 | mask = 'FFFFFFFF' # スキャンするチャンネルを指定するマスク(32bit) 最下位ビットが ch33 470 | duration = 7 # スキャン時間 1増えると2倍の時間がかかる 471 | side = 0 # 0: B route 472 | 473 | scanresult = {} 474 | # Step1 デバイスにスキャンコマンドを送る 475 | if self.type == self.DSS: 476 | cmd = 'SKSCAN ' + str(mode) + ' ' + mask + ' ' \ 477 | + str(duration) + ' ' + str(side) + '\r\n' 478 | else: 479 | cmd = 'SKSCAN ' + str(mode) + ' ' + mask + ' ' \ 480 | + str(duration) + '\r\n' 481 | 482 | logger.info(cmd.strip()) 483 | self.ser.write(cmd.encode('ascii')) 484 | # デバイスからOKが返されるのを待つ 485 | if not self._wait_ok(): 486 | return False 487 | 488 | # Step2 スキャン結果がイベントとして返されるのを待つ 489 | # 様々なイベントが発生するのでそれぞれ処理する 490 | toc = 0 491 | while True: 492 | # 一行読み取る(タイムアウト1秒) 493 | res = self.ser.readline() 494 | 495 | if res == b'': 496 | # データ無し。タイムアウト時など。 497 | toc += 1 498 | if toc > self._TIMEOUT_SCAN: 499 | return False 500 | 501 | elif res[:8] == b'EVENT 22': 502 | # EVENT 22:アクティブスキャンが完了 -> ループ終了 503 | logger.info('EVENT: 22') 504 | break 505 | 506 | elif res[:8] == b'EVENT 20': 507 | # EVENT 20:Beaconを受信した 508 | # 直後に EPANDESC イベントが発生し、スキャン結果が表示される 509 | logger.info('EVENT: 20') 510 | 511 | elif res[:8] == b'EPANDESC': 512 | # EPANDESC:スキャンで発見したPANを通知するイベント(一旦改行される) 513 | logger.info('EPANDESC') 514 | 515 | elif res[:2] == b' ': 516 | # EPANDESC イベントに続いてスキャン結果が流れてくるので読み込んでゆく 517 | # 行頭のスペース2個に続いて [param]:[value]+が繰り返して送られる 518 | # [param] = "Channel","Channel Page","Pan ID","Addr","LQI","PairID" 519 | lst = res.decode('ascii').strip().split(':') 520 | logger.info(' ' + res.decode('ascii').strip()) 521 | scanresult[lst[0]] = lst[1] 522 | 523 | else: 524 | logger.info('Unkown EVENT : ' + res.decode('ascii')) 525 | 526 | logger.debug('active scan result') 527 | logger.debug(scanresult) 528 | if {'Pan ID','Channel','Addr'} <= scanresult.keys(): 529 | # スキャン結果に必要な情報が含まれている場合 530 | with open("scancache.json", 'w') as f: 531 | # スキャン結果のキャッシュへの書込み 532 | json.dump(scanresult, f, indent=4) 533 | return scanresult 534 | 535 | else: 536 | return {} 537 | 538 | def open( self ): 539 | """ デバイスのシリアルポートをオープンする。 540 | """ 541 | self.ser = serial.Serial( 542 | port = self.port, 543 | baudrate = self.baud, 544 | bytesize = serial.EIGHTBITS, 545 | parity = serial.PARITY_NONE, 546 | stopbits = serial.STOPBITS_ONE, 547 | timeout = 1, 548 | xonxoff = False, 549 | rtscts = False, 550 | dsrdtr = False 551 | ) 552 | # self.ser.timeout = 1 553 | logger.info('SKDevice open port={}, baud={}'.format(self.port, self.baud)) 554 | return True 555 | 556 | def reset( self ): 557 | """デバイスにリセットコマンドを送る 558 | 戻り値: 559 | True: 成功 560 | False: 失敗 561 | """ 562 | cmd = 'SKRESET\r\n' 563 | logger.debug(cmd.strip()) 564 | self.ser.write(cmd.encode('ascii')) 565 | if self._wait_ok(): 566 | return True 567 | else: 568 | return False 569 | 570 | def setup( self, id, password ): 571 | """デバイスにBルートIDおよびパスワードを登録し、スキャンの前準備 572 | 戻り値: 573 | True: 成功 574 | False: 失敗 575 | """ 576 | r1 = self._set_password(password) 577 | r2 = self._set_id(id) 578 | if r1 and r2: 579 | return True 580 | else: 581 | return False 582 | 583 | def scan( self ): 584 | """アクティブスキャンを実行する。結果を self.scanresult 辞書に格納 585 | 戻り値: 586 | True: 成功 587 | False: 失敗 588 | """ 589 | self.scanresult = {} 590 | 591 | # Step1 スキャン結果のキャッシュがあればそれを使う,なければスキャン実行 592 | self.scanresult = self._scancache() 593 | if not self.scanresult: 594 | self.scanresult = self._scanexec() 595 | if not self.scanresult: 596 | # スキャン失敗 597 | return False 598 | 599 | # Step2 スキャン結果を使ったデバイスの設定(通信先の情報を設定する) 600 | if {'Pan ID', 'Channel', 'Addr'} <= self.scanresult.keys(): 601 | # スキャン結果の核心部がちゃんと取得できてる 602 | 603 | # SKデバイスのレジスタに、スキャンで得られたPSN_IDとチャネル番号を設定する。 604 | self._set_panid(self.scanresult['Pan ID']) 605 | self._set_channel(self.scanresult['Channel']) 606 | 607 | # SKLL64コマンド: 64ビットMACアドレスをIP_V6アドレスに変換する 608 | cmd = 'SKLL64 ' + self.scanresult['Addr'] + '\r\n' 609 | logger.info(cmd.strip()) 610 | self.ser.write(cmd.encode('ascii')) 611 | self.ser.readline() #エコーバック読み飛ばし 612 | self.ipv6_addr = self.ser.readline().strip().decode('ascii') 613 | logger.info('IP_ADDR = ' + self.ipv6_addr) 614 | 615 | # ここまできたらスキャン成功とする 616 | return True 617 | 618 | else: 619 | # 実はスキャン成功してなかった 620 | self.scanresult = {} 621 | return False 622 | 623 | def join( self , rejoin=False): 624 | '''PANA認証クライアントとしてPANA認証シーケンスを開始 625 | 626 | 引数: 627 | rejoin (bool): リジョインの場合はTrue(デフォルトはTrue) 628 | 629 | 戻り値: 630 | True: 成功 631 | False: 失敗 632 | ''' 633 | # Step1 デバイスに SKJOIN コマンドを送信する。 指定したIP6アドレスとPANA認証シーケンスを開始する。 634 | if rejoin: 635 | cmd = 'SKREJOIN\r\n' 636 | else: 637 | cmd = 'SKJOIN ' + self.ipv6_addr + '\r\n' 638 | logger.info(cmd.strip()) 639 | self.ser.write(cmd.encode('ascii')) 640 | 641 | # Step2 イベントを監視し、接続先からの返答を待つ EVENT 24が得られるとJoin成功 642 | toc = 0 643 | while True: 644 | event = self._get_event() 645 | 646 | if event: 647 | if event['NAME'] == 'EVENT': 648 | if event['NUM'] == '25': 649 | # EVENT 25: PANAによる接続が完了した。 650 | logger.info('EVENT: 25 - JOIN SUCCEED') 651 | return True 652 | 653 | elif event['NUM'] == '24': 654 | # EVENT 24: PANAによる接続過程でエラーが発生した 655 | logger.info('EVENT: 24 - JOIN FAILED') 656 | return False 657 | 658 | else: 659 | logger.info('EVENT: ' + event['NUM']) 660 | logger.debug(event) 661 | 662 | elif event['NAME'] == 'ERXUDP': 663 | logger.info('ERXUDP') 664 | logger.debug(event) 665 | 666 | else: 667 | logger.info(event) 668 | pass 669 | 670 | else: 671 | # データ無し、一定回数でタイムアウト 672 | toc += 1 673 | logger.debug('no evnet') 674 | if toc > self._TIMEOUT_MAX: 675 | logger.info('timeout') 676 | return False 677 | 678 | def rejoin( self ): 679 | """PANA認証状態で再認証を行い、暗号化キーの更新を行う 680 | 681 | RL7023 Stick-D/DSS はデフォルトで自動再認証を行うため通常は必要ない。 682 | 683 | 戻り値: 684 | True: 成功 685 | False: 失敗 686 | """ 687 | return self.join(rejoin=True) 688 | 689 | def sendto( self, dataframe ): 690 | """スマートメーターにコマンドを送信する 691 | 692 | 引数: 693 | dataframe(byte): スマートメーターに送信する Echonet電文 694 | 695 | 戻り値: 696 | True: 成功 697 | False: 失敗 698 | 699 | ToDo: 700 | 701 | """ 702 | byteframe = bytes.fromhex(dataframe.encode()) 703 | # コマンド送信 704 | # udp_handle 705 | # | addr port 706 | # | | | security 1:encrypt 707 | # | | | | side 0:B-route 708 | # | | | | | length 709 | # | | | | | | 710 | if self.type == self.DSS: 711 | cmd = "SKSENDTO 1 {0} 0E1A 1 0 {1:04X} ".format(self.ipv6_addr, len(byteframe)).encode('ascii') 712 | else: 713 | cmd = "SKSENDTO 1 {0} 0E1A 1 {1:04X} ".format(self.ipv6_addr, len(byteframe)).encode('ascii') 714 | cmd += byteframe # + b'\r\n' 715 | self.ser.write(cmd) 716 | evt21 = False 717 | toc = 0 718 | while True: 719 | res = self.ser.readline() 720 | if res == b'': 721 | toc += 1 722 | if toc > self._TIMEOUT_MAX: 723 | logger.debug('timeout') 724 | return False 725 | 726 | elif res[:8] == b'EVENT 21': 727 | logger.debug(res.decode('ascii').strip()) 728 | evt21 = True 729 | 730 | elif res[:2] == b'OK': 731 | logger.debug(res.decode('ascii').strip()) 732 | return evt21 733 | 734 | else: 735 | #logger.debug(res.decode('ascii').strip()) 736 | logger.debug('unknown response') 737 | 738 | def receive( self ): 739 | """スマートメーターからの電文を受信する 740 | 741 | 戻り値: 742 | スマートメーターからの電文に対応する DataFrame オブジェクト 743 | """ 744 | 745 | event = self._get_event() 746 | 747 | # 受信イベント処理 748 | if event: 749 | if event['NAME'] == 'ERXUDP': 750 | 751 | dataframe = DataFrame.decode(event['DATALEN'], event['DATA']) 752 | 753 | if dataframe: 754 | logger.debug([event['DATA'], dataframe.endict()]) 755 | return dataframe 756 | 757 | else: 758 | logger.error('invalid ERXUDP data frame') 759 | logger.error(event) 760 | pass 761 | 762 | # ERXUDP以外のイベントの処理 763 | else: 764 | logger.warning('other EVENT') 765 | logger.warning(event) 766 | pass 767 | 768 | return None 769 | 770 | def term( self ): 771 | """デバイスに SKTERM コマンドを送り、PANAセッションの終了を要請 772 | 773 | 戻り値: 774 | True: 成功 775 | False: 失敗 776 | """ 777 | cmd = 'SKTERM\r\n' 778 | logger.info(cmd.strip()) 779 | self.ser.write(cmd.encode('ascii')) 780 | if self._wait_ok(): 781 | #return True 782 | pass 783 | else: 784 | return False 785 | 786 | toc = 0 787 | while True: 788 | event = self._get_event() 789 | 790 | if event: 791 | if event['NAME'] == 'EVENT': 792 | if event['NUM'] == '27': 793 | # EVENT 27: セッション終了が成功 794 | logger.info('EVENT: 27 - TERM SUCCEED') 795 | return True 796 | 797 | elif event['NUM'] == '28': 798 | # EVENT 28: タイムアウトしてセッション終了となった 799 | logger.info('EVENT: 28 - TERM TIMEOUT, Session terminate') 800 | return True 801 | 802 | else: 803 | logger.info('EVENT: ' + event['NUM']) 804 | logger.debug(event) 805 | 806 | elif event['NAME'] == 'ERXUDP': 807 | logger.info('ERXUDP') 808 | logger.debug(event) 809 | else: 810 | logger.info(event) 811 | pass 812 | else: 813 | # データ無し、一定回数でタイムアウト 814 | toc += 1 815 | logger.debug('no evnet') 816 | if toc > self._TIMEOUT_MAX: 817 | logger.info('timeout') 818 | return False 819 | 820 | def close( self ): 821 | """デバイスのシリアルポートをクローズする""" 822 | self.ser.close() 823 | logger.info('skdevice closed. ') 824 | 825 | class DataFrame ( ): 826 | '''EchonetBroute用のデータフレームを定義する 827 | 828 | 【 EchonetBroute-Lite データフレーム の構成 】 829 | 0 1 2 3 4 5 6 7 8 830 | ------------------------------------------------ 831 | EHD TID SEOJ DEOJ ESV OPC EPC PDC EDT 832 | 1081 0001 028801 05FF01 72 01 E7 04 000004A5 833 | ------------------------------------------------ 834 | 0 EHD: Echonet Lite header 0x1081 で EchonetBroute Lite。スマートメータはこれで固定 835 | 1 TID: Transaction ID 要求-応答を対応付ける番号(要求時に自由につける) 836 | 2 SEOJ: Sender Echonet ObJect (id) 送り手側機器オブジェクトID 837 | 3 DEOJ: Destination Echonet ObJect (id) 宛先側機器オブジェクトID 838 | 機器オブジェクトIDの例 839 | 05FF01 = Raspi側 840 | 05: 管理グループコード 841 | FF: コントローラークラス 842 | 01: インスタンス番号 843 | 028801 = スマートメータ側 844 | 02: 住宅・設備関連機器クラスグループ 845 | 88: 低圧スマートメータークラス 846 | 01: インスタンス番号 847 | 0EF001 848 | ? ノードプロファイルオブジェクト? 849 | 4 ESV: Echonet Lite Service 850 | 0x62=プロパティ値 読み出し要求 851 | 0x72=プロパティ値 読み出し応答 852 | 0x73=プロパティ値 通知(要求なしで定期的に通知) 853 | 他にもいろいろある 854 | 5 OPC: 処理対象プロパティカウンタ(以下のEPC/PDC/EDTがこの数だけ繰り返される) 855 | -----繰り返し----- 856 | 6 EPC: Echonet プロパティ 機器ごとにプロパティコードが定められている、スマートメータークラスでは0xE7が1W単位の瞬時電力 857 | 7 PDC: プロパティデータカウンタ プロパティのデータサイズ、例えば4バイト プロパティ要求のときは00を送る 858 | 8 EDT: プロパティ値データ 例えば瞬時電力の値 0x000004A5 = 1189W プロパティ要求のときは省略 859 | -----繰り返し----- 860 | 861 | 参考文献 862 | ECHONET-Lite_Ver.1.13_02.pdf ECHONET Lite 通信ミドルウェア仕様 863 | Appendix_Release_K.pdf ECHONET 機器オブジェクト詳細規定 864 | ''' 865 | 866 | # トランザクションID 867 | TID = 0 868 | 869 | def __init__( self, dataframe={} ): 870 | """コンストラクタ""" 871 | #self.dataframe = dataframe 872 | if dataframe: 873 | self.ehd = dataframe['EHD'] 874 | self.tid = dataframe['TID'] 875 | self.seoj = dataframe['SEOJ'] 876 | self.deoj = dataframe['DEOJ'] 877 | self.esv = dataframe['ESV'] 878 | self.properties = {} 879 | for prop in dataframe['PROPERTIES']: 880 | #prop['EPC']: prop[] 881 | self.properties[prop['EPC']] = prop['EDT'] 882 | 883 | @classmethod 884 | def decode ( cls, length, encoded_data ): 885 | """受信した電文(HEX文字データ列)からインスタンスを作成する 886 | 887 | 引数: 888 | length (str): 電文の長さ(16進表記 2byte 文字列) 889 | encoded_data (str): 電文の本体(16進表記の文字列) 890 | 891 | 戻り値: 892 | DataFrame インスタンス 893 | 894 | """ 895 | 896 | dataframe = {} 897 | if len(encoded_data) != int(length, 16) * 2: 898 | logger.error('DataFrame decode error - invalid length') 899 | return None 900 | 901 | if not is_hex(encoded_data): 902 | logger.error('DataFrame decode error - invalid data') 903 | return None 904 | 905 | df = cls() 906 | try: 907 | df.ehd = encoded_data[:4] 908 | df.tid = encoded_data[4:8] 909 | df.seoj = encoded_data[8:14] 910 | df.deoj = encoded_data[14:20] 911 | df.esv = encoded_data[20:22] 912 | df.opc = encoded_data[22:24] 913 | df.properties = {} 914 | base = 24 915 | int_opc = int(df.opc, 16) 916 | for pc in range(int_opc): 917 | epc = encoded_data[base : base + 2] 918 | pdc = encoded_data[base + 2 : base + 4] 919 | int_pdc = int(pdc, 16) 920 | edt = encoded_data[base + 4 : base + 4 + int_pdc * 2] 921 | df.properties[epc] = edt 922 | base += 4 + int_pdc * 2 923 | 924 | except: 925 | logger.error('DataFrame decode error - conflicting data') 926 | return None 927 | 928 | return df 929 | 930 | @classmethod 931 | def cmd_get_property( cls , epc_list ): 932 | '''Echonet Bルートの「プロパティ値要求電文」を作成する。 933 | 934 | 引数: 935 | epc_code_list( list of str ): 要求するプロパティコードのリスト 936 | 937 | 戻り値: 938 | 構成されたデータフレームオブジェクト 939 | 940 | ToDo: 941 | SEOJやDEOJは決め打ちでいいのか? 942 | ''' 943 | df = cls() 944 | df.ehd = '1081' 945 | df.tid = '{:04X}'.format(cls.TID) 946 | # cls.TID = (cls.TID + 1) % 0xffff # インクリメントしておく 947 | df.seoj = '05FF01' 948 | df.deoj = '028801' 949 | df.esv = '62' 950 | df.opc = '{:02X}'.format(len(epc_list)) 951 | df.properties = {} 952 | for epc in epc_list: 953 | df.properties[epc] = '' 954 | logger.debug('Echonet-lite sendto frame : ' + df.encode()) 955 | return df 956 | 957 | def encode( self ): 958 | """DataFrameオブジェクトから送信電文(HEX)を作成する 959 | """ 960 | data = self.ehd + self.tid + self.seoj + self.deoj + self.esv + self.opc 961 | for epc, edt in self.properties.items(): 962 | pdc = '{:02X}'.format(len(edt) // 2) 963 | data += epc + pdc + edt 964 | 965 | return data 966 | 967 | def endict( self ): 968 | """DataFrameオブジェクトを見やすい辞書形式に変換する""" 969 | dict = {} 970 | dict['EHT'] = self.ehd 971 | dict['TID'] = self.tid 972 | dict['SEOJ'] = self.seoj 973 | dict['DEOJ'] = self.deoj 974 | dict['ESV'] = self.esv 975 | dict['OPC'] = self.opc 976 | dict['PROPERTIES'] = {} 977 | for prop, value in self.properties.items(): 978 | dict['PROPERTIES']['EPC'] = prop 979 | dict['PROPERTIES']['EDT'] = value 980 | dict['PROPERTIES']['PDC'] = '{:02X}'.format(len(value) // 2) 981 | 982 | return dict 983 | 984 | class BrouteReader ( Worker ): 985 | """スマートメーターと通信を行い、継続的に電力情報の読み取りを行う。 986 | 987 | デバイスの状態を管理し、状態に応じた動作を定義している 988 | 989 | 状態遷移図: 990 | INIT (初期状態) 991 | | 992 | | _open() WiSUNドングルのシリアルポートの設定など 993 | | 994 | OPEN (ドングル利用可能な状態) 995 | | 996 | | _setup() ドングルのリセット、ドングルのレジスタににBルートIDとパスワードをセット 997 | | 998 | SETUP (スキャンに必要な接続情報が設定された状態) 999 | | 1000 | | _scan() スマートメーターの検索、アドレスの取得 1001 | | 1002 | SCAN (通信相手が見つかって、あとは認証して接続するだけの状態) 1003 | | 1004 | | _join() 相手に接続を要請(PANA認証) 1005 | | 1006 | JOIN (接続状態にある) 1007 | | 1008 | | _sendto(cmd) 「プロパティ値読み出し要求」コマンドを送る(瞬時電力や積算電力) 1009 | | _receive() 「プロパティ値読み出し応答」スマートメーターからの返信を取得 1010 | | _accept() 返信データを処理 1011 | | 1012 | 以下 JOIN の繰り返し 1013 | 1014 | 回復不能なエラーが発生したときは、状態を巻き戻し最初からやり直す。 1015 | """ 1016 | 1017 | # def __init__( self , port, baudrate, broute_id, broute_pwd, requests=[], record_que=None ): 1018 | def __init__( self , wisundev, broute_id, broute_pwd, requests=[], record_que=None ): 1019 | """コンストラクタ 1020 | 1021 | 引数: 1022 | port (str): WiSUN デバイスのシリアルポートのデバイスファイルパス 1023 | baudrate (int): ボーレート 1024 | broute_id (str): Bルート接続用ID(電力会社に申請して取得) 1025 | broute_pwd (str): Bルート接続用パスワード(電力会社に申請して取得) 1026 | requests (list of dic): スマートメータに問い合わせるプロパティリスト、間隔 1027 | record_que (Queue): 記録用 queue 1028 | """ 1029 | super().__init__() 1030 | 1031 | self.record_que = record_que 1032 | self.broute_id = broute_id 1033 | self.broute_pwd = broute_pwd 1034 | if not requests: 1035 | # 指定されなかったときのデフォルト値 1036 | requests=[ 1037 | { 'epc':['D3','D7','E1'], 'cycle': 3600 }, # 係数(D3),有効桁数(D7),単位(E1) 1038 | { 'epc':['E7'], 'cycle': 10 }, # 瞬時電力(E7) 1039 | { 'epc':['E0'], 'cycle': 120 }, # 積算電力量(E0) 1040 | ] 1041 | for req in requests: 1042 | req['lasttime'] = 0 1043 | self.requests = requests 1044 | #self.wisundev = WiSunRL7023DSS( port, baudrate ) 1045 | self.wisundev = wisundev 1046 | 1047 | # 状態 1048 | self._STATE_INIT = 0 1049 | self._STATE_OPEN = 1 1050 | self._STATE_SETUP = 2 1051 | self._STATE_SCAN = 3 1052 | self._STATE_JOIN = 4 1053 | self.state = self._STATE_INIT 1054 | 1055 | # リトライカウンタ 1056 | self.scan_retry = 0 1057 | self.join_retry = 0 1058 | 1059 | # 係数、単位、有効桁数 のデフォルト値 時々スマートメーターに問い合わせるべきもの 1060 | self.coefficient = 1 1061 | self.unit = 0.1 1062 | self.effective_digits = 0x06 1063 | 1064 | # 定期的な実行のための変数(前回実行した時間を記憶しておく) 1065 | self.lasttime_rejoin = 0 1066 | self.lasttime_erxudp = 0 1067 | self.lasttime_receive = 0 1068 | 1069 | def _open( self ): 1070 | """デバイスのシリアルポートをopenする""" 1071 | logger.info('state = INITIAL') 1072 | if self.wisundev.open(): 1073 | self.state = self._STATE_OPEN 1074 | logger.info('state => OPEN') 1075 | else: 1076 | logger.error('ERROR Cannot open device') 1077 | # エラーの場合は1秒停止。無限ループがCPUを専有しないように。(以下同様) 1078 | time.sleep(5) 1079 | 1080 | def _setup( self ): 1081 | """デバイスのリセット、BルートID、パスワードの設定 1082 | """ 1083 | # リセット 1084 | if self.wisundev.reset(): 1085 | pass 1086 | else: 1087 | logger.error('ERROR Cannot reset device') 1088 | time.sleep(5) 1089 | # break 1090 | 1091 | # BルートIDとパスワードをデバイスに設定(レジスタに登録される) 1092 | if self.wisundev.setup( self.broute_id, self.broute_pwd ): 1093 | self.state = self._STATE_SETUP 1094 | logger.info('state => SETUP') 1095 | self.wisundev._get_registers() 1096 | else: 1097 | logger.error('ERROR Cannot setup device') 1098 | time.sleep(5) 1099 | 1100 | def _scan( self ): 1101 | """アクティブスキャンの実行(リトライあり)""" 1102 | if self.wisundev.scan(): 1103 | self.state = self._STATE_SCAN 1104 | logger.info('state => SCAN') 1105 | self.scan_retry = 0 1106 | else: 1107 | # 失敗してもただちに状態を遷移させず、ループで何度かトライ 1108 | logger.error('ERROR Fail to scan ... retry times = ' + str(self.scan_retry)) 1109 | self.scan_retry += 1 1110 | if self.scan_retry > 5: 1111 | self.scan_retry = 0 1112 | self.wisundev.close() 1113 | self.state = self._STATE_INIT 1114 | time.sleep(10) 1115 | 1116 | def _join( self ): 1117 | """PANA認証接続要求を行う(リトライあり)""" 1118 | if self.wisundev.join(): 1119 | self.state = self._STATE_JOIN 1120 | logger.info('state => JOIN') 1121 | self.join_retry = 0 1122 | 1123 | ts = datetime.datetime.now().timestamp() 1124 | self.lasttime_erxudp = ts 1125 | self.lasttime_rejoin = ts 1126 | self.lasttime_receive = ts 1127 | 1128 | else: 1129 | # 失敗してもただちに状態を遷移させず、ループで何度かトライ 1130 | logger.error('ERROR Fail to join ... retry times = ' + str(self.join_retry)) 1131 | self.join_retry += 1 1132 | if self.join_retry > 5: 1133 | self.join_retry = 0 1134 | self.wisundev.close() 1135 | try: 1136 | os.remove('scancache.json') 1137 | except: 1138 | pass 1139 | self.state = self._STATE_INIT 1140 | time.sleep(10) 1141 | 1142 | def _rejoin( self ): 1143 | """PANA再認証を行う 1144 | セッションの有効期限が近づくとデバイスが自動的に再認証を行う設定になっている場合は不要 1145 | """ 1146 | if self.wisundev.rejoin(): 1147 | self.state = self._STATE_JOIN 1148 | logger.info('state => JOIN') 1149 | 1150 | ts = datetime.datetime.now().timestamp() 1151 | self.lasttime_erxudp = ts 1152 | self.lasttime_rejoin = ts 1153 | self.lasttime_receive = ts 1154 | else: 1155 | self.state = self._STATE_INIT 1156 | time.sleep(5) 1157 | 1158 | def _sendto( self, cmd ): 1159 | """Bルートのプロパティ値要求電文をスマートメーターに送る""" 1160 | return self.wisundev.sendto( cmd ) 1161 | 1162 | def _receive ( self ): 1163 | return self.wisundev.receive() 1164 | 1165 | def _accept( self, dataframe ): 1166 | """受信した電文を受け付ける処理""" 1167 | 1168 | def getvalue(rawdata): 1169 | value = int(rawdata, 16) 1170 | return value * self.coefficient * self.unit 1171 | 1172 | def datestr(rawdata): 1173 | year = int(rawdata[:4],16) 1174 | month = int(rawdata[4:6],16) 1175 | day =int (rawdata[6:8],16) 1176 | hour = int(rawdata[8:10],16) 1177 | minute = int(rawdata[10:12],16) 1178 | second = int(rawdata[12:14],16) 1179 | return "{:0=4}/{:0=2}/{:0=2} {:0=2}:{:0=2}:{:0=2}".format(year,month,day,hour,minute,second) 1180 | 1181 | seoj = dataframe.seoj 1182 | esv = dataframe.esv 1183 | if seoj == '028801' and esv in ['72','73']: 1184 | # 送信元が '028801'(スマートメーター)で ESV が 72(プロパティ値要求の応答) 1185 | for epc, edt in dataframe.properties.items(): 1186 | if epc == 'E7': # 瞬時電力 E7 1187 | value = hex_to_signed_int(edt) 1188 | self.record_que.put(['BR', epc, value, 'X']) 1189 | 1190 | elif epc == 'E8': # 瞬時電流計測値 1191 | rphase = edt[:4] 1192 | tphase = edt[4:] 1193 | rvalue = hex_to_signed_int(rphase) * 0.1 # アンペア 1194 | tvalue = hex_to_signed_int(tphase) * 0.1 # アンペア 1195 | self.record_que.put(['BR', 'E8R', rvalue, 'X']) 1196 | self.record_que.put(['BR', 'E8T', tvalue, 'X']) 1197 | 1198 | elif epc in ['E0','E3']: # 積算電力量(正/負) 1199 | value = getvalue(edt) 1200 | self.record_que.put(['BR', epc, value, 'X']) 1201 | 1202 | elif epc == 'D3': # 係数 coefficient D3 1203 | value = int(edt, 16) 1204 | self.coefficient = value 1205 | logger.debug('cofficient = ' + str(value)) 1206 | self.record_que.put(['BR', epc, value, 'X']) 1207 | 1208 | elif epc == 'D7': # 積算電力有効桁数 effective digits D7 1209 | value = int(edt, 16) 1210 | self.effective_digits = value 1211 | logger.debug('effective_digits = ' + str(value)) 1212 | self.record_que.put(['BR', epc, value, 'X']) 1213 | 1214 | elif epc == 'E1': # 積算電力単位 unit E1 1215 | value = int(edt, 16) 1216 | if value == 0x00: 1217 | unit = 1.0 1218 | elif value== 0x01: 1219 | unit = 0.1 1220 | elif value == 0x02: 1221 | unit = 0.01 1222 | elif value == 0x03: 1223 | unit = 0.001 1224 | elif value == 0x04: 1225 | unit = 0.0001 1226 | elif value == 0x0A: 1227 | unit = 10.0 1228 | elif value == 0x0B: 1229 | unit = 100.0 1230 | elif value == 0x0C: 1231 | unit = 1000.0 1232 | elif value == 0x0D: 1233 | unit = 10000.0 1234 | else: 1235 | value = 0.1 1236 | self.unit = unit 1237 | logger.debug('unit = ' + str(self.unit)) 1238 | self.record_que.put(['BR','E1',value,'X']) 1239 | 1240 | elif epc in ['EA', 'EB']: # 定時 積算電力量 計測値 (正方向,逆方向計測値) 1241 | value = getvalue(edt[14:]) 1242 | logger.info(datestr(edt[:14]) + ' ' + epc + ' = ' + str(value)) 1243 | self.record_que.put(['BR', epc, value, 'X']) 1244 | 1245 | else: 1246 | logger.warning('unknown property:' + epc + ' value:' + edt) 1247 | 1248 | else: 1249 | # スマートメーターからのプロパティ読み出し応答以外の電文を処理 1250 | # ESV=73 プロパティ値通知も定期的に受信している 1251 | # EPC=EA:定時 積算電力量 計測値 (正方向計測値) 1252 | # EPC=EB:定時 積算電力量 計測値 (逆方向計測値) 1253 | logger.warning('unknown SEOJ or ESV : ' + dataframe.seoj + ',' + dataframe.esv) 1254 | logger.warning(dataframe.endict()) 1255 | 1256 | def _term( self ): 1257 | self.wisundev.term() 1258 | self.wisundev.close() 1259 | 1260 | def run( self ): 1261 | """スレッド処理""" 1262 | logger.info('[START]') 1263 | 1264 | while not self.stopEvent.is_set(): 1265 | # スレッドループの中では、状態遷移に応じた処理を定義 1266 | if self.state == self._STATE_INIT: 1267 | self._open() 1268 | 1269 | elif self.state == self._STATE_OPEN: 1270 | self._setup() 1271 | 1272 | elif self.state == self._STATE_SETUP: 1273 | self._scan() 1274 | 1275 | elif self.state == self._STATE_SCAN: 1276 | self._join() 1277 | 1278 | # 以下は接続状態(JOIN)においての処理 1279 | elif self.state == self._STATE_JOIN: 1280 | """ 1281 | ToDo: 1282 | トランザクション管理 1283 | TIDの対応付け、成功失敗時の何らかの処理したほうが良い。 1284 | 失敗時に再送など。info の場合 1285 | """ 1286 | now = datetime.datetime.now().timestamp() 1287 | 1288 | # 必要があればここで定期的に _rejoin() を行う。 1289 | 1290 | for req in self.requests: 1291 | # 要求するデータのリストについて、定期的に値要求する 1292 | if now - req['lasttime'] > req['cycle']: 1293 | cmd = DataFrame.cmd_get_property(req['epc']) 1294 | res = self._sendto(cmd) 1295 | if req['lasttime'] == 0: 1296 | req['lasttime'] = now 1297 | else: 1298 | req['lasttime'] += req['cycle'] 1299 | 1300 | # スマートメーターからの電文を待つ 1301 | # この関数は電文があれば直ちに DataFrame オブジェクトを返すが、 1302 | # 受信しないとタイムアウトして空の辞書を返す 1303 | dataframe = self._receive() 1304 | 1305 | # 受信電文処理 1306 | if dataframe: 1307 | now = datetime.datetime.now().timestamp() 1308 | self.lasttime_receive = now 1309 | self._accept(dataframe) 1310 | 1311 | else: 1312 | # 電文受信が無かった場合 1313 | # print('.') 1314 | pass 1315 | 1316 | # 過去10分(600秒)で 電文受信が発生しなかったら初期化 1317 | if now - self.lasttime_receive > 600: 1318 | logger.error('ERROR broute data receive timeout') 1319 | self.wisundev.term() 1320 | self.wisundev.close() 1321 | self.state = self._STATE_INIT 1322 | time.sleep(5) 1323 | 1324 | # スレッドストップイベントがセットされ、run()の終了の前 1325 | self._term() 1326 | logger.info('[STOP]') 1327 | --------------------------------------------------------------------------------