【Flask/Python】Nature RemoをWeb画面から操作するWebサイトを作成する。めっちゃ詳細解説。(OSSとしてgithubに公開)
今回作成したサイトは、 一応 Nature スマートリモコン Nature Remo 3 ネイチャーリモ Remo-1W3 Alexa/Google Home/Siri対応 Nature スマートリモコン Nature Remo mini 2 ネイチャーリモミニ2 Remo-2W2 Alexa/Google Home/Siri対応
本記事で行うこと
v-0.1.0
のブランチとします。
github.com作成したWebサイトのイメージ
作成したWebサイトのポイント
モチベーション
Nature Remoとは
概要
余談
Nature Remo mini 2
を購入しましたが、Nature Remo 3
の方がセンサーの種類が多いのでそちらも欲しいです。Nature Remo mini 2
でしか動作確認しておりませんが、APIを叩いているのでAPI対応していればどのNature Remoでも動作するとお思います。Nature Remo mini 2
とNature Remo 3
製品を載せておきます。
Nature RemoのAPI
概要
- Nature RemoはAPIを使って操作することができます。
- APIの仕様は以下の公式ページから参照できます。
https://developer.nature.global/ - またAPI操作に必要なTokenは以下のページから取得可能です。
https://home.nature.global/ - このTokenが流出してしまうと自宅の家電が操作されてしまいますので、間違えてgithubにあげないようにう注意が必要です。
- (誤って流出してしまった場合は、上記のTokenページから削除するのが良いです。)
- 具体的な操作方法はpython codeのセクションで少し解説してみたいと思います。
余談
- API送信したあと、どうやって自宅のNature Remoに命令を出しているのかと疑問でしたが、Nature Remoの中の人が解説していたので以下の資料を見ると面白いです。
Nature Remoの裏側 ~ AWSとWeb技術をIoTの世界でフル活用する / Inside Nature Remo - Speaker Deck - 簡単にいうと
WebSocket
という技術を使い、API送信先の環境の更新を検知して操作しているようです。 WebSocket
について体感してみたい人は以下の記事を見てみてください。
n-guitar.hatenablog.com
(こういう仕組みを考えている時って楽しいですよね。)
作成したWebサイトの利用方法
- Codeの解説の前に先に作成したWebサイトの利用方法を記載しておこうと思います。
- ※githubのREADMEを内容は同じです。
dockerでの起動
- docker/docker-compose環境があれば以下のように
REMO_TOKEN
にhttps://home.nature.global/で取得したTokenを設定して、起動でOKです。 - git clone
$ git clone https://github.com/n-guitar/flask-natuer-remo.git
- docker-compose.yml
version: "3" services: web: build: . environment: - REMO_TOKEN=your_token ports: - 5000:5000
- start container
$ docker-compose up -d
この状態でブラウザでhttp://127.0.0.1:5000/に接続。
Pythonでの起動
- Pythonで起動する場合は以下のようにします。
※以下の例ではvenv環境を使っています。 - git clone
$ git clone https://github.com/n-guitar/flask-natuer-remo.git
- venv & module install
$ python -m venv env $ source ./env/bin/activate $ pip install -r requirements.txt
$ export REMO_TOKEN="your_token" $ cd src $ python main.py
テスト済み環境
- 2021/3/8時点のテスト済み環境は以下になります。
Python 3.9
Flask 1.1
requests 2.25
※必要に応じて、更新します。
python部分
- では今回のcode部分について解説してみたいと思います。
Nature RemoのAPI操作
api/remoclient.py
がNature RemoのAPIを操作するobjectとなります。- 結局、https://swagger.nature.global/ の内容をpythonで操作しているだけなので、解説はさらっと見るくらいでOKです。
api/remoclient.py
import os import requests import json class NatureRemoClient(object): def __init__(self, base_url=None): if base_url: self.base_url = base_url else: self.base_url = 'https://api.nature.global' token = os.environ.get('REMO_TOKEN') if not token: raise Exception('Please set your API token to REMO_TOKEN') self.headers = { 'accept': 'application/json', 'Authorization': 'Bearer ' + token } def call_api(self, url, method='get', params=None): req_method = getattr(requests, method) try: res = req_method(self.base_url+url, headers=self.headers, params=params) return res.json() except Exception as e: raise Exception('Failed to call API: %s' % str(e)) def get_appliances(self): url = '/1/appliances' return self.call_api(url) def send_signal(self, signal_id): url = '/1/signals/' + signal_id + '/send' return self.call_api(url, method='post') def send_aircon_settings(self, appliance_id, temperature=None, operation_mode=None, air_volume=None, air_direction=None, button=None): url = '/1/appliances/' + appliance_id + '/aircon_settings' print(url) params = { "temperature": temperature, "operation_mode": operation_mode, "air_volume": air_volume, "air_direction": air_direction, "button": button } print(params) return self.call_api(url, method='post', params=params) def send_tv(self, appliance_id, button): url = '/1/appliances/' + appliance_id + '/tv' print(url) params = { "button": button } print(params) return self.call_api(url, method='post', params=params)
def init
token = os.environ.get('REMO_TOKEN')
でOS上の環境変数を取得し、headersのAuthorization
にBearer Token
を入れてAPIの雛形を作ります。(ちなみにBearerの後の半角スペースがないとNGです)- APIは
https://api.nature.global/hoge
というのが基本で、hoge
部分にそれぞれ対応したAPI部分になります。
def call_api
- APIコールする関数で、url, method, paramsを引数として実行します。
- paramsにはどのボタンを実行するか、エアコンの温度を何度にするかなどををJson形式で引き渡します。
- 基本的に情報取得は
get
で家電の操作系はsend
methodを引き渡すことになります。
def get_appliances
https://api.nature.global/1/appliances
を実行。- このAPIを実行すると、登録されている家電の情報が全て取得可能です。
- 後のJsonファイルの操作セクションで説明しますが、このAPIで取得した情報を使って、操作画面を生成することで、余計なAPI送信をしないようにします。
def send_signal
https://api.nature.global/1/signals/signal_id/send
を実行。- Nature Remoに個別で登録した家電を操作するAPIになります。
- 個別で登録した家電は登録したボタン毎に
signal_id
が紐付きます。 - 登録した家電に一意に紐付く
appliance_id
は使いません。 - 正直、次に解説する
send_aircon_settings
と同じく、appliance_id
をAPIのURLにして、paramsにsignal_id
をセットする仕様の方がよかった気がしなくもないです。
def send_aircon_settings
https://api.nature.global//1/appliances/appliance_id/aircon_settings
を実行。appliance_id
には登録したエアコンのIDをセットします。- paramsに以下のように値をセットすることでエアコンの操作が可能です。
- 各パラメータは操作したい項目だけ引き渡してあげればOKです。例えば温度を変更したい場合は、
"temperature": 26,
と行った感じになります。
params = { "temperature": temperature, "operation_mode": operation_mode, "air_volume": air_volume, "air_direction": air_direction, "button": button }
def send_tv
https://api.nature.global//1/appliances/appliance_id/tv
を実行。appliance_id
には登録したTVのIDをセットします。- TVはボタン毎にボタン名が紐ずくのでparamsには以下のように値をセットします。
params = { "button": button }
- ここまではAPI操作部分になります。
jsonファイルの操作
https://api.nature.global/1/appliances
で取得したjsonファイルの操作を行うobjectとなります。
import json from api.remoclient import NatureRemoClient class AppliancesDataClient(object): """ save & load json json parse """ def __init__(self, file_path=None): # json set if file_path: self.file_path = file_path else: self.file_path = "./remo_data/appliances.json" self.json_data = None def json_save(self): nclient = NatureRemoClient() appliances = nclient.get_appliances() try: with open(self.file_path, 'w') as outfile: json.dump(appliances, outfile, indent=4, ensure_ascii=False) except Exception as e: self.init_error = e def json_load(self): try: with open(self.file_path, "r") as json_file: self.json_data = json.load(json_file) except Exception as e: self.init_error = e def appliances_get_all(self): if self.json_data: return self.json_data else: return self.init_error def appliances_get_air(self, air_id=None): self.json_load() # load json, because update temperature if self.json_data: appliances_list = [] for data in self.json_data: if data['type'] == "AC": appliances_list.append(data) return appliances_list else: return self.init_error def appliances_json_update_air_temp(self, appliance_id, temperature): update_index = 0 for i, appliance in enumerate(self.json_data): if appliance['id'] == appliance_id: update_index = i self.json_data[update_index]['settings']['temp'] = str(temperature) with open(self.file_path, 'w') as outfile: json.dump(self.json_data, outfile, indent=4, ensure_ascii=False) def appliances_get_other(self): if self.json_data: appliances_list = [] for data in self.json_data: if data['type'] == "IR": appliances_list.append(data) return appliances_list else: return self.init_error def appliances_get_tv(self): if self.json_data: appliances_list = [] for data in self.json_data: if data['type'] == "TV": appliances_list.append(data) return appliances_list else: return self.init_error
- ポイントだけ掻い摘むと、取得したjsonファイルは
"type": "TV",
といったようにtype毎にTV
,IR
,AC
のようにわかれるので、それぞれ対応するjson部分を取得します。 def appliances_json_update_air_temp
では画面上でエアコンの温度設定を変えた時にローカルに保存したjsonファイルを更新しています。update_index
はエアコンが複数存在する時にどのエアコンの温度を更新するかを判定しています。
Flaskのメイン部分
$ python main.py
で起動する時のメインになります。
main.py
import os from flask import Flask, render_template, request from api.json_dataclient import AppliancesDataClient from air_controller import air_controller from other_controller import other_controller from tv_controller import tv_controller app = Flask(__name__) app.register_blueprint(air_controller) app.register_blueprint(other_controller) app.register_blueprint(tv_controller) UPLOAD_FOLDER = './static/images/' ALLOWED_EXTENSIONS = set(['.jpg','.jpeg']) app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER # limit upload file size : 5MB app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 app.config.from_object(__name__) @app.route('/') def top(): image_path = "./static/images" files = os.listdir(image_path) # If a file other than default.jpg exists, display image.jpg. if len(files) > 1: files.remove("default.jpg") image_list = list(map(lambda image: "images/" + image, files)) return render_template('top.html', image_list=image_list) @app.route('/air') def air(): appliances_air = appliances_client.appliances_get_air() return render_template('air.html', appliances_air=appliances_air) @app.route('/tv') def tv(): appliances_tv = appliances_client.appliances_get_tv() return render_template('tv.html', appliances_tv=appliances_tv) @app.route('/other') def other(): appliances_other = appliances_client.appliances_get_other() return render_template('other.html', appliances_other=appliances_other) @app.route('/settings', methods=['GET', 'POST']) def settings(): if request.method == 'POST': img_file = request.files['img_file'] print(img_file) _, ext = os.path.splitext(img_file.filename) ext = ext.lower() print(ext) if ext and ext in ALLOWED_EXTENSIONS: img_file.filename = "image.jpg" img_url = os.path.join(app.config['UPLOAD_FOLDER'], img_file.filename) img_file.save(img_url) appliances_get_all = appliances_client.appliances_get_all() return render_template('settings.html', appliances_get_all=appliances_get_all,result="Uploaded") else: appliances_get_all = appliances_client.appliances_get_all() return render_template('settings.html', appliances_get_all=appliances_get_all,result="Sony ... {} is not supported. Supports jpeg/jpg".format(ext)) else: appliances_get_all = appliances_client.appliances_get_all() return render_template('settings.html', appliances_get_all=appliances_get_all) if __name__ == "__main__": appliances_client = AppliancesDataClient() appliances_client.json_save() appliances_client.json_load() app.run(host='0.0.0.0', port=5000, debug=True)
register_blueprint
- 後述するBlueprintですが、Blueprintを使うとFlaskのURLを分割できます。
- 今回Nature RemoのAPI操作を受け付けるエンドポイントを分割しています。
def top
image_path = "./static/images"
のディレクトリ配下にあるjpgファイルを表示します。default.jpg
しかない場合は、default.jpg
を表示するようにしていますが、これは完全に遊び機能です。
def air/tv/other
def settings
- settingsメニューでアップロードされたファイルを保存します。
- これも分割してもよかったかも・・・とこれを書きながら思ったので、次回のバージョンで分割するかもです。
main
FlaskのURL分割(Blueprint)
- Blueprintを使ってFlaskのURLを分割したものになります。
air_controller.py
from flask import Blueprint from api.json_dataclient import AppliancesDataClient from api.remoclient import NatureRemoClient air_controller = Blueprint('air_controller', __name__, url_prefix='/air/api') @air_controller.route('/send/power/<appliance_id>/<signal>', methods=['POST']) def send_power(appliance_id,signal): nclient = NatureRemoClient() if signal == "power-on": signal = "" result = nclient.send_aircon_settings(appliance_id=appliance_id,button=signal) return result @air_controller.route('/send/temp/<appliance_id>/<signal>', methods=['POST']) def send_temp(appliance_id,signal): nclient = NatureRemoClient() result = nclient.send_aircon_settings(appliance_id=appliance_id,temperature=signal) # update json data appliances_client = AppliancesDataClient() appliances_client.json_load() appliances_client.appliances_json_update_air_temp(appliance_id=appliance_id, temperature=signal) return result @air_controller.route('/send/mode/<appliance_id>/<signal>', methods=['POST']) def send_mode(appliance_id,signal): nclient = NatureRemoClient() signal = signal[5:] result = nclient.send_aircon_settings(appliance_id=appliance_id,operation_mode=signal) return result @air_controller.route('/send/vol/<appliance_id>/<signal>', methods=['POST']) def send_vol(appliance_id,signal): nclient = NatureRemoClient() signal = signal[4:] result = nclient.send_aircon_settings(appliance_id=appliance_id,air_volume=signal) return result @air_controller.route('/send/dir/<appliance_id>/<signal>', methods=['POST']) def send_dir(appliance_id,signal): nclient = NatureRemoClient() signal = signal[4:] result = nclient.send_aircon_settings(appliance_id=appliance_id,air_direction=signal) return result
Blueprint
air_controller = Blueprint('air_controller', __name__, url_prefix='/air/api')
と記述することで、ベースのエンドポイントを記述できます。- 例えば、
@air_controller.route('/send/dir/<appliance_id>/<signal>', methods=['POST'])
となっていた場合は、/air/api/send/dir/<appliance_id>/<signal>
がエンドポイントになります。
def send_power
- エアコン電源のon,offをおこないます。
- エアコン電源をonにするときは
button=""
と空文字を送る仕様のため、条件を入れています。
def send_temp
- エアコンの温度を変更します。
- 画面上で変更した後は、jsで画面上も更新するのですが、リロードした時にローカルのjsonファイルをみてしまい、値が画面上は戻ってしまうのでローカルのjsonを更新しています。
def send_mode/vol/dir
- エアコン電源、温度以外のパラメータを更新します。
- volやdirは
auto
の他に1
や2
といったパラメータを送る signal = signal[4:]
、signal = signal[5:]
としているのは、signalをhtmlのid属性からとってくるのですがid属性を一意にしたかったため、<dev class="signal" id="vol-1" >
,<dev class="signal" id="dir-1" >
,<dev class="signal" id="mode-warm" >
と操作機能毎にvol-
,dir-
,mode-
を余分につけている。その余分な部分を取り除いている。以下、
tv_controller.py
とother_controller.py
は上記と変わらないので割愛します。
tv_controller.py
from flask import Blueprint from api.remoclient import NatureRemoClient tv_controller = Blueprint('tv_controller', __name__, url_prefix='/tv/api') @tv_controller.route('/send/<appliance_id>/<button_name>', methods=['POST']) def send_tv(appliance_id,button_name): nclient = NatureRemoClient() result = nclient.send_tv(appliance_id=appliance_id,button=button_name) return result
other_controller.py
from flask import Blueprint from api.remoclient import NatureRemoClient other_controller = Blueprint('other_controller', __name__, url_prefix='/other/api') @other_controller.route('/send_signal/<signal_id>', methods=['POST']) def send_signal(signal_id): nclient = NatureRemoClient() result = nclient.send_signal(signal_id=signal_id) return result
HTML部分
base template
template/base.html
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Flask Nature Remo</title> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css' )}}" /> <link rel="stylesheet" href="{{ url_for('static', filename='css/all.min.css' )}}" /> </head> <body> <div class="navigation"> <ul> <li> <a href="{{ url_for('top') }}"> <span class="title">Flask Nature Remo</span> </a> </li> <li> <a href="{{ url_for('air') }}"> <span class="icon"><i class="fas fa-wind"></i></span> <span class="title">AIR</span> </a> </li> <li> <a href="{{ url_for('tv') }}"> <span class="icon"><i class="fas fa-tv"></i></span> <span class="title">TV</span> </a> </li> <li> <a href="{{ url_for('other') }}"> <span class="icon"><i class="fas fa-adjust"></i></span> <span class="title">OTHER</span> </a> </li> <li> <a href="{{ url_for('settings') }}"> <span class="icon"><i class="fas fa-cog"></i></span> <span class="title">SETTINGS</span> </a> </li> </ul> </div> <div class="main_content">{% block content %} {% endblock %}</div> <div class="footer"></div> </body> <script type=text/javascript src="{{ url_for('static', filename='js/controller.js') }}"></script> </html>
jinja tempaleでstatic
- Flaskでjinja tempaleと使いcssやjavascriptを読み込むときは
{{ url_for('static', filename='js/controller.js') }}
とstatic
部分にベースディレクトリ名、js/controller.js
に(ディレクトリ+)ファイル名として設定。
topメニュー
- 特に解説不要なので割愛しますが、先程の
base.html
の{% block content %} {% endblock %}
部分にコンテンツが差し込まれます。
template/top.html
{% extends "base.html" %} {% block content %} <div class="menu-title">Top Manu</div> <div class="menu-text"> </div> <div class="image-frame"> <ul class="top-images"> {% for image in image_list %} <li class="image"> <img src="{{ url_for('static', filename=image )}}" /> </li> {% endfor %} </ul> </div> {% endblock %}
エアコンメニュー
template/air.html
{% extends "base.html" %} {% block content %} <div class="menu-title"> AIR Manu </div> <div class="menu-text"> </div> <div class="air-manu-main"> <div class="select"> <ul> {% for appliance in appliances_air %} {# Set 0th to activate #} {% if loop.index0 == 0 %} <li class="list active" select-filter="id-{{ appliance['id'] }}">{{ appliance['nickname'] }}</li> {% else %} <li class="list" select-filter="id-{{ appliance['id'] }}">{{ appliance['nickname'] }}</li> {% endif %} {% endfor %} </ul> </div> <div class="air-product"> {% for appliance in appliances_air %} {# Set 0th to activate #} {% if loop.index0 == 0 %} <div class="btnBox air power id-{{ appliance['id'] }} active"> power <div class="airbtn"> <i class="fas fa-power-off"> <dev class="signal" id="power-on" > on </dev> </i> </div> <div class="airbtn"> <i class="fas fa-power-off"> <dev class="signal" id="power-off" > off</dev> </i> </div> </div> <div class="btnBox air temperature id-{{ appliance['id'] }} active"> temperature <div class="airbtn temp-up"> <i class="fas fa-angle-up"> <dev class="signal" id="temp-up" ></dev> </i> </div> <div class="airbtn temp-num"> <dev class="signal" id="temp-num" >{{appliance['settings']['temp']}}</dev> </div> <div class="airbtn temp-down"> <i class="fas fa-angle-down"> <dev class="signal" id="temp-down" ></dev> </i> </div> </div> <div class="btnBox air mode id-{{ appliance['id'] }} active"> mode <div class="airbtn"> <i class="fas fa-car"> <dev class="signal" id="mode-auto" > auto</dev> </i> </div> <div class="airbtn"> <i class="fas fa-wind"> <dev class="signal" id="mode-blow" > blow</dev> </i> </div> <div class="airbtn"> <i class="fas fa-snowflake"> <dev class="signal" id="mode-cool" > cool</dev> </i> </div> <div class="airbtn"> <i class="fas fa-tint-slash"> <dev class="signal" id="mode-dry" > dry</dev> </i> </div> <div class="airbtn"> <i class="fas fa-burn"> <dev class="signal" id="mode-warm" > warm</dev> </i> </div> </div> <div class="btnBox air vol id-{{ appliance['id'] }} active"> air volume <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-auto" > auto</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-1" > 1</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-2" > 2</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-3" > 3</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-4" > 4</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-5" > 5</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-6" > 6</dev> </i> </div> </div> <div class="btnBox air dir id-{{ appliance['id'] }} active"> direction <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-auto" > auto</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-swing" > swing</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-1" > 1</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-2" > 2</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-3" > 3</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-4" > 4</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-5" > 5</dev> </i> </div> </div> <div class="btnBox air id-{{ appliance['id'] }} active"> <p><i class="fas fa-thermometer-half"></i></p> <p class="signal"></p> </div> {% else %} <div class="btnBox air power id-{{ appliance['id'] }}"> power <div class="airbtn"> <i class="fas fa-power-off"> <dev class="signal" id="power-on" > on </dev> </i> </div> <div class="airbtn"> <i class="fas fa-power-off"> <dev class="signal" id="power-off" > off</dev> </i> </div> </div> <div class="btnBox air temperature id-{{ appliance['id'] }}"> temperature <div class="airbtn temp-up"> <i class="fas fa-angle-up"> <dev class="signal" id="temp-up" ></dev> </i> </div> <div class="airbtn temp-num"> <dev class="signal" id="temp-num" >{{appliance['settings']['temp']}}</dev> </div> <div class="airbtn temp-down"> <i class="fas fa-angle-down"> <dev class="signal" id="temp-down" ></dev> </i> </div> </div> <div class="btnBox air mode id-{{ appliance['id'] }}"> mode <div class="airbtn"> <i class="fas fa-car"> <dev class="signal" id="mode-auto" > auto</dev> </i> </div> <div class="airbtn"> <i class="fas fa-wind"> <dev class="signal" id="mode-blow" > blow</dev> </i> </div> <div class="airbtn"> <i class="fas fa-snowflake"> <dev class="signal" id="mode-cool" > cool</dev> </i> </div> <div class="airbtn"> <i class="fas fa-tint-slash"> <dev class="signal" id="mode-dry" > dry</dev> </i> </div> <div class="airbtn"> <i class="fas fa-burn"> <dev class="signal" id="mode-warm" > warm</dev> </i> </div> </div> <div class="btnBox air vol id-{{ appliance['id'] }}"> air volume <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-auto" > auto</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-1" > 1</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-2" > 2</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-3" > 3</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-4" > 4</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-5" > 5</dev> </i> </div> <div class="airbtn"> <i class="fas fa-fan"> <dev class="signal" id="vol-6" > 6</dev> </i> </div> </div> <div class="btnBox air dir id-{{ appliance['id'] }}"> direction <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-auto" > auto</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-swing" > swing</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-1" > 1</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-2" > 2</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-3" > 3</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-4" > 4</dev> </i> </div> <div class="airbtn"> <i class="fas fa-paper-plane"> <dev class="signal" id="dir-5" > 5</dev> </i> </div> </div> <div class="btnBox air id-{{ appliance['id'] }}"> <p><i class="fas fa-thermometer-half"></i></p> <p class="signal"></p> </div> {% endif %} {% endfor %} </div> <div id="sendingWrap"> <div class="sendind-text">sending signal ... </div> <div id="sending"></div> </div> </div> {% endblock %}
- classにactiveが付与されている部分だけcssで表示するように制御している。
{% if loop.index0 == 0 %}
としているのはエアコンが複数存在した時に、一番初めのエアコンだけactiveを付与するように制御している。select-filter="id-{{ appliance['id'] }}"
は画面上で選択したエアコンに対応するボタンを表示するように制御している。- わざわざ
appliance['id']
にid-
をつけているのは、appliance['id']
が数字から始まるものがあり、cssで動作しないためこうした。(実際はcss側でエスケープしてもよかった気がする) - 各エアコンのボタンはhtmlにベタ書きしてしまっているため、他のエアコンで動作確認はできていない。
- 動的にボタンを生成することは可能だが見た目を考慮したためエアコンメニューはこうしてみた。
- 動的にボタンを生成しているのはこの次のTVメニューと、OTHERメニューになる。
TVメニュー
template/tv.html
{% extends "base.html" %} {% block content %} <div class="menu-title">TV Manu</div> <div class="menu-text"> </div> <div class="tv-manu-main"> <div class="select"> <ul> {% for appliance in appliances_tv %} {# Set 0th to activate #} {% if loop.index0 == 0 %} <li class="list active" select-filter="id-{{ appliance['id'] }}">{{ appliance['nickname'] }}</li> {% else %} <li class="list" select-filter="id-{{ appliance['id'] }}">{{ appliance['nickname'] }}</li> {% endif %} {% endfor %} </ul> </div> <div class="tv-product"> {% for appliance in appliances_tv %} {# Set 0th to activate #} {% if loop.index0 == 0 %} {% for button in appliance['tv']['buttons'] %} <div class="btnBox tv id-{{ appliance['id'] }} active"> <i class="fas fa-bolt"> <dev class="signal" id="{{ button['name'] }}"> {{ button['name'] }}</dev> </i> </div> {% endfor %} {% else %} {% for button in appliance['tv']['buttons'] %} <div class="btnBox tv id-{{ appliance['id'] }}"> <i class="fas fa-bolt"> <dev class="signal" id="{{ button['name'] }}"> {{ button['name'] }}</dev> </i> </div> {% endfor %} {% endif %} {% endfor %} </div> <div id="sendingWrap"> <div class="sendind-text">sending signal ... </div> <div id="sending"></div> </div> </div> {% endblock %}
- TVはsignalが
{{ button['name'] }}
となる。 - ボタンは存在するだけ、動的に生成する。
OTHERメニュー
template/other.html
{% extends "base.html" %} {% block content %} <div class="menu-title">Other Manu</div> <div class="menu-text"> </div> <div class="other-manu-main"> <div class="select"> <ul> {% for appliance in appliances_other %} {# Set 0th to activate #} {% if loop.index0 == 0 %} <li class="list active" select-filter="id-{{ appliance['id'] }}">{{ appliance['nickname'] }}</li> {% else %} <li class="list" select-filter="id-{{ appliance['id'] }}">{{ appliance['nickname'] }}</li> {% endif %} {% endfor %} </ul> </div> <div class="other-product"> {% for appliance in appliances_other %} {# Set 0th to activate #} {% if loop.index0 == 0 %} {% for signal in appliance['signals'] %} <div class="btnBox other id-{{ appliance['id'] }} active"> <i class="fas fa-bolt"> <dev class="signal" id="id-{{ signal['id'] }}"> {{ signal['name'] }}</dev> </i> </div> {% endfor %} {% else %} {% for signal in appliance['signals'] %} <div class="btnBox other id-{{ appliance['id'] }}"> <i class="fas fa-bolt"> <dev class="signal" id="id-{{ signal['id'] }}"> {{ signal['name'] }}</dev> </i> </div> {% endfor %} {% endif %} {% endfor %} </div> <div id="sendingWrap"> <div class="sendind-text">sending signal ... </div> <div id="sending"></div> </div> </div> {% endblock %}
SETTINGSメニュー
- OTHER(IR)はsignalが
signal['id']
となる。 - TVと同じくボタンは存在するだけ、動的に生成する。
template/settings.html
{% extends "base.html" %} {% block content %} <div class="menu-title"> SETTINGS Manu </div> <div class="menu-text"> {{ test }} </div> <div class="settings-manu-main"> <p>Nature Remo</p> <table class="appliance_table"> <thead> <tr> <th>id</th> <th>name</th> <th>serial_number</th> <th>firmware_version</th> </tr> </thead> <tr> <td>{{ appliances_get_all[0]['device']['id'] }}</td> <td>{{ appliances_get_all[0]['device']['name'] }}</td> <td>{{ appliances_get_all[0]['device']['serial_number'] }}</td> <td>{{ appliances_get_all[0]['device']['firmware_version'] }}</td> </tr> </table> <p>AIR</p> <table class="appliance_table"> <thead> <tr> <th>appliance_id</th> <th>type</th> <th>nickname</th> <th>model_name</th> </tr> </thead> {% for appliance in appliances_get_all %} {% if appliance['type'] == 'AC' %} <tr> <td>{{ appliance['id'] }}</td> <td>{{ appliance['type'] }}</td> <td>{{ appliance['nickname'] }}</td> <td>{{ appliance['model']['name'] }}</td> </tr> {% endif %} {% endfor %} </table> <p>TV</p> <table class="appliance_table"> <thead> <tr> <th>appliance_id</th> <th>type</th> <th>nickname</th> <th>model_name</th> </tr> </thead> {% for appliance in appliances_get_all %} {% if appliance['type'] == 'TV' %} <tr> <td>{{ appliance['id'] }}</td> <td>{{ appliance['type'] }}</td> <td>{{ appliance['nickname'] }}</td> <td>{{ appliance['model']['name'] }}</td> </tr> {% endif %} {% endfor %} </table> <p>OTHER</p> <table class="appliance_table"> <thead> <tr> <th>appliance_id</th> <th>type</th> <th>nickname</th> </tr> </thead> {% for appliance in appliances_get_all %} {% if appliance['type'] == 'IR' %} <tr> <td>{{ appliance['id'] }}</td> <td>{{ appliance['type'] }}</td> <td>{{ appliance['nickname'] }}</td> </tr> {% endif %} {% endfor %} </table> <div class="image-upload"> <p>Top Image Upload </p> <p>Only jpg File & Max Size 5MB</p> <form action="{{ url_for('settings') }}" method=post enctype="multipart/form-data"> <input type=file id="img_file" name=img_file> <input type=submit value=save> </form> {% if result %} <p>{{ result }}</p> {% endif %} </div> </div> {% endblock %}
- 登録されている機器とNature Remoの情報を表示するようにした。
- topページの画像を変更できる画像アプロードformを作成している。
CSS(SCSS)部分
@import url("https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@700&display=swap"); $cBaseColor: #eaeaea; $cBaseColorHover: #d3f7ff; $cBaseTextColor: #3c5082; $cSelectActiveMenu: #d0cece; $cActiveButtonBorder: #fff; $cSendingCircle: #d3f7ff; body { margin: 0; min-height: 100vh; font-family: "M PLUS Rounded 1c", sans-serif; } .navigation { position: fixed; background: $cBaseColor; width: 180px; height: 100%; & ul { position: absolute; margin-top: 40px; padding: 0; width: 100%; & li { position: relative; width: 100%; list-style: none; &:hover { background: $cBaseColorHover; } & a { position: relative; display: block; width: 100%; display: flex; text-decoration: none; color: $cBaseTextColor; & .icon { position: relative; display: block; min-width: 60px; height: 60px; line-height: 60px; text-align: center; & .fas { font-size: 24px; } } & .title { position: relative; display: block; padding: 0 10px; height: 60px; line-height: 60px; text-align: start; white-space: nowrap; } } } } } .menu-title { color: $cBaseTextColor; } .main_content { margin-left: 180px; padding: 42px; & .menu-title { text-align: center; } } .image-frame { margin-top: 60px; & > ul.top-images { list-style: none; position: relative; padding: 0; & > li { position: absolute; overflow: hidden; & > img { width: 100%; height: 100%; } } } } .air-manu-main .select, .tv-manu-main .select, .other-manu-main .select { padding: 20px; & ul { display: flex; flex-wrap: wrap; margin-bottom: 10px; & li { list-style: none; background: $cBaseColor; padding: 8px 20px; margin: 5px; letter-spacing: 1px; color: $cBaseTextColor; cursor: pointer; &.active { background: $cSelectActiveMenu; } &:hover { background: $cBaseColorHover; cursor: pointer; } } } } .tv-product, .other-product { display: flex; flex-wrap: wrap; & > .btnBox { width: 0px; margin: 0px; padding: 0px; transition: 0.3s; opacity: 0; &.active { min-width: 180px; margin: 5px; padding: 5px; font-size: 20px; background: $cBaseColor; place-items: center; text-align: center; border-style: solid; border-color: $cActiveButtonBorder; color: $cBaseTextColor; opacity: 1; &:hover { background: $cBaseColorHover; cursor: pointer; } } } } .air-product { display: flex; flex-wrap: wrap; align-items: flex-start; & > .btnBox { width: 0px; margin: 0px; padding: 0px; transition: 0.3s; opacity: 0; &.power.active { min-width: 180px; margin: 5px; padding: 5px; font-size: 20px; border-style: solid; border-color: $cActiveButtonBorder; color: $cBaseTextColor; text-align: center; opacity: 1; & > .airbtn { background: $cBaseColor; padding: 5px; border-style: solid; border-color: $cActiveButtonBorder; } } &.temperature.active { min-width: 180px; margin: 5px; padding: 5px; font-size: 20px; border-style: solid; border-color: $cActiveButtonBorder; color: $cBaseTextColor; text-align: center; opacity: 1; & > .airbtn { background: $cBaseColor; padding: 5px; border-style: solid; border-color: $cActiveButtonBorder; } & > .temp-num { background: $cActiveButtonBorder; } } &.mode.active { min-width: 180px; margin: 5px; padding: 5px; font-size: 20px; border-style: solid; border-color: $cActiveButtonBorder; color: $cBaseTextColor; text-align: center; opacity: 1; & > .airbtn { background: $cBaseColor; padding: 5px; border-style: solid; border-color: $cActiveButtonBorder; } } &.vol.active { min-width: 180px; margin: 5px; padding: 5px; font-size: 20px; border-style: solid; border-color: $cActiveButtonBorder; color: $cBaseTextColor; text-align: center; opacity: 1; & > .airbtn { background: $cBaseColor; padding: 5px; border-style: solid; border-color: $cActiveButtonBorder; } } &.dir.active { min-width: 180px; margin: 5px; padding: 5px; font-size: 20px; border-style: solid; border-color: $cActiveButtonBorder; color: $cBaseTextColor; text-align: center; opacity: 1; & > .airbtn { background: $cBaseColor; padding: 5px; border-style: solid; border-color: $cActiveButtonBorder; } } &.active { & > .airbtn:hover { background: $cBaseColorHover; cursor: pointer; } } } } .settings-manu-main { color: $cBaseTextColor; } .appliance_table { & > thead { background-color: $cBaseColor; } & > tbody > tr { text-align: center; &:hover { background-color: $cBaseColorHover; } } } .image-upload { margin-top: 20px; & > form > input { font-size: 15px; font-family: auto; } } #sendingWrap { position: fixed; right: 3%; bottom: 3%; display: flex; opacity: 0; &.active { opacity: 1; } } /* sending anime */ #sending { width: 10px; height: 10px; border-radius: 50%; border: 5px solid $cSendingCircle; border-right-color: transparent; animation: sendAnime 1s linear infinite; opacity: 1; } @keyframes sendAnime { 0% { transform: rotate(0deg); } 50% { transform: rotate(180deg); } 100% { transform: rotate(360deg); } }
- 画面の色合いは変数にしているため、変更すれば好みの色にすることができる。
@import url("https://fonts.googleapis.com/・・・
は好みのフォントを以下のサイトから取得しcssにinportすることで使えるようになる。
Google Fonts- active属性が付与されたボタンだけ表示したいため、active属性には
opacity: 1;
として制御している。 sending anime
はボタン押下時に、裏でjsがAPIを送信し、処理が帰ってくるまでの間右下に表示する部分。以下のような表示がでるようにした。
Javascript部分
static/js/controller.js
// select manu controller const select_products = document.querySelectorAll(".list"); const click_or_ontouch = window.ontouchstart ? 'touchstart' : 'click'; console.log(click_or_ontouch) select_products.forEach((el, i) => { el.addEventListener(click_or_ontouch, () => { selected = el.getAttribute("select-filter"); el.classList.add("active"); // Delete active class for the button that was pressed. select_products.forEach((v, k) => { if (i == k) { //pass ; } else { select_products[k].classList.remove("active"); console.log("info:remove active class:"); } }); // Inactive all button. remove active class. const select_btns = document.querySelectorAll(".btnBox"); select_btns.forEach((v, k) => { select_btns[k].classList.remove("active"); console.log("info:.btnBox all remove active class:"); }); // Activate the button corresponding to the button you pressed. const active_btn = ".btnBox." + selected; const active_btns = document.querySelectorAll(active_btn); active_btns.forEach((v, k) => { active_btns[k].classList.add("active"); console.log("info:select .btnBox add active class:"); }); }); }); // sending anime controller const sending_anime = (status) => { const sendingwrap = document.querySelector("#sendingWrap"); if (status == "sending") { sendingwrap.classList.add("active"); } else if(status == "done") { sendingwrap.classList.remove("active"); } }; // send api const signal_fetch = async (url, appliance_id, signal) => { sending_anime("sending"); if (url.match(/other/)) { // other appliance signal_id = signal fetch_url = url + "/" + signal_id; } else { // tv air appliance fetch_url = url + "/" + appliance_id + "/" + signal; } await fetch(fetch_url, { method: "POST", }) .then((response) => response.text()) .then((text) => { console.log(text); sending_anime("done"); }); }; // click button action const click_btn_action = (target_btn_class_name, action_url) => { let action_btn = document.querySelectorAll(target_btn_class_name); action_btn.forEach((el, i) => { el.addEventListener(click_or_ontouch, () => { appliance_id = document .querySelector(".list.active") .getAttribute("select-filter"); appliance_id = appliance_id.slice(3); signal = el.querySelector(".signal").id; // other appliance if (target_btn_class_name.match(/other/)) { signal = signal.slice(3); } console.log("click:", appliance_id, signal); signal_fetch( (url = action_url), (appliance_id = appliance_id), (signal = signal) ); }); }); }; // click button action instance // air click_btn_action(target_btn_class_name=".power > .airbtn",action_url="/air/api/send/power") click_btn_action(target_btn_class_name=".mode > .airbtn",action_url="/air/api/send/mode") click_btn_action(target_btn_class_name=".vol > .airbtn",action_url="/air/api/send/vol") click_btn_action(target_btn_class_name=".dir > .airbtn",action_url="/air/api/send/dir") // tv click_btn_action(target_btn_class_name=".btnBox.tv",action_url="/tv/api/send") // other click_btn_action(target_btn_class_name=".btnBox.other",action_url="/other/api/send_signal") const air_temp_btns = document.querySelectorAll(".temperature > .airbtn"); air_temp_btns.forEach((el, i) => { // console.log(el); el.addEventListener(click_or_ontouch, (e) => { appliance_id = document .querySelector(".list.active") .getAttribute("select-filter"); appliance_id = appliance_id.slice(3); signal = el.querySelector(".signal").id; temp_num_aria = document.querySelector( ".temperature > .airbtn > .signal" ); temp_num = temp_num_aria.innerText; if (signal == "temp-up") { if (Number(temp_num) < 30.0) { temp_num = Number(temp_num) + 0.5; temp_num_aria.textContent = temp_num; } } else if (signal == "temp-down") { if (Number(temp_num) > 18.0) { temp_num = Number(temp_num) - 0.5; temp_num_aria.textContent = temp_num; } } console.log("click:", appliance_id, signal, temp_num); signal_fetch( (url = "/air/api/send/temp"), (appliance_id = appliance_id), (signal = temp_num) ); }); });
activeボタンの切り替え(select manu controller)
- 複数機器がある場合、選択した機器のclassにactiveを付与し、他の機器のactiveを削除する機能を持つ。
- jQueryを使うともっと簡単にできるのだが、意地でも使いたくないという気持ちからこうなった。
Fetchによる非同期処理(send api, click button action)
- APIをボタンが押下された時に送信する機能。
click button action(click_btn_action関数)
でボタンが押下されたイベントをが来たらsend api(signal_fetch関数)
にurl
とappliance_id
とsignal
を引き渡して実行する。click button action(click_btn_action関数)
はclick button action instance
部分でそれぞれ対応したメニューのボタンのアクションを生成している。send api(signal_fetch関数)
はfetchを使い非同期でapiを送信する。- これもjQueryを使うと簡単にできるのだが、意地でも使いたくないという気持ちからこうなった。
send時のanime(sending anime controller)
send api(signal_fetch関数)
から呼ばれ、sending animeの表示をコントロールしている。- 仕組みは activeボタンの切り替と同じで、activeの追加・削除を行っている。
タッチスクリーン(スマホ)対応
- 手元のiPadから使うシーンもあったが、タッチした時の反応が悪かったのでタッチスクリーン対応した。
const click_or_ontouch = window.ontouchstart ? 'touchstart' : 'click';
の部分でイベントを判定している。
後書き
- これを作ってから3/12時点で1ヶ月くらいたったが、仕事中はほぼ毎日のように利用しているので、使い勝手は満足している。
- なんとなくpythonで作るより、正直Reactとかで作った方が早かったんじゃないか説はあるが、pythonの勉強を兼ねてたのでまぁよかったかなと思った。
- 普段Bootstrapで見た目を整えてしまうのだが、(
多分誰も使わない)OSSとして公開したかったのでcssを自作してみたけど、webの見た目部分がどのようにcssで表現されているか学ぶことができてとてもよかった。 - cssの楽しみを知ったのでダークモード対応とかしてみたいと思った。
- 普段の仕事はインフラエンジニア(OS,SV,DBなどの基盤全般)なので、今回みたいにフロントやサーバサイドを週末いっぱい書こうと思います。