haku-maiのブログ

インフラエンジニアですが、アプリも作ります。

【Flask/Python】Nature RemoをWeb画面から操作するWebサイトを作成する。めっちゃ詳細解説。(OSSとしてgithubに公開)

f:id:n-guitar:20210314183659p:plain:w800

本記事で行うこと

  • Nature RemoをWeb画面から操作するサイトを作成したのでcodeの解説を記載する。
  • なお、解説するcodeはv-0.1.0のブランチとします。
    github.com

作成したWebサイトのイメージ

  • Topページ
  • 画像はフリー画像だが、SETTINGS Menuから変更可能にした。
    f:id:n-guitar:20210307185554p:plain:w700
  • エアコンページ
  • レイアウトをエアコンのボタンに合わせて整えている。
  • うちのエアコンでしか試していないので、仮にそのボタンが存在しないときはエラーになると思う。誰か試して欲しい。(今後の課題)
    f:id:n-guitar:20210307185600p:plain:w700
  • TVページ
  • レイアウトを整えないでおいてみた。登録されているTVに存在するボタンを動的に生成する。見た目が悪いので整えたい。(今後の課題)
    f:id:n-guitar:20210307185605p:plain:w700
  • OTHERページ
  • 後述するが、Nature Remoには赤外線のボタンを任意に登録できる機能がある。
  • 登録した、ボタンを動的に生成する。
  • またエアコンもTVもOTHERも複数の製品が登録されていても切り替えて操作できるようにしている。
    f:id:n-guitar:20210307185611p:plain:w700
  • SETTINGSページ
  • Nature RemoやNature Remoに登録済みの情報を表示する。
  • Topページの画像を変更できる。今は.jpg,.jpegしかアップロードできないように制限している。
    f:id:n-guitar:20210307185615p:plain:w700

作成したWebサイトのポイント

  • BootStrapやJQueryフレームワークは利用せず、CSS/JSは全て作成する。
  • Nature RemoへのAPI送信は、5分以内に30回という制限付きなので、初回起動とボタン操作のよるAPI送信の時のみの最小限とする。
  • 基本的な部分はPython/Flaskで実装し、ボタン操作(非同期でのAPI送信、操作対象の切り替え)の部分だけ最小限にJSを作成する。

モチベーション

  • Nature Remo Mini2を購入し、GoogleHomeから「OK,Google. エアコンの温度を26度にして」と発言しているところを、会議中ミュートにしているのを忘れていて、聞かれてしまい恥ずかしい思いをしたため、声を出さずに操作したかった。
  • Nature Remoの専用アプリケーションがあるが、iPhone「ログイン→アプリをタップする→ロードを待つ→エアコンをタップする→ボタンを押す」というのがやや面倒で、PCで仕事しながらそのままのWebブラウザから操作できればもっと簡単だろと思ったため。
  • Nature Remoを操作するSlackBotを作成したが、いちいちBotが連携しているSlackチャンネルを探す→エアコンの温度を26度に。と文字を入力する」というのが結構面倒で全く利用しなくなったため。

Nature Remoとは

概要
  • Nature Remo(ネイチャーリモ)とは、テレビやエアコン、赤外線で操作する家電を登録して、スマホアプリやAPIから操作できる製品です。
  • 手軽に登録できるし、赤外線で操作する家電は大体操作できるのでとても便利に活用させていただいております。
  • ネット上は詳しく説明している記事が沢山溢れているので、Googleで検索してみてください。
余談

Nature RemoのAPI

概要
  • Nature RemoはAPIを使って操作することができます。
  • APIの仕様は以下の公式ページから参照できます。
    https://developer.nature.global/
  • またAPI操作に必要なTokenは以下のページから取得可能です。
    https://home.nature.global/
  • このTokenが流出してしまうと自宅の家電が操作されてしまいますので、間違えてgithubにあげないようにう注意が必要です。
  • (誤って流出してしまった場合は、上記のTokenページから削除するのが良いです。)
  • 具体的な操作方法はpython codeのセクションで少し解説してみたいと思います。
余談

作成したWebサイトの利用方法

  • Codeの解説の前に先に作成したWebサイトの利用方法を記載しておこうと思います。
  • githubのREADMEを内容は同じです。
