picoesakun が 2024年10月31日16時15分26秒 に編集
初版
タイトルの変更
小鳥用自動給餌器-ピコエサくん-
タグの変更
RaspberryPi
Python
Flask
gunicorn
サーボモーター
記事種類の変更
製作品
ライセンスの変更
(MIT) The MIT License
本文の変更
# ピコエサくんについて うちで飼っているセキセイインコのピコ用にRaspberry Pi Zero Wを使って自動給餌器を作りました。 鳥の場合、餌箱にたくさん餌を入れて食べ放題にしている場合が多いですが、一気食いを防ぐため、1日5回(+おやつ)に分けて決まった量の餌を与えています。 外出時でも分割して餌をあげることができるようにと考えて作りました。 市販されている鳥用の給餌器もあるようですが、食べた殻を排出する機能が主のようで、一定量の餌を与えるものではないようです。 魚用の自動給餌器を工夫して使っている飼い主さんもいるようですが、給餌器の大きさや餌箱に餌を投入する方法に課題があるため、自分で作ることにしました。 Webで給餌時間を設定できるようにしました。 ![ピコエサくん設置](https://camo.elchika.com/19715bc7cbbf288986d348a0e942d43b0e83b1ff/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f61663534356431342d376636662d346362382d393934612d393963363166306361386262/) ![設定画面1](https://camo.elchika.com/0d142b96f993669b0a07fffabe47e3694bf90974/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f37333435393863362d383733642d343661642d383338322d303864316131393166663931/) ![設定画面2](https://camo.elchika.com/ab154583c9e9f1636b5102551956a7f2b03e2212/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f36643366633762362d363236362d343362642d613666322d623737323765333032346630/) # 部品と工具 ## 部品 | 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. 餌受け回転部 2. フレーム部 3. 餌格納部 4. 餌投入ガイド ![構成](https://camo.elchika.com/fb551ed419270d15cf352a5b9915b2ca81b1a9af/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f36633637316262622d643065632d343966392d383534642d653037343964663835393636/) ### 「1. 餌受け回転部」の作成 #### 1.1. キャップの中心に穴を開ける コンパスやサークルカッターを使い、クリアファイルをキャップの内側に合わせて円形に切り取ります。 切り取った円形のクリアファイルをキャップの内側にはめ込み、切り取った際に中心にできた穴を目印にしてキャップに穴を開けます。穴はサーボホーンがはまる大きさ(7mm)まで広げます。 中心から外れた位置に穴を開けてしまうと回転が歪んでしまうので、中心に穴を開けることが重要です。 ![キャップと円形に切り取ったクリアファイル](https://camo.elchika.com/6757fbf5dd57cd032579b16d67ba0b1381ef7f9b/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f31373465383838642d316534622d343164392d623535322d356138363434353364323363/) #### 1.2. サーボホーンをネジ止めするための穴開けと固定 サーボホーンの穴に合わせて4箇所穴を開けます。ネジ止めするためサーボホーン側の穴も広げました。 M2ネジとナット(部品No.11)を使用して固定します。 ![キャップとサーボホーン](https://camo.elchika.com/c41aee9a33cd2dd563eb74abea501aa56d0c0b35/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f36643439313863642d623262382d343464652d393061302d396439636539636366383735/) #### 1.3. 餌受け用の穴を開ける 穴の幅は18mmくらいです。 ![餌受け用の穴](https://camo.elchika.com/481ba214b7add251c86d2f8327bfed9448a78b98/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f35623134393234642d393330652d343266312d383263662d343062386466626165363963/) #### 1.4. 餌受けの切り出し ![切り出したキャップ](https://camo.elchika.com/85dcd9b06d94bea64dba479a179cd93f17632d1d/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f64653766633931392d626134372d346464622d396133302d366236613661353533336534/) #### 1.5. 餌受けの取り付け ![餌受け仮止め](https://camo.elchika.com/4784ebb68526048b72c0f99b2152fca59d91771e/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f35653264396264322d633563382d343838612d383463382d366536306136373862383935/) 餌受けをセロテープで仮止めして、アクリルパウダー&アクリルリキッドで固定します。 ![餌受けの取り付け](https://camo.elchika.com/fa01a3a564e31121a9052eae386e27a3066a4857/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f33633132636264362d313233302d346637382d396138652d396536623932363639663664/) はみ出た部分は切り取ったり削ったりします。 ![餌受け取り付け完了](https://camo.elchika.com/a9bbbd950a45fe94d3b47c6234633c041c0a24d5/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f66303065303365312d373332322d346162392d623638362d333433313263333662613031/) ### 「2. フレーム部」の作成 #### 2.1. サーボモーター取り付け用の穴を開ける サーボモーターの凸部に合わせた穴と、固定用の2mmの穴を2つ開けます。 ![サーボモーター取り付け部](https://camo.elchika.com/0d4dd5fa3b77cf847716a0d5f1bda52824b4fde1/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f65343762326364382d376638332d346466662d623239622d303734666163396636636133/) #### 2.2. 餌投入ガイド連結用の穴を開ける 2cm × 1.5cm程度の穴と、その両脇にミニS字フックを取り付けるための穴を開けます。 ![ 餌投入ガイド連結部](https://camo.elchika.com/a90a0921206a821622d10a4e3b57d88af3d8a9ed/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f36376434663462632d363961632d343866392d386437612d656534353132326136333164/) #### 2.3. S字フック取り付け穴を開ける 3mm程度の穴を開けます。 ![S字フック取り付け穴](https://camo.elchika.com/a1ea0667e80e4f9d67d73df674f21949065aa339/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f34373363616166352d306663322d346437352d613631342d393031383665316636336138/) ### 「3. 餌格納部」の作成 #### 3.1. 円形タッパーの切り出し 「2. フレーム部」の内側に収まるように切り出します。 ![切り出した円形タッパー](https://camo.elchika.com/1e2e7f2f73e8228724e538fc5e12de5aa34661a8/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f31366631646531662d336461392d343564662d396166302d646665333632653630383433/) 3.2. 餌排出用の穴を開ける 1.2cm × 1.1cm程度の穴を開けます。また、餌が溜まる箇所にアクリルパウダー&アクリルリキッドで傾斜をつけています。 ![餌排出穴と傾斜](https://camo.elchika.com/3caf37aea30015ee71b37e09b2703cba20567ef5/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f32396539343332622d646564642d343466372d383935302d383161656132386666356632/) ### 「4. 餌投入ガイド」の作成 #### 4.1. ガイド受け口を作成する クリアファイルを、外径 6.5cm、内径 2.9cmでサークルカッターなどを用いて切り取り、半分に切ります。 ![ガイド受け口1](https://camo.elchika.com/d7f5888c0c26c6c5b66ec6ddf96fc30e1344d0f9/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f31383831366264382d353162362d343935332d623061652d333830643365366162633066/) 両端をセロテープで貼り付けます。 ![ガイド受け口2](https://camo.elchika.com/9e3a49d10b5533c2adaa14cd2601fe9a18116cdd/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f37313266656637642d626231612d343438612d393136392d393764396236396133666561/) #### 4.2. ガイド投入部を作成する クリアファイルを、6cm × 5.5cmでやや台形に切り取ります。 ![ガイド投入部1](https://camo.elchika.com/a2a0987e8315f8e0045e7d893c52deb63600edab/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f30643839663363652d333534362d346461612d626439622d353235633532336665393936/) 丸めてセロテープで貼り付けます。 ![ガイド投入部2](https://camo.elchika.com/53a6b6e2210d7500b10dae14f6c215944c7041eb/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f36363738323866642d333264352d343234632d613935392d373135633735353861343338/) 上部を斜めにカットします。 ![ガイド投入部3](https://camo.elchika.com/393932cccf83ecc5fdb5d010ee19ce82ef4582b4/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f32616563633164662d636336332d346538652d396238372d366432656430633038356531/) #### 4.4. ガイド受け口とガイド投入部を結合する ガイド受け口とガイド投入部をセロテープで貼り付けます。 ![餌投入ガイド1](https://camo.elchika.com/8f71e2003b72b07b52e9c3b3b8b24664ca2a3a6c/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f39333064623433352d333766382d343632332d383761372d643535326166386533333065/) ミニS字フックを取り付けるための穴を両サイドに開けます。 ![餌投入ガイド2](https://camo.elchika.com/3a43d427a261e23cce7bdf595cb48528e708b622/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f35623736333536662d306261312d346638662d623334332d303934386565366235393835/) # ハード部の結合 ### 1. スペーサーを取り付ける フレームに部品No.9のスペーサーをナット(部品No.10)で取り付けます。フレームの外側にスペーサーがついています。 ![スペーサー取り付け](https://camo.elchika.com/b4e7b38d0ab1fa66a29e1ccdf21a48a21fb94a70/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f35363035663633622d373835662d346466392d393264332d396338323936333637353266/) ### 2. サーボモーターを取り付ける フレームの外側にネジ(部品No.10)を使用してサーボモーターを取り付けます。 ![サーボモーター取り付け](https://camo.elchika.com/3f3b9e57c75c0018fa153673d0abcd5e3db33c68/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f62323433623433662d396464302d343035392d386237322d323238353032653566343565/) ### 3. 餌受け回転部を取り付ける ![餌受け回転部取り付け](https://camo.elchika.com/ec8a8d85b9b846cea4b543fbc3c2a2c97cb44540/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f62343163636235642d343931352d343264632d616263372d393233353865313563393434/) ### 4. 餌格納部を取り付ける 餌格納部のサイズがフレームとぴったりあっているとネジなどによる固定は不要です。 ![餌格納部取り付け](https://camo.elchika.com/f8d0e80ca9baaf069237a2f1c28ed3a09f698c79/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f32313832346533622d396430362d346539662d613862392d663365636636356464333361/) 餌格納部と餌受け回転部と接点は以下のようになります。 ![接点](https://camo.elchika.com/ecf526e2d379af4ff3fc9572c218e9c4f519944c/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f65373531343631312d646161332d343462332d386439322d653438633562623466313235/) ### 5. ミニS字フックを作る 部品No.13のステンレス針金を使って、ミニS字フックを2つ作ります。高さが1.2cm程度です。 ![ミニS字フック](https://camo.elchika.com/9b66957e744157badd8fe340d5a0223449b059b6/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f66343130353832662d646130362d343434322d626630612d623061346633393136323664/) ### 6. 餌投入ガイドを取り付ける フレームに餌投入ガイドをミニS字フックで取り付けます。 ![餌投入ガイド取り付け](https://camo.elchika.com/f2d06cd24723b183d721e56c361039bc8bae3e94/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f39336335663837362d633134632d343861662d613331352d343333643936636264353839/) ### 7. 蓋を閉める 蓋の上部にS字フックを取り付けるための穴を開けておきます。 ![蓋の穴](https://camo.elchika.com/5431959ad29c1da2ad75af6dce195a478ccc0942/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f33656131363136352d393263392d343562352d393036392d656532346437376337663332/) ![蓋を閉じてS字フックを取り付け](https://camo.elchika.com/a608cdd7efff7f8ba7447fda2b41e393980ce6e2/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f35363363666136392d313938312d343839352d383464632d383838306236666338663739/) ### 8. ラズパイとサーボモーターを接続する ![](https://camo.elchika.com/7f41d554fadce8f547d63606285c68ab811d6365/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f34623366333930612d343636652d343432372d623034312d653738643365316664373661/) # システム構成 ### 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 ``` # プログラム ## 画面遷移図 ![画面遷移図](https://camo.elchika.com/31f320f3e7470d3d792b7b096e16ed39611e8fdf/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33636262643238632d623335622d346530332d386366622d3465396264633161303531642f61333531633966302d393138312d343635342d623938662d343239333732343431363264/) ## ソースコード ### 1. サーボモーター用コード ```python: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を使用しています。 ```python: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. レイアウト ```html: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. ログイン画面 ```html: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. ワンタイムパスワード画面 ```html: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. メニュー画面 ```html: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. 毎日ごはん設定画面 ```html: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. 一度だけごはん設定画面 ```html: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. 管理画面 ```html: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送信用コード 給餌結果やエラーをメールで送付します。 ```python: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でスケジュールされ実行されます。 ```bash: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= ``` # 最後に 餌がうまく排出されず、餌受け回転部と餌格納部は素材を替えてたりして何度も作り直しました。 入れる餌が少ないと十分に排出されないため、餌格納部はまだ改善の余地があります。 これを見てピコエサくんを作ってくれる飼い主さんがいるかどうかわかりませんが、もしいたらうれしいなと思います。