ピコエサくんについて
うちで飼っているセキセイインコのピコ用にRaspberry Pi Zero Wを使って自動給餌器を作りました。
鳥の場合、餌箱にたくさん餌を入れて食べ放題にしている場合が多いですが、一気食いを防ぐため、1日5回(+おやつ)に分けて決まった量の餌を与えています。
外出時でも分割して餌をあげることができるようにと考えて作りました。
市販されている鳥用の給餌器もあるようですが、食べた殻を排出する機能が主のようで、一定量の餌を与えるものではないようです。
魚用の自動給餌器を工夫して使っている飼い主さんもいるようですが、給餌器の大きさや餌箱に餌を投入する方法に課題があるため、自分で作ることにしました。
Webで給餌時間を設定できるようにしました。
部品と工具
部品
No. | 部品 | 備考 |
---|---|---|
1 | Raspberry Pi Zero W | |
2 | SDカード | |
3 | サーボモーター(FT90B) | 秋月電子で購入 |
4 | ジャンパワイヤー (オスメス3本 ) | 秋月電子で購入 |
5 | ミニタッパー | 家にあったもの |
6 | 円形ミニタッパー | ダイソーで購入 |
7 | ペットボトルのキャップ × 2 | 2L用と500ml用 |
8 | クリアファイル | 家にあったもの |
9 | プラスチックスペーサー M2×5mm TP2-5 | 秋月電子で購入 |
10 | M2プラスチックなべ小ねじ+六角ナットセット M2×5mm | 秋月電子で購入 |
11 | ミニ四駆AOパーツ 2mmビスセット | ヨドバシで購入 |
12 | S字フック | ダイソーで購入 |
13 | ステンレス針金 | ダイソーで購入 |
工具など
No. | 工具 | 用途 |
---|---|---|
1 | ホビールーター | キャップやタッパーの加工 |
2 | カッター | クリアファイルの加工 |
3 | サークルカッター | クリアファイルの加工 |
4 | プラスドライバー | サーボモーター、サーボホーンの固定 |
5 | ラジオペンチ | ナットの固定やS字フックの作成 |
6 | アクリルパウダー&アクリルリキッド | 餌受け回転部の作成など |
7 | セロテープ | クリアファイルパーツの固定 |
8 | 保護ゴーグル | 目の保護に |
ハード部の加工
構成
「1. 餌受け回転部」の作成
1.1. キャップの中心に穴を開ける
コンパスやサークルカッターを使い、クリアファイルをキャップの内側に合わせて円形に切り取ります。
切り取った円形のクリアファイルをキャップの内側にはめ込み、切り取った際に中心にできた穴を目印にしてキャップに穴を開けます。穴はサーボホーンがはまる大きさ(7mm)まで広げます。
中心から外れた位置に穴を開けてしまうと回転が歪んでしまうので、中心に穴を開けることが重要です。
1.2. サーボホーンをネジ止めするための穴開けと固定
サーボホーンの穴に合わせて4箇所穴を開けます。ネジ止めするためサーボホーン側の穴も広げました。
M2ネジとナット(部品No.11)を使用して固定します。
1.3. 餌受け用の穴を開ける
1.4. 餌受けの切り出し
1.5. 餌受けの取り付け
餌受けをセロテープで仮止めして、アクリルパウダー&アクリルリキッドで固定します。
はみ出た部分は切り取ったり削ったりします。
「2. フレーム部」の作成
2.1. サーボモーター取り付け用の穴を開ける
サーボモーターの凸部に合わせた穴と、固定用の2mmの穴を2つ開けます。
2.2. 餌投入ガイド連結用の穴を開ける
2cm × 1.5cm程度の穴と、その両脇にミニS字フックを取り付けるための穴を開けます。
2.3. S字フック取り付け穴を開ける
「3. 餌格納部」の作成
3.1. 円形タッパーの切り出し
「2. フレーム部」の内側に収まるように切り出します。
3.2. 餌排出用の穴を開ける
1.2cm × 1.1cm程度の穴を開けます。また、餌が溜まる箇所にアクリルパウダー&アクリルリキッドで傾斜をつけています。
「4. 餌投入ガイド」の作成
4.1. ガイド受け口を作成する
クリアファイルを、外径 6.5cm、内径 2.9cmでサークルカッターなどを用いて切り取り、半分に切ります。
両端をセロテープで貼り付けます。
4.2. ガイド投入部を作成する
クリアファイルを、6cm × 5.5cmでやや台形に切り取ります。
丸めてセロテープで貼り付けます。
上部を斜めにカットします。
4.4. ガイド受け口とガイド投入部を結合する
ガイド受け口とガイド投入部をセロテープで貼り付けます。
ミニS字フックを取り付けるための穴を両サイドに開けます。
ハード部の結合
1. スペーサーを取り付ける
フレームに部品No.9のスペーサーをナット(部品No.10)で取り付けます。フレームの外側にスペーサーがついています。
2. サーボモーターを取り付ける
フレームの外側にネジ(部品No.10)を使用してサーボモーターを取り付けます。
3. 餌受け回転部を取り付ける
4. 餌格納部を取り付ける
餌格納部のサイズがフレームとぴったりあっているとネジなどによる固定は不要です。
餌格納部と餌受け回転部と接点は以下のようになります。
5. ミニS字フックを作る
部品No.13のステンレス針金を使って、ミニS字フックを2つ作ります。高さが1.2cm程度です。
6. 餌投入ガイドを取り付ける
7. 蓋を閉める
8. ラズパイとサーボモーターを接続する
システム構成
1. ディレクトリ構成
/home/pi/配下
|-- picoesa | |-- .env | |-- app.py | |-- config | | `-- settings.py | |-- flask_session | |-- logs | | |-- access_log.log | | |-- error_log.log | | `-- servo.log | |-- picoesa.sh | |-- picoesa_mail.py | |-- picoesa_servo.py | `-- templates | |-- adminpanel.html | |-- authtotp.html | |-- dailysetting.html | |-- error.html | |-- layout.html | |-- login.html | |-- menu.html | |-- oneshotsetting.html | `-- totp.html
2. Web環境
フレームワーク: Flask
WSGI: Gunicorn
2.1. systemctl設定ファイル
gunicorn.service
[Unit] Description=picoesa After=network.target [Service] User=pi WorkingDirectory=/home/pi/picoesa ExecStart=gunicorn --config /home/pi/picoesa/config/settings.py [Install] WantedBy=multi-user.target
2.2. Gunicorn設定ファイル
settings.py
chdir = "/home/pi/picoesa" wsgi_app = "app:app" bind = "0.0.0.0:8080" workers = 3 timeout = 60 deamon = True reload = True accesslog = "/home/pi/picoesa/logs/access_log.log" errorlog = "/home/pi/picoesa/logs/error_log.log" loglevel = "debug"
3. Pythonライブラリ一覧
requirements.txt
aiohappyeyeballs==2.4.0 aiohttp==3.10.5 aiosignal==1.3.1 arandr==0.1.10 astroid==2.5.1 asttokens==2.0.4 async-timeout==4.0.3 attrs==24.2.0 automationhat==0.2.0 beautifulsoup4==4.9.3 blinker==1.8.2 blinkt==0.1.2 buttonshim==0.0.2 cachelib==0.13.0 Cap1xxx==0.1.3 certifi==2020.6.20 chardet==4.0.0 click==8.1.7 colorama==0.4.4 colorzero==1.1 croniter==3.0.3 cryptography==3.3.2 cupshelpers==1.0 dbus-python==1.2.16 distro==1.5.0 docutils==0.16 drumhat==0.1.0 envirophat==1.0.0 ExplorerHAT==0.4.2 flask==3.0.3 Flask-Login==0.6.1 flask-session==0.8.0 Flask-WTF==1.0.1 fourletterphat==0.1.0 frozenlist==1.4.1 gpiozero==1.6.2 gunicorn==23.0.0 html5lib==1.1 idna==2.10 importlib-metadata==8.4.0 isort==5.6.4 itsdangerous==2.2.0 jedi==0.18.0 jinja2==3.1.4 lazy-object-proxy==0.0.0 logger==1.4 logging==0.4.9.6 logilab-common==1.8.1 lor-deckcodes==5.0.0 lxml==4.6.3 MarkupSafe==2.1.5 mccabe==0.6.1 microdotphat==0.2.1 mote==0.0.4 motephat==0.0.3 msgspec==0.18.6 multidict==6.0.5 mypy==0.812 mypy-extensions==0.4.3 numpy==1.19.5 oauthlib==3.1.0 olefile==0.46 packaging==24.1 pantilthat==0.0.7 parso==0.8.1 pexpect==4.8.0 pgzero==1.2 phatbeat==0.1.1 pianohat==0.1.0 picamera==1.13 piglow==1.2.5 pigpio==1.78 pillow==10.4.0 psutil==5.8.0 pycairo==1.16.2 pycups==2.0.1 pygame==1.9.6 Pygments==2.7.1 PyGObject==3.38.0 pyinotify==0.9.6 PyJWT==1.7.1 pylint==2.7.2 pyOpenSSL==20.0.1 pyotp==2.9.0 pypng==0.20220715.0 pyserial==3.5b0 pysmbc==1.0.23 python-apt==2.2.1 python-crontab==2.6.0 python-dateutil==2.8.2 python-dotenv==1.0.1 pytz==2024.1 qrcode==7.4.2 rainbowhat==0.1.0 reportlab==3.5.59 requests==2.25.1 requests-oauthlib==1.0.0 responses==0.12.1 roman==2.0.0 RPi.GPIO==0.7.0 RTIMULib==7.2.1 scrollphat==0.0.7 scrollphathd==1.2.1 Send2Trash==1.6.0b1 sense-hat==2.6.0 simplejson==3.17.2 six==1.16.0 skywriter==0.0.7 sn3218==1.2.7 soupsieve==2.2.1 spidev==3.5 SQLAlchemy==1.4.37 ssh-import-id==5.10 thonny==4.0.1 toml==0.10.1 touchphat==0.0.1 twython==3.8.2 typed-ast==1.4.2 typing-extensions==3.7.4.3 unicornhathd==0.0.4 urllib3==1.26.5 webencodings==0.5.1 werkzeug==3.0.4 wrapt==1.12.1 WTForms==3.0.1 yarl==1.9.6 zipp==3.20.1
プログラム
画面遷移図
ソースコード
1. サーボモーター用コード
picoesa_servo_py
#!/usr/bin/env python
from gpiozero import AngularServo
from gpiozero.pins.pigpio import PiGPIOFactory
from time import sleep
from dotenv import load_dotenv
import os
import sys
import logging
import traceback
import picoesa_mail
LOG_PATH = "/home/pi/picoesa/logs/servo.log"
MSG_SUCCESS = "ピコちゃんにごはんをあげました。"
MSG_FAIL = "エラーが発生しました。"
load_dotenv()
use_mail = bool(int(os.getenv("ENABLE_MAIL_NOTICE")))
logger = logging.getLogger("pikoesa_servo")
logging.basicConfig(encoding="utf-8", filename=LOG_PATH, level=logging.DEBUG, format="%(asctime)s\t%(levelname)s\t%(message)s")
factory = PiGPIOFactory()
init_angle = 85
servo = AngularServo(17, initial_angle=init_angle, min_angle=-90, max_angle=90, pin_factory=factory, min_pulse_width=0.5/1000, max_pulse_width=2.5/1000)
def move_servo_slowly(target_position):
position = servo.angle
step = 2 if target_position > position else -2
while True:
position += step
if position > 90:
position = 90
elif position < -90:
position = -90
servo.angle = position
if (step > 0 and position >= target_position) or (step < 0 and position <= target_position):
break
sleep(0.01)
def do_picoesa():
# 餌が落ちるように首振りをします
for i in range(10):
sleep(0.1)
servo.angle = 80
sleep(0.1)
servo.angle = 90
sleep(1)
move_servo_slowly(-90)
sleep(2)
move_servo_slowly(50)
sleep(0.1)
# 餌がこぼれないように素早く元に位置に移動させます
servo.angle = init_angle
def do_picoesa_multiple(count):
for i in range(count):
do_picoesa()
sleep(3)
try:
picoesa_count = 1
if len(sys.argv) > 1:
if sys.argv[1].isdigit():
picoesa_count = int(sys.argv[1])
do_picoesa_multiple(picoesa_count)
if use_mail == True:
picoesa_mail.send_email(MSG_SUCCESS)
except:
traceback_message = MSG_FAIL + traceback.format_exc();
logger.debug(traceback_message)
picoesa_mail.send_email(traceback_message)
2. Flask用コード
2.1. pythonコード
スケジュールにはcronとatを使用しています。
app.py
from flask import Flask, request, redirect, render_template, flash, url_for, session
from flask_session import Session
from crontab import CronTab
from datetime import datetime, timedelta
from dotenv import load_dotenv
from functools import wraps
import os
import logging
import random
import string
import subprocess
import re
import ipaddress
import pyotp
import traceback
IS_AUTH = "is_auth"
IS_FIRST_AUTH = "is_first_auth"
CRON_USER = "pi"
PICOESA_SH = "/home/pi/picoesa/picoesa.sh"
app = Flask(__name__)
gunicorn_logger = logging.getLogger("gunicorn.error")
app.logger.handlers = gunicorn_logger.handlers
app.logger.setLevel(gunicorn_logger.level)
def generate_random_word(length=10):
characters = string.ascii_letters + string.digits + string.punctuation
return ''.join(random.choice(characters) for i in range(length))
# 設定情報は.envに記載します
load_dotenv()
app.secret_key = os.getenv("PICOESA_SECRET_KEY")
app.config["SESSION_TYPE"] = "filesystem"
app.config["ENABLE_AUTH"] = bool(int(os.getenv("PICOESA_ENABLE_AUTH", 0)))
if bool(app.config["ENABLE_AUTH"]) == True:
app.config["SESSION_PERMANENT"] = False
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=30)
if len(str(os.getenv("PICOESA_ID"))) != 0:
app.config["UID"] = os.getenv("PICOESA_ID")
else:
app.config["UID"] = generate_random_word()
if len(str(os.getenv("PICOESA_PWD"))) != 0:
app.config["PWD"] = os.getenv("PICOESA_PWD")
else:
app.config["PWD"] = generate_random_word()
if len(str(os.getenv("PICOESA_OTP_SECRET_KEY"))) != 0:
app.config["OTP_SECRET_KEY"] = os.getenv("PICOESA_OTP_SECRET_KEY")
else:
app.config["OTP_SECRET_KEY"] = generate_random_word(50)
Session(app)
def is_authenticated():
if IS_AUTH in session and session.get(IS_AUTH):
app.logger.debug("auth: true")
return True
else:
app.logger.debug("auth: false")
return False
def is_private_ip(ip):
try:
ip_obj = ipaddress.ip_address(ip)
return ip_obj.is_private
except:
app.logger.debug(traceback.format_exc())
return False
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
try:
app.logger.debug("IS_AUTH: " + str(session.get(IS_AUTH)))
if not (IS_AUTH in session and session.get(IS_AUTH)):
return redirect(url_for('login_get'))
return f(*args, **kwargs)
except:
session.clear()
app.logger.debug(traceback.format_exc())
return redirect(url_for("error_get"))
return decorated_function
def clear_at_jobs():
result = subprocess.run("atq | awk '{print $1}' | while read jobid; do atrm $jobid; done", shell=True)
app.logger.debug(result)
def set_at_job(dt_str, count):
if len(dt_str) == 0:
return
dt = datetime.strptime(dt_str, "%Y-%m-%dT%H:%M")
argDt = f"{dt:%Y%m%d%H%M}"
result = subprocess.run(f"echo '{PICOESA_SH} {count}' | at -t {argDt}", shell=True)
app.logger.debug(result)
def set_cron_job(time_str, count):
if len(time_str) == 0:
return
trgt_datetime = datetime.strptime(time_str, "%H:%M").time()
cron = CronTab(user=CRON_USER)
job = cron.new(command=f"{PICOESA_SH} {count}")
job.setall(trgt_datetime)
cron.write()
def clear_cron_jobs():
cron = CronTab(user=CRON_USER)
jobs = cron.find_command(re.compile(r".*picoesa\.sh.*"))
for job in jobs:
cron.remove(job)
cron.write()
def get_at_jobid(atqstr):
re_result = re.search(r"^(\d+)", atqstr)
return re_result.group(1)
def get_at_job_param(atqstr):
jobid = get_at_jobid(atqstr);
result = subprocess.run(["at", "-c", str(jobid)], capture_output=True, text=True)
re_result = re.search(r".*picoesa\.sh\s+(\d+)", result.stdout)
if re_result:
return re_result.group(1)
return "1"
def conv_atq_datetime(atqstr):
re_result = re.match(r".+(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (.+2\d{3})", atqstr)
return datetime.strptime(re_result.group(2), "%b %d %H:%M:%S %Y")
def get_at_jobs():
atqlist = subprocess.run(["atq"], capture_output=True, text=True)
timelist = []
for line in atqlist.stdout.splitlines():
job_date = conv_atq_datetime(line)
job_param = get_at_job_param(line)
timelist.append((job_date, job_param))
timelist.sort(key=lambda x: x[0])
resultset = {}
i = 1
for tm in timelist:
key_time = f"schedule_{i}"
key_count = f"count_schedule_{i}"
resultset[key_time] = f"{tm[0]:%Y-%m-%dT%H:%M}"
resultset[key_count] = tm[1]
i += 1
return resultset
def get_cron_job_param(job_cmd):
re_result = re.search(r"(\d+)$", job_cmd)
if re_result:
return re_result.group(1)
return "1"
def get_cron_jobs():
cron = CronTab(user=CRON_USER)
# jobs = cron.find_command(PICOESA_SH)
jobs = cron.find_command(re.compile(r".*picoesa\.sh.*"))
timelist = []
for job in jobs:
job_hour = int(str(job.hour))
job_minute = int(str(job.minute))
job_time = f"{job_hour:02}:{job_minute:02}"
job_param = get_cron_job_param(job.command)
timelist.append((job_time, job_param))
timelist.sort(key=lambda x: x[0])
resultset = {}
i = 1
for tm in timelist:
key_time = f"schedule_{i}"
key_count = f"count_schedule_{i}"
resultset[key_time] = tm[0]
resultset[key_count] = tm[1]
i += 1
app.logger.debug(resultset)
return resultset
@app.route('/')
def index():
return redirect(url_for("login_get"))
@app.route("/error", methods=["GET"])
def error_get():
flash("エラーが発生しました。ログを確認してください。")
return render_template("error.html")
@app.route("/login", methods=["GET"])
def login_get():
try:
# 認証無し設定の場合はプライベートIPのみ許可する
if app.config["ENABLE_AUTH"] == False:
app.logger.debug(request.remote_addr)
if is_private_ip(request.remote_addr) == True:
app.logger.debug("is private")
session[IS_AUTH] = True
return redirect(url_for("menu_get"))
return render_template("login.html")
except:
session.clear()
app.logger.debug(traceback.format_exc())
return redirect(url_for("error_get"))
@app.route("/login", methods=["POST"])
def login_post():
try:
uid = request.form["uid"]
pwd = request.form["pwd"]
if uid != app.config["UID"] or pwd != app.config["PWD"]:
flash("IDまたはパスワードが違います。")
return render_template("login.html")
else:
session[IS_FIRST_AUTH] = True
session["uid"] = uid
return redirect(url_for("auth_totp_get"))
except:
session.clear()
app.logger.debug(traceback.format_exc())
return redirect(url_for("error_get"))
@app.route('/authtotp', methods=['GET'])
def auth_totp_get():
if not (IS_FIRST_AUTH in session and session.get(IS_FIRST_AUTH)):
return render_template("login.html")
return render_template("authtotp.html")
@app.route('/authtotp', methods=['POST'])
def auth_totp_post():
if not (IS_FIRST_AUTH in session and session.get(IS_FIRST_AUTH)):
return render_template("login.html")
token = request.form["otp"]
totp = pyotp.TOTP(app.config["OTP_SECRET_KEY"])
if totp.verify(token):
session[IS_AUTH] = True
return redirect(url_for("menu_get"))
flash("パスワードに誤りがあります。")
return render_template("authtotp.html")
@app.route("/menu", methods=["GET"])
@login_required
def menu_get():
if request.args.get("do") == "logoff":
session.clear()
app.logger.debug("logoff session:" + str(session))
return redirect(url_for("login_get"))
return render_template("menu.html", anable_auth = app.config["ENABLE_AUTH"])
@app.route("/oneshotsetting", methods=["GET"])
@login_required
def oneshotsetting_get():
joblist = get_at_jobs()
return render_template("oneshotsetting.html", **joblist)
@app.route("/oneshotsetting", methods=["POST"])
@login_required
def oneshotsetting_post():
clear_at_jobs()
for i in range(10):
set_at_job(request.form[f"schedule_{i + 1}"], request.form[f"count_schedule_{i + 1}"])
flash("登録しました。")
joblist = get_at_jobs()
return render_template("oneshotsetting.html", **joblist)
@app.route("/dailysetting", methods=["GET"])
@login_required
def dailysetting_get():
joblist = get_cron_jobs()
return render_template("dailysetting.html", **joblist)
@app.route("/dailysetting", methods=["POST"])
@login_required
def dailysetting_post():
clear_cron_jobs()
for i in range(7):
set_cron_job(request.form[f"schedule_{i + 1}"], request.form[f"count_schedule_{i + 1}"])
flash("登録しました。")
joblist = get_cron_jobs()
return render_template("dailysetting.html", **joblist)
@app.route("/adminpanel", methods=["GET"])
@login_required
def adminpanel_get():
return render_template("adminpanel.html")
@app.route("/adminpanel", methods=["POST"])
@login_required
def adminpanel_post():
action = request.form.get("action")
if action == "halt":
subprocess.Popen(["sudo", "shutdown", "-h", "now"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
flash("シャットダウンします。")
if action == "reboot":
subprocess.Popen(["sudo", "shutdown", "-r", "now"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
flash("再起動します。")
return render_template("adminpanel.html")
if __name__ == "__main__":
#app.run(host="0.0.0.0", port=80, debug=False)
app.run()
2.2. レイアウト
layout.html
<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>picoesakun</title>
</head>
{% block body %}{% endblock %}
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
{{ message }}<br>
{% endfor %}
{% endif %}
{% endwith %}
</html>
2.3. ログイン画面
login.html
{% extends "layout.html" %}
{% block body %}
<h3>ピコエサくん</h3>
<form action='login' method='POST'>
<input type='text' name='uid' id='uid' placeholder='userid' value="" />
<br />
<input type='password' name='pwd' id='pwd' placeholder='password' value="" />
<br />
<input type='submit' name='submit' value="ログイン" />
</form>
{% endblock %}
2.4. ワンタイムパスワード画面
authtotp.html
{% extends "layout.html" %}
{% block body %}
<form action='authtotp' method='POST'>
<input type='password' name='otp' id='otp' placeholder='one time password' value="" />
<br />
<input type='submit' name='submit' value="OK" />
</form>
{% endblock %}
2.5. メニュー画面
menu.html
{% extends "layout.html" %}
{% block body %}
<h3>メニュー</h3>
<a href="{{ url_for('dailysetting_get') }}">毎日ごはん設定</a>
<br><br>
<a href="{{ url_for('oneshotsetting_get') }}">一度だけごはん設定</a>
<br><br>
<a href="{{ url_for('adminpanel_get') }}">管理</a>
{% if anable_auth%}
<br><br>
<a href="{{ url_for('menu_get', do='logoff') }}">ログオフ</a>
{% endif %}
{% endblock %}
2.6. 毎日ごはん設定画面
dailysetting.html
{% extends "layout.html" %}
{% block body %}
<a href="{{ url_for('menu_get') }}">メニューに戻る</a>
<h3>毎日ごはん設定</h3>
<form action='dailysetting' method='POST'>
<input type="time" name="schedule_1" id="schedule_1" value="{{schedule_1}}"/>
<label for="count_schedule_1">回数</label>
<input type="number" name="count_schedule_1" id="count_schedule_1" min="1" max="9" value="{{count_schedule_1}}"/>
<br /><br />
<input type="time" name="schedule_2" id="schedule_2" value="{{schedule_2}}"/>
<label for="count_schedule_2">回数</label>
<input type="number" name="count_schedule_2" id="count_schedule_2" min="1" max="9" value="{{count_schedule_2}}"/>
<br /><br />
<input type="time" name="schedule_3" id="schedule_3" value="{{schedule_3}}"/>
<label for="count_schedule_3">回数</label>
<input type="number" name="count_schedule_3" id="count_schedule_3" min="1" max="9" value="{{count_schedule_3}}"/>
<br /><br />
<input type="time" name="schedule_4" id="schedule_4" value="{{schedule_4}}"/>
<label for="count_schedule_4">回数</label>
<input type="number" name="count_schedule_4" id="count_schedule_4" min="1" max="9" value="{{count_schedule_4}}"/>
<br /><br />
<input type="time" name="schedule_5" id="schedule_5" value="{{schedule_5}}"/>
<label for="count_schedule_5">回数</label>
<input type="number" name="count_schedule_5" id="count_schedule_5" min="1" max="9" value="{{count_schedule_5}}"/>
<br /><br />
<input type="time" name="schedule_6" id="schedule_6" value="{{schedule_6}}"/>
<label for="count_schedule_6">回数</label>
<input type="number" name="count_schedule_6" id="count_schedule_6" min="1" max="9" value="{{count_schedule_6}}"/>
<br /><br />
<input type="time" name="schedule_7" id="schedule_7" value="{{schedule_7}}"/>
<label for="count_schedule_7">回数</label>
<input type="number" name="count_schedule_7" id="count_schedule_7" min="1" max="9" value="{{count_schedule_7}}"/>
<br /><br />
<button type='submit' name='action' value="save">登録</button>
</form>
{% endblock %}
2.7. 一度だけごはん設定画面
oneshotsetting.html
{% extends "layout.html" %}
{% block body %}
<a href="{{ url_for('menu_get') }}">メニューに戻る</a>
<h3>一度だけごはん設定</h3>
<form action='oneshotsetting' method='POST'>
<input type="datetime-local" name="schedule_1" id="schedule_1" value="{{schedule_1}}" />
<label for="count_schedule_1">回数</label>
<input type="number" name="count_schedule_1" id="count_schedule_1" min="1" max="9" value="{{count_schedule_1}}"/>
<br /><br />
<input type="datetime-local" name="schedule_2" id="schedule_2" value="{{schedule_2}}"/>
<label for="count_schedule_2">回数</label>
<input type="number" name="count_schedule_2" id="count_schedule_2" min="1" max="9" value="{{count_schedule_2}}"/>
<br /><br />
<input type="datetime-local" name="schedule_3" id="schedule_3" value="{{schedule_3}}"/>
<label for="count_schedule_3">回数</label>
<input type="number" name="count_schedule_3" id="count_schedule_3" min="1" max="9" value="{{count_schedule_3}}"/>
<br /><br />
<input type="datetime-local" name="schedule_4" id="schedule_4" value="{{schedule_4}}"/>
<label for="count_schedule_4">回数</label>
<input type="number" name="count_schedule_4" id="count_schedule_4" min="1" max="9" value="{{count_schedule_4}}"/>
<br /><br />
<input type="datetime-local" name="schedule_5" id="schedule_5" value="{{schedule_5}}"/>
<label for="count_schedule_5">回数</label>
<input type="number" name="count_schedule_5" id="count_schedule_5" min="1" max="9" value="{{count_schedule_5}}"/>
<br /><br />
<input type="datetime-local" name="schedule_6" id="schedule_6" value="{{schedule_6}}"/>
<label for="count_schedule_6">回数</label>
<input type="number" name="count_schedule_6" id="count_schedule_6" min="1" max="9" value="{{count_schedule_6}}"/>
<br /><br />
<input type="datetime-local" name="schedule_7" id="schedule_7" value="{{schedule_7}}"/>
<label for="count_schedule_7">回数</label>
<input type="number" name="count_schedule_7" id="count_schedule_7" min="1" max="9" value="{{count_schedule_7}}"/>
<br /><br />
<input type="datetime-local" name="schedule_8" id="schedule_8" value="{{schedule_8}}"/>
<label for="count_schedule_8">回数</label>
<input type="number" name="count_schedule_8" id="count_schedule_8" min="1" max="9" value="{{count_schedule_8}}"/>
<br /><br />
<input type="datetime-local" name="schedule_9" id="schedule_9" value="{{schedule_9}}"/>
<label for="count_schedule_9">回数</label>
<input type="number" name="count_schedule_9" id="count_schedule_9" min="1" max="9" value="{{count_schedule_9}}"/>
<br /><br />
<input type="datetime-local" name="schedule_10" id="schedule_10" value="{{schedule_10}}"/>
<label for="count_schedule_10">回数</label>
<input type="number" name="count_schedule_10" id="count_schedule_10" min="1" max="9" value="{{count_schedule_10}}"/>
<br /><br />
<button type='submit' name='action' value="save">登録</button>
</form>
{% endblock %}
2.8. 管理画面
adminpanel.html
{% extends "layout.html" %}
{% block body %}
<a href="{{ url_for('menu_get') }}">メニューに戻る</a>
<h3>管理</h3>
<form action='adminpanel' method='POST'>
<button type='submit' name='action' value="reboot">再起動</button>
<br><br>
<button type='submit' name='action' value="halt">シャットダウン</button>
</form>
{% endblock %}
3. gmail送信用コード
給餌結果やエラーをメールで送付します。
picoesa_mail.py
import smtplib, ssl
from email.mime.text import MIMEText
from dotenv import load_dotenv
import os
# 設定は.envに記載
load_dotenv()
mail_id = os.getenv("MAIL_ID")
mail_pwd = os.getenv("MAIL_PWD")
mail_from = os.getenv("MAIL_FROM")
mail_to = os.getenv("MAIL_TO")
def create_message(mail_body):
msg = MIMEText(mail_body, "plain", "utf-8")
msg["Subject"] = "ピコエサくん通知"
msg["From"] = mail_from
msg["To"] = mail_to
return msg
def send_email(mail_body):
server = smtplib.SMTP_SSL("smtp.gmail.com", 465, context = ssl.create_default_context())
# server.set_debuglevel(1)
server.login(mail_id, mail_pwd)
server.send_message(create_message(mail_body))
server.quit()
if __name__ == "__main__":
send_email("ピコエサくん通知本文")
4. サーボモーター実行スクリプト
スクリプトはcronまたはatでスケジュールされ実行されます。
picoesa.sh
#!/bin/sh
/usr/bin/python /home/pi/picoesa/picoesa_servo.py $1
5. 設定ファイル
.env
# FlaskアプリケーションののSECRET KEYを設定します。 PICOESA_SECRET_KEY= # 認証の有効無効を設定します。(1 - True, 0 - False) PICOESA_ENABLE_AUTH=0 # ログインIDを設定します。認証が有効な場合に使用します。 PICOESA_ID= # ログインパスワードを設定します。認証が有効な場合に使用します。 PICOESA_PWD= # 二要素認証のSECRET KEYを設定します。認証が有効な場合に使用します。 PICOESA_OTP_SECRET_KEY= # 通知メールを送るかどうかを設定します。(1 - True, 0 - False) ENABLE_MAIL_NOTICE=0 # gmailのアドレスを設定します。 MAIL_ID= # gmailのアプリパスワードを設定します。 MAIL_PWD= # 送信元アドレスを設定します。 MAIL_FROM= # 送信先アドレスを設定します。 MAIL_TO=
最後に
餌がうまく排出されず、餌受け回転部と餌格納部は素材を替えてたりして何度も作り直しました。
入れる餌が少ないと十分に排出されないため、餌格納部はまだ改善の余地があります。
これを見てピコエサくんを作ってくれる飼い主さんがいるかどうかわかりませんが、もしいたらうれしいなと思います。
-
picoesakun
さんが
2024/10/31
に
編集
をしました。
(メッセージ: 初版)
ログインしてコメントを投稿する