dockerでの起動
  • docker/docker-compose環境があれば以下のようにREMO_TOKENhttps://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のAuthorizationBearer Tokenを入れてAPIの雛形を作ります。(ちなみにBearerの後の半角スペースがないとNGです)
  • APIhttps://api.nature.global/hogeというのが基本で、hoge部分にそれぞれ対応したAPI部分になります。
def call_api
  • APIコールする関数で、url, method, paramsを引数として実行します。
  • paramsにはどのボタンを実行するか、エアコンの温度を何度にするかなどををJson形式で引き渡します。
  • 基本的に情報取得はgetで家電の操作系はsendmethodを引き渡すことになります。
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_idAPIの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となります。

api/json_dataclient.py

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
  • jsonファイルからそれぞれ対応した部分を取得し、そのデータをレンダリング先に引き渡します。
def settings
  • settingsメニューでアップロードされたファイルを保存します。
  • これも分割してもよかったかも・・・とこれを書きながら思ったので、次回のバージョンで分割するかもです。
main
  • アプリケーション起動時に、APIからjsonを取得し、読み込んでいます。

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の他に12といったパラメータを送る
  • 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.pyother_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と使いcssjavascriptを読み込むときは{{ 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" >&nbsp;on&nbsp;</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-power-off">
                            <dev class="signal" id="power-off" >&nbsp;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" >&nbsp;auto</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-wind">
                            <dev class="signal" id="mode-blow" >&nbsp;blow</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-snowflake">
                            <dev class="signal" id="mode-cool" >&nbsp;cool</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-tint-slash">
                            <dev class="signal" id="mode-dry" >&nbsp;dry</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-burn">
                            <dev class="signal" id="mode-warm" >&nbsp;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" >&nbsp;auto</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-1" >&nbsp;1</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-2" >&nbsp;2</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-3" >&nbsp;3</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-4" >&nbsp;4</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-5" >&nbsp;5</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-6" >&nbsp;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" >&nbsp;auto</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-swing" >&nbsp;swing</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-1" >&nbsp;1</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-2" >&nbsp;2</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-3" >&nbsp;3</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-4" >&nbsp;4</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-5" >&nbsp;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" >&nbsp;on&nbsp;</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-power-off">
                            <dev class="signal" id="power-off" >&nbsp;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" >&nbsp;auto</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-wind">
                            <dev class="signal" id="mode-blow" >&nbsp;blow</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-snowflake">
                            <dev class="signal" id="mode-cool" >&nbsp;cool</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-tint-slash">
                            <dev class="signal" id="mode-dry" >&nbsp;dry</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-burn">
                            <dev class="signal" id="mode-warm" >&nbsp;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" >&nbsp;auto</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-1" >&nbsp;1</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-2" >&nbsp;2</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-3" >&nbsp;3</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-4" >&nbsp;4</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-5" >&nbsp;5</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-fan">
                            <dev class="signal" id="vol-6" >&nbsp;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" >&nbsp;auto</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-swing" >&nbsp;swing</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-1" >&nbsp;1</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-2" >&nbsp;2</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-3" >&nbsp;3</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-4" >&nbsp;4</dev>
                        </i>
                    </div>
                    <div class="airbtn">
                        <i class="fas fa-paper-plane">
                            <dev class="signal" id="dir-5" >&nbsp;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 ...&nbsp;</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'] }}">&nbsp;{{ 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'] }}">&nbsp;{{ button['name'] }}</dev>
                        </i>
                    </div>
                {% endfor %}
            {% endif %}
        {% endfor %}
    </div>
    <div id="sendingWrap">
        <div class="sendind-text">sending signal ...&nbsp;</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'] }}">&nbsp;{{ 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'] }}">&nbsp;{{ signal['name'] }}</dev>
                        </i>
                    </div>
                {% endfor %}
            {% endif %}
        {% endfor %}
    </div>
    <div id="sendingWrap">
        <div class="sendind-text">sending signal ...&nbsp;</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を送信し、処理が帰ってくるまでの間右下に表示する部分。以下のような表示がでるようにした。
    f:id:n-guitar:20210312225832p:plain:w200

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関数)urlappliance_idsignalを引き渡して実行する。
  • 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などの基盤全般)なので、今回みたいにフロントやサーバサイドを週末いっぱい書こうと思います。