
#15 Raspberry Piを用いたドアシステム
私は今回開発したシステムを誰もが簡単に使えるように改良していきたい。
それによって誰もが安価に高セキュリティな環境を築くことができる時代を到来させていきたいとこの1年半のプロジェクトで考えました。
なので途中成果としてのレポートです。
1. はじめに
私がこの開発を始めるきっかけは3つある。
1つ目は、年々不審者に関するニュースが増加しているためである。グラフ1に示す通り長野県内における住居侵入罪発生件数はR2年度以降増加傾向に転じている。このことから、不審者対策を行うためには何が重要かを考えるようになった。
2つ目は部室のセキュリティがとても低いためである。現在縣陵の多くの部活はドアノブにシリンダー錠を用いている。また、鍵は4桁の暗証番号を設定したキーボックスの中に保管されていて、基本的に暗証番号が変更されることもない。そのため各部室の暗証番号が部員以外にも流出していることもある。そのため、セキュリティ性はとても低い。また、今年度に入ってから複数の部活の部室において不審者が侵入した形跡が確認された。私が所属する物理化学部においても不審者が過去に侵入した形跡が発見された。これらのことから不審者対策を行うためにスマートロックが有用ではないかと考えた。
3つ目は、既存のスマートロックシステムは必要な機能が付いていないことが多く、高価なためである。部室のセキュリティ向上には「不審者を記録する」「複数人が鍵を持つことができる」「簡単に管理することができる」の3つの機能が必要であると考えた。しかし、これらのすべての機能を満たすものは少なく、高価であることが多いことや、複数人での利用に向かないものが多い。よって、自分でスマートロックを開発することによってそれらの条件を全て満たすシステムを設置することができるのではないかと考えた。
![グラフ1 長野県内における住居侵入犯罪発生件数[1]](https://blog.yuna1107.com/_emdash/api/media/file/01M5HG8F0H783X6J2EHK4ME6W6.png)
2. 既存の鍵のタイプについて
現在日本の多くで使われているのはシリンダー錠であり、スマートロックの普及率は0.2%程度といわれている。なぜ、スマートロックの普及率は低いのだろうか。それについて疑問を持った私はシリンダー錠とスマートロックのメリット・デメリットについて比較を行うことにした。 比較した結果は表1の通りである。[2]
表1から読み取れるように、シリンダー錠はスマートロックに比べてセキュリティ性が低い。しかし、日本で普及していない要因としては鍵に対する考え方の違いというのが大きいことがわかった。日本において鍵は初めからついてあるものであって自分たちで選ぶという意識が他国に比べて低いということがスマートロックの普及率に影響しているのだろう。[6]

3. 開発するシステムについて
2で比較した内容をもとに開発するシステムの方向性、必要な機能を絞り込んだ。部室に設置するため、既存の鍵をほとんど改造することなく設置する必要がある。そのため、シリンダー錠と電気錠を組み合わせて物理錠を安全な場所に保管することでセキュリティ性を高めることにした。
条件の1つ目として安価に開発する必要があると考えた。主な理由として1で述べた通り既存のものは高価なものが多く自分たちが購入することが難しいためである。そのため比較的安価な小型コンピューターのRaspberryPiを用いて開発を行うことにした。
2つ目は不審者の写真を記録することである。これまでの部室における不審者騒動では犯人が誰かということを特定できていなかった。なので、今回開発するシステムでは不審者の画像を記録して犯人の特定を容易にしたいと考えた。そこで部活の連絡で用いているDiscord※1のWebhookという機能を用いて不審者の画像を送信するシステムを開発することにした。
3つ目は複製が困難でありながらも使いやすい鍵を開発することである。そこで、当初NFCカードを鍵として使用し、IDの登録・削除をDatabaseから容易に行えるようにしてセキュリティ性を高めようと考えていた。しかし、開発途中で利用人数の想定が大幅に変わってしまいWebアプリを鍵として用いる形に変更した。
4つ目は操作履歴を簡単に調べることができるようにすることである。学校側が生徒の最終利用時刻や、現在の鍵の状況を簡単に調べることができるようにするためにGoogle Spread Sheetを用いた検索サービスを開発することにした。
※1 Discordはアメリカ合衆国で開発されたコミュニケーション・サービスアプリである。
※2 WebhookはDiscordの機能の一つで外部サービスと連携させるための機能である。外部サービスの通知を特定のチャンネルへ飛ばす目的で使われることが多い。
4.使用した機材・言語・サービスの説明
プログラミング言語
Python
汎用性が高く初心者にも扱いやすいプログラミング言語の一種。近年ではAI開発などにも用いられている。 [7]
Google Apps Script (JavaScript)
Google Workspaceの自動化などを目的として用いられるプログラミング言語の一種。JavaScriptをベースとして作られた言語であり、Googleの各種サービスとの連携が容易にできるようにされている。 [8]
Flutter (Dart)
Dartとはモバイルアプリやウェブアプリのクライアント開発向けにGoogleによって設計されたプログラミング言語の一種。
Flutterとはクロスプラットフォームに対応したオープンソースなフレームワークである。現在はAndrroid・iOS・macOS・
Windows・Linuxに対応しており、単一コードで複数のプラットフォームに対応したモバイルアプリを開発することができる。 [9][10]
サービス
Vercel
ウェブアプリのデプロイ及びプレビュー、公開を行うことができるクラウドサービス。[11]

Google SpreadSheet
Googleのサービスの一つとして提供されている表計算ソフト。Google製品との連携が簡単にできる。
Supabase
Firebaseの代替として注目を集めているデータベース。リアルタイム通知機能などの多くの機能を提供している。[12]

Discord
Windows・macOS・iOS・Android・Linux・Webブラウザで動作するインスタントメッセージ・ビデオ通話・音声通話サービス。ゲームなどのさまざまな目的で利用されており、外部サービスとの連携などが容易にできるようになっている。[13]

GitHub
プログラムコードなどをクラウド上に保存し、コードの公開や、外部サービスと連携させてWebアプリを構築するなどのさまざまなことを行うことができるサービス。データのバージョン管理機能が充実している。

機材
RaspberryPi4 (4GB RAM)
イギリスで開発された教育用の小型コンピューターである。さまざまなモジュールとの接続や連携が容易になっておりIot機器の開発などに利用されており業務用、個人用などのさまざまな方法で活用されている。
Webカメラ (EMEET C960)
主にパソコンと接続してリアルタイムで映像を配信できる小型カメラである。さまざまなシーンや目的に使用することができる。
サーボモータ(MG996R)
サーボ機構において位置及び速度を制御できるモーターのことである。サーボドライバーとセットでサーボ機構を構成する。
LCDモジュール (ILI9341)
液晶パネルと、画像を表示するためのドライバ・コントローラーが組み込まれたものである。比較的安価で薄型、低消費電力であるため電子工作の部品としてよく使われる。
5.システムの構成
システムは図1の通りに3つに分かれている。メインシステムでは鍵の開錠・施錠の操作を行う。
不審者通知システムは夜間のみ起動し、不審人物がいたら写真を撮影する。検索システムはSupredSheetを用いて作成した。
システムは管理を行いやすくするために3つに分割している。また、メインシステムと不審者通知システム、検索システムはそれぞれ独立しており、互いにアクセスが行われることはない。各システムの詳細は6章から8章にて以降にて解説する。ソースコードの解説はそれぞれの章では行わず、10章にて解説する。

6.メインシステムについて
この章ではメインシステムについて解説を行う。メインシステムは主に2つのプログラムから成り立っている。2つのプログラムに分かれている理由は、解錠時と施錠時において操作の仕様が異なるためである。
まず、施錠操作についてである。施錠操作はどこからでも行えるようにしている。主な理由としては施錠を行い忘れた時にドアの前まで戻らずとも操作を行えるようにするためである。これによって施錠のし忘れというのを防ぎやすくなると考えている。ユーザーがWebアプリを開くとログイン画面が表示される。そこで事前に学校から配布されたアカウントを用いてログインを行うと生徒用のページが表示される。画面中央のlockボタンをタップすることによってWebアプリからSupabaseを経由してRaspberryPiに施錠リクエストが送信される。RaspberryPiがリクエストを受信したら施錠操作を行う仕組みとなっている。
次は解錠操作についてである。解錠操作についてはドアの前にいる時のみ解錠できるシステムとなっているが教職員と生徒用アカウントでは仕様が異なる設計となっている。まず、生徒用アカウントについて解説する。ログイン画面にてログインするときに2つの条件を元に生徒用アカウントか教職員用アカウントであるかを判定している。1つ目はメールアドレスの末尾が@kenryo.ed.jpであるかということだ。この条件に一致しないアカウントについては原則的にログインを行うことができない。2つ目の条件は@の前に年号の数字が4桁並んでおりその数字が現在の年の-3年以内にあるかということだ。この条件に一致したものを生徒用アカウントとして扱う。ただし、Supabaseでユーザーとして登録されていないメールアドレスについてはこれらの条件に一致してもログインを行うことができない。このログイン画面にて生徒用アカウントと判断されると生徒用ページが、教師アカウントと判断されると教職員用ページが表示される。
例)現在の年が2024年4月の場合
teacher-teacher@kenryo.ed.jp
⇨教師用アカウントとして分類
students-2021@kenryo.ed.jp
⇨生徒用アカウントとして分類(大学1年生)
note@gmail.com
⇨縣陵アドレスでないためログインすることはできない
現在の年が2025年1月の場合
students-2022@kenryo.ed.jp
⇨生徒用アカウントとして分類(高校3年生)
students-2021@kenryo.ed.jp
⇨4年前に作成されたアカウントであるため
教師用アカウントとして分類(大学1年生)
解錠操作を行うためには2段階認証を突破しないと行うことができないようになっている。それは生徒が自宅などの学校外からドアを開けられる状況が発生しないようにするためだ。2段階認証の仕組みとしてはドアに設置するLCDモジュール(ディスプレイ)に10秒ごとに有効期間が20秒の4桁パスワードを表示する。それを利用者はWebアプリのunlockボタンをタップした時に表示される認証ページに入力してdoneボタンをクリックする。Webアプリは解錠リクエストを送信するときに入力されたパスコードをセットにしてSupabase経由でRaspberryPiに送信する。RaspberryPiでコードが一致するかの確認を行い、一致する時のみ解錠操作を行う。教職員用アカウントでログインしているときはunlockをクリックすると写真5のようなテキストが表示され解錠するを選択すると解錠操作を行うことができる。教職員用アカウントでは2段階認証を行っていない理由は、生徒よりも教職員の方が探究ラボを利用する頻度が多いことが推測されるため、利用するたびに2段階認証を行うのでは相当な手間がかかってしまうのではないかと考えたためである。
解錠・施錠のそれぞれの操作が完了したタイミングで操作履歴をSupabaseに追加する。
7.不審者通知システムについて
この章では不審者通知システムについて解説する。不審者通知システムは事前に設定された時間帯のみ作動する。ドアの前の様子をWebカメラで監視し、直前の様子と比較して変化があった時に写真を撮影してDiscordに送信する。Discordへの写真の送信はWebhookを用いて行う。
Discordに写真を送信するときは通常のメッセージなどのような形式ではなくembedと呼ばれる形式を用いて送信している。このメリットとしては画像を埋め込んで送信することができるのでどのタイミングで送信されたメッセージに画像が対応しているかというのが一目でわかるというメリットがある。今回開発したシステムでは不審者を検知した時に撮影した写真と撮影時刻、事前に設定されていた通知メッセージをセットにしてembedで送信している。また、LINEなどのメッセージアプリではなくDiscordを用いたのには2つ理由がある。
1つ目は過去のメッセージを検索しやすいということだ。LINEではオープンチャットを除くとメッセージの保存を端末に依存している。そのため、グループLINE(オープンチャット)などでは自分のアカウントが参加する以前のトーク履歴の検索ができない。しかし、Discordはメッセージをクラウド上に保存しているため、自分がサーバー(LINEのグループと同じ)に参加する以前のメッセージについても検索することができる。
2つ目は、画像などの保存期限がないということである。LINEでは写真やファイル、動画などは送信してから30日(ファイルは7日)が経過すると閲覧することができない。しかし、不審者などを記録するときに1ヶ月程で閲覧できなくなってしまうのは不審者を捜索するときなどに悪影響となってしまう。そのため、現時点においては半永久的に送信した写真やファイルなどを保存できるDiscordを用いた。
8.検索システムについて
検索システムはGoogle SpreadSheetを用いて開発した。理由としては、教職員が簡単に使うことができることを目的として開発しており、現在の縣陵ではGoogle Driveをさまざまな面で活用しているためSpread Sheetならば使い方の説明等をせずにすぐに導入できるのではないかと考えたからである。システムの構造としては大きく二つに分けることができる。関数を使った処理とGoogleAppsScriptを使った処理である。
まずはGoogle Apps Scriptを用いた処理について解説する。実行している内容としてはSupabaseの操作ログが保存されたテーブルの最新のものを表示されたviewというテーブルにアクセスして操作履歴を取得する。取得するときはJSONと呼ばれるテキスト形式で取得する。取得したデータをスプレッドシートに月毎のシートに分割して保存する。このとき、各月のシートのA列にlog_idが記録されている。最新の月のシートの最終行から取得したlog_idを取得したデータのlog_idと比較して、SupreadSheetに記録されていないデータがあるかを判断する。記録されていないデータがあったときは、該当のデータをスプレッドシートに記録する。
次に関数で行っている処理について解説する。QUERY関数を用いて検索を行っている。ユーザーは検索ワードに条件指定を行うことで検索を行うことができる。年と月を指定しないと検索対象となるシートが決定できないため検索を行うことはできない。項目1と項目2にはユーザーが自由に検索条件を指定できる。また、検索項目名や条件も自由に変更することができる。検索時に使用した関数は次の通りである。
=QUERY(IMPORTRANGE("sheet_id",C5&C6&"!B:G"),$F$9,1)この関数はまず、IMPORTRANGE関数を用いて検索対象のシートを指定している。そのため、操作データを格納するスプレッドシートは検索用のシートと別にすることもできる。C5には年(西暦)がC6には月が入力されるためそれを使ってシート名にしている。(2024年6月のデータは202406という名前のシートに格納される)QUERY関数では検索用のクエリをF9で指定している。F9では検索条件をもとにクエリを作成している。
9.開発における課題と対応策
今回の開発においては2つの重大なトラブルが発生した。この章ではそれらについて記す。
1つ目はメインシステムの仕様変更である。このシステムは当初物理化学部の部室にて採用することを想定していた。そのため、利用人数が30人にも満たない想定であった。物理化学部ではSuicaカードを所持している人が多くいるため、Suicaカードに内蔵されているNFCタグを用いて鍵の解錠施錠をするシステムを開発していた。しかし、部室へのドアシステムの設置を検討したときに、Wi-Fiが届いていないということと、電気を使うことができないという理由で諦めざるを得なかった。そのような時に校内設置予定の探究ラボに設置しないかという提案を受けた。そのため利用人数が1000人以上に増加してしまった。これによって、NFCタグを使ったスマートロックは管理コストが膨大になってしまう。そこで、NFCタグを使用せずに鍵の解錠・施錠を行う方法を検討しなければならなくなった。そして、操作方法を検討した結果Webアプリを用いて鍵の操作を行うことにした。(図2)

2つ目はPythonのライブラリの管理についてである。今回開発したシステムでは様々なライブラリと呼ばれるモジュールや外部サービスとの連携のためのツールを用いている。これらのライブラリは定期的に更新され機能が追加されたり、バグが少なくなったりする。しかし、大幅なバージョン更新の際には従来では動作していたコードが動作しなくなることもある。今回のシステム開発中にもSupabaseとの連携を行うライブラリの更新が起こってしまった。それによってこれまで開発していたコードが使えなくなってしまった。対応策としてはライブラリをダウングレードして旧バージョンを用いることでシステムを開発した。
10.開発したコードの解説
ソースコードについての解説である。興味がない場合などは読み飛ばして差し支えない。(11章へ)
10-1.ウェブアプリについて
ウェブアプリのlibフォルダ内のコードについての解説のみ載せる。utilsフォルダ内のソースコードについての解説は掲載しない。GitHubを参考するようにしてほしい。
| lib/main.dart | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter_dotenv/flutter_dotenv.dart'; | |
| import 'package:supabase_flutter/supabase_flutter.dart'; | |
| import 'package:login/pages/login_page.dart'; | |
| Future<void> main() async{ | |
| WidgetsFlutterBinding.ensureInitialized(); | |
| // .envを読み込めるように設定. | |
| await dotenv.load(fileName: '.env'); | |
| await Supabase.initialize( | |
| url: dotenv.get('LET_URL'), // .envのURLを取得. | |
| anonKey: dotenv.get('LET_ANONKEY'), // .envのanonキーを取得. | |
| ); | |
| runApp(const MyApp()); | |
| } | |
| final supabase = Supabase.instance.client; | |
| class MyApp extends StatelessWidget { | |
| // ignore: use_super_parameters | |
| const MyApp({Key? key}): super(key: key); | |
| @override | |
| Widget build(BuildContext context) { | |
| return const MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| title: 'KENRYO STEAM LAB.', | |
| home: LoginPage(), | |
| ); | |
| } | |
| } | |
| class MyHomePage extends StatefulWidget { | |
| const MyHomePage({super.key, required this.title}); | |
| final String title; | |
| @override | |
| State<MyHomePage> createState() => _MyHomePageState(); | |
| } | |
| class _MyHomePageState extends State<MyHomePage> { | |
| int _counter = 0; | |
| void _incrementCounter() { | |
| setState(() { | |
| _counter++; | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar( | |
| backgroundColor: Theme.of(context).colorScheme.inversePrimary, | |
| title: Text(widget.title), | |
| ), | |
| body: Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: <Widget>[ | |
| const Text( | |
| 'You have pushed the button this many times:', | |
| ), | |
| Text( | |
| '$_counter', | |
| style: Theme.of(context).textTheme.headlineMedium, | |
| ), | |
| ], | |
| ), | |
| ), | |
| floatingActionButton: FloatingActionButton( | |
| onPressed: _incrementCounter, | |
| tooltip: 'Increment', | |
| child: const Icon(Icons.add), | |
| ), | |
| ); | |
| } | |
| } |
このファイルではSupabaseのURLとAPI_Keyの設定やサイトの名称を設定している。
SupabseのURLとAPI_Keyの設定は.envファイルに設定しておいたものを取得している。
| lib/pages/login_pages.dart | |
| // ignore_for_file: unused_import, prefer_interpolation_to_compose_strings, use_build_context_synchronously, duplicate_ignore | |
| //import 'dart:ffi'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:login/pages/register_page.dart'; | |
| import 'package:login/utils/constants.dart'; | |
| import 'package:supabase_flutter/supabase_flutter.dart'; | |
| import 'package:login/pages/home_page.dart'; | |
| import 'package:flutter_secure_storage/flutter_secure_storage.dart'; | |
| import 'package:login/pages/hide_page.dart'; | |
| class LoginPage extends StatefulWidget { | |
| // ignore: use_super_parameters | |
| const LoginPage({Key? key}) : super(key: key); | |
| static Route<void> route() { | |
| return MaterialPageRoute(builder: (context) => const LoginPage()); | |
| } | |
| @override | |
| LoginPageState createState() => LoginPageState(); | |
| } | |
| const storage = FlutterSecureStorage(); | |
| class LoginPageState extends State<LoginPage> { | |
| bool _isLoading = false; | |
| final _emailController = TextEditingController(); | |
| final _passwordController = TextEditingController(); | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _loadLoginInfo(); | |
| } | |
| Future<void> _loadLoginInfo() async { | |
| final email = await storage.read(key: 'email'); | |
| final password = await storage.read(key: 'password'); | |
| if (email != null && password != null) { | |
| _emailController.text = email; | |
| _passwordController.text = password; | |
| } | |
| } | |
| Future<void> _signIn() async { | |
| setState(() { | |
| _isLoading = true; | |
| }); | |
| try { | |
| await supabase.auth.signInWithPassword( | |
| email: _emailController.text, | |
| password: _passwordController.text, | |
| ); | |
| // Save login info | |
| await storage.write(key: 'email', value: _emailController.text); | |
| await storage.write(key: 'password', value: _passwordController.text); | |
| DateTime now = DateTime.now(); | |
| // 今年の西暦を取得 | |
| int thisYear = now.year; | |
| int nextYear = thisYear - 1; | |
| int lastYear = thisYear - 2; | |
| int secondYear = thisYear - 3; | |
| // Navigate to home page | |
| // ignore: use_build_context_synchronously | |
| if(_emailController.text.endsWith(lastYear.toString() +"@kenryo.ed.jp") || _emailController.text.endsWith(thisYear.toString() +"@kenryo.ed.jp") || _emailController.text.endsWith(nextYear.toString() +"@kenryo.ed.jp") || _emailController.text.endsWith(secondYear.toString() +"@kenryo.ed.jp")){ | |
| Navigator.of(context) | |
| .pushAndRemoveUntil(HomePage.route(), (route) => false); | |
| }else if(_emailController.text.endsWith("@kenryo.ed.jp")){ | |
| Navigator.of(context) | |
| .pushAndRemoveUntil(HidePage.route(), (route) => false); | |
| }else{ | |
| context.showErrorSnackBar(message: "kenryo.ed.jpのメールアドレスを入力してください。"); | |
| } | |
| } on AuthException catch (error) { | |
| // ignore: use_build_context_synchronously | |
| context.showErrorSnackBar(message: error.message); | |
| } catch (_) { | |
| // ignore: use_build_context_synchronously | |
| context.showErrorSnackBar(message: unexpectedErrorMessage); | |
| } | |
| if (mounted) { | |
| setState(() { | |
| _isLoading = false; | |
| }); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| _emailController.dispose(); | |
| _passwordController.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar(title: const Text('ログイン')), | |
| body: ListView( | |
| padding: formPadding, | |
| children: [ | |
| TextFormField( | |
| controller: _emailController, | |
| decoration: const InputDecoration(labelText: 'メールアドレス'), | |
| keyboardType: TextInputType.emailAddress, | |
| ), | |
| formSpacer, | |
| TextFormField( | |
| controller: _passwordController, | |
| decoration: const InputDecoration(labelText: 'パスワード'), | |
| obscureText: true, | |
| ), | |
| formSpacer, | |
| ElevatedButton.icon( | |
| onPressed: _isLoading ? null : _signIn, | |
| label: const Text('ログイン'), | |
| icon: const Icon(Icons.login), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| }} |
このファイルではLoginPageを作成している。ログインアカウントはSupabaseのAuthに登録されたユーザーのみしかログインできない。65行目のif文で縣陵アドレスが末尾4桁の数字が指定範囲内の数字であるかを確認して生徒用アカウントであるかを判定している。70行目にあるelse ifで教師アカウントであるかを判定している。上記までの条件に一致しないメールアドレスを使っているアカウントに関してはログインすることができないようにしている。教師アカウントでログインしようとしている場合はHide Page(教職員用ページ)に、生徒アカウントでログインしようとしている場合はHome Page(生徒用ページ)に転送されるようになっている。
| lib/pages/home_page.dart | |
| // ignore: unused_import | |
| // ignore_for_file: unused_field, sort_child_properties_last, prefer_const_constructors | |
| // ignore: unused_import | |
| import 'dart:async'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:login/pages/login_page.dart'; | |
| // ignore: unused_import | |
| import 'package:login/utils/constants.dart'; | |
| // ignore: unused_import | |
| import 'package:supabase_flutter/supabase_flutter.dart'; | |
| // ignore: unused_import | |
| import 'package:login/pages/authentication_page.dart'; | |
| class HomePage extends StatefulWidget { | |
| // ignore: use_super_parameters | |
| const HomePage({Key? key}) : super(key: key); | |
| static Route<void> route() { | |
| return MaterialPageRoute( | |
| builder: (context) => const HomePage(), | |
| ); | |
| } | |
| @override | |
| State<HomePage> createState() => HomePageState(); | |
| } | |
| class HomePageState extends State<HomePage> { | |
| // ignore: non_constant_identifier_names | |
| String Door = "Select the operation."; | |
| int _counter = 0; | |
| // ignore: unused_element | |
| void _incrementCounter() { | |
| setState(() { | |
| _counter++; | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar( | |
| title: const Row(children: [ | |
| Icon(Icons.sensor_door), | |
| Text("KENRYO STEAM LAB.") | |
| ]), | |
| ), | |
| drawer: Drawer( | |
| child: ListView( | |
| children: [ | |
| DrawerHeader( | |
| child: Center( | |
| child: Text( | |
| 'Menu', | |
| style: TextStyle( | |
| fontWeight: FontWeight.bold, | |
| fontSize: 20, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ListTile( | |
| title: Text('Logout'), | |
| onTap: () => {Navigator.of(context) | |
| .pushAndRemoveUntil(LoginPage.route(), (route) => false) | |
| }, | |
| ), | |
| ], | |
| ), | |
| ), | |
| body: Column( | |
| mainAxisAlignment : MainAxisAlignment.center, | |
| children: <Widget>[ | |
| TextButton.icon( | |
| onPressed: ()async{ | |
| await supabase | |
| .from('action') | |
| .insert({'text': 'Lock','number':424242}); | |
| final channelB = supabase.channel('admin'); | |
| channelB.subscribe((status, error) { | |
| if (status != RealtimeSubscribeStatus.subscribed) { | |
| return; | |
| } | |
| // Send a message once the client is subscribed | |
| channelB.sendBroadcastMessage( | |
| event: 'RequestForLocking', | |
| payload: {'payload': 424242, 'user':'student'}, | |
| ); | |
| }); | |
| Door = 'Door is Locked.'; | |
| setState((){}); | |
| }, | |
| icon: const Icon( | |
| Icons.lock, | |
| color:Colors.pink, | |
| size : 50 | |
| ), | |
| label: const Flexible(child: Text('lock', | |
| style: TextStyle( | |
| fontSize: 30,)), | |
| ), | |
| ), | |
| TextButton.icon( | |
| onPressed: ()async{Navigator.of(context) | |
| .pushAndRemoveUntil(AuthenticationPage.route(), (route) => false); | |
| Door = 'Door is Unlocked.'; | |
| await supabase | |
| .from('action') | |
| .insert({'text': 'Unlock Request','number':565656}); | |
| }, | |
| icon: const Icon( | |
| Icons.lock_open, | |
| color:Colors.blue, | |
| size : 50 | |
| ), | |
| label: const Flexible(child: Text('unlock', | |
| style: TextStyle( | |
| fontSize: 30,)), | |
| ), | |
| ), | |
| Container( | |
| width: double.infinity, | |
| height: 100, | |
| decoration: BoxDecoration( | |
| border: Border.all(color: Colors.green, width: 4), | |
| borderRadius: BorderRadius.circular(8), | |
| ), | |
| margin: const EdgeInsets.all(10), | |
| child: Center( | |
| child: Text( | |
| Door, | |
| style: const TextStyle(fontSize: 36,), | |
| )), | |
| ), | |
| Container( | |
| alignment: Alignment.bottomCenter, | |
| width: double.infinity, | |
| child: const Text("Made By Hinata Misawa, Kotaro Otsuka in 2024 ©") | |
| ), | |
| ],), | |
| floatingActionButton: | |
| FloatingActionButton( | |
| onPressed: () => {Navigator.of(context) | |
| .pushAndRemoveUntil(LoginPage.route(), (route) => false) | |
| }, | |
| child: const Icon(Icons.logout) | |
| ), | |
| ); | |
| } | |
| } | |
HomePage(生徒用ページ)作成している。60行目からのbodyでページのデザインやボタンを定義している。109行目のTestbutton .iconではunlockボタンを定義しており、クリックされた時にAuthenticationPageに遷移するように定義されている。140行目にあるfloatingActionButtonではページ右下に設置しているログアウトボタンを定義している。ログアウトボタンをクリックするとLoginPageに遷移するようになっている。
| lib/pages/authentication_page.dart | |
| // ignore: unused_import | |
| // ignore_for_file: unused_field, avoid_print, non_constant_identifier_names | |
| // ignore: unused_import | |
| import 'dart:async'; | |
| import 'package:flutter/material.dart'; | |
| // ignore: unused_import | |
| import 'package:login/main.dart'; | |
| import 'package:login/pages/home_page.dart'; | |
| // ignore: unused_import | |
| import 'package:login/utils/constants.dart'; | |
| // ignore: unused_import | |
| import 'package:supabase_flutter/supabase_flutter.dart'; | |
| class AuthenticationPage extends StatefulWidget { | |
| // ignore: use_super_parameters | |
| const AuthenticationPage({Key? key}) : super(key: key); | |
| static Route<void> route() { | |
| return MaterialPageRoute( | |
| builder: (context) => const AuthenticationPage(), | |
| ); | |
| } | |
| @override | |
| State<AuthenticationPage> createState() => AuthenticationPageState(); | |
| } | |
| class AuthenticationPageState extends State<AuthenticationPage> { | |
| // Create a text controller and use it to retrieve the current value | |
| // of the TextField. | |
| final auth_number = TextEditingController(); | |
| @override | |
| void dispose() { | |
| // Clean up the controller when the widget is disposed. | |
| auth_number.dispose(); super.dispose(); | |
| } | |
| final supabase = Supabase.instance.client; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar( | |
| title: const Row(children: [ | |
| Icon(Icons.sensor_door), | |
| Text("Kenryo Lab Application") | |
| ]), | |
| ), | |
| body: Column( | |
| mainAxisAlignment : MainAxisAlignment.center, | |
| children: <Widget>[ | |
| // ignore: prefer_const_constructors | |
| Text( | |
| "ドアに表示されている認証コードを入力してください", | |
| // ignore: prefer_const_constructors | |
| style: TextStyle(fontSize: 17), | |
| ), | |
| TextFormField( | |
| keyboardType: TextInputType.number, | |
| controller: auth_number, | |
| decoration: const InputDecoration( | |
| border: UnderlineInputBorder(), | |
| labelText: 'Enter number.', | |
| ), | |
| ), | |
| TextButton.icon( | |
| onPressed: ()async{ | |
| final message = auth_number.text; | |
| final channelB = supabase.channel('admin'); | |
| channelB.subscribe((status, error) { | |
| // Wait for successful connection | |
| debugPrint('debug: _SendMessage'); | |
| if (status != RealtimeSubscribeStatus.subscribed) { | |
| return; | |
| } | |
| // Send a message once the client is subscribed | |
| channelB.sendBroadcastMessage( | |
| event: 'RequestForUnlocking', | |
| payload: {'payload': message, 'user':'student'}, | |
| ); | |
| }); | |
| print("45"); | |
| Navigator.of(context) | |
| .pushAndRemoveUntil(HomePage.route(), (route) => false); | |
| setState((){}); | |
| }, | |
| icon: const Icon( | |
| Icons.done, | |
| color:Colors.blue, | |
| size : 50 | |
| ), | |
| label: const Flexible(child: Text('done', | |
| style: TextStyle( | |
| fontSize: 30,)), | |
| ), | |
| ), | |
| Container( | |
| alignment: Alignment.bottomCenter, | |
| width: double.infinity, | |
| child: const Text("made by Kenryo Physics and Chemistry Club") | |
| ), | |
| ],), | |
| ); | |
| } | |
| } | |
AuthenticationPageを定義している。入力フィールドは56行目に定義されており、数字キーボードのみしか出ないようになっている。入力されたパスコードはSupabaseを通してRaspberryPiに送られる。数字入力してページの中央部にあるdoneボタンをクリックするときの動作は63行目に定義されており、HomePageに遷移すると同時にパスコードの送信を行なっている。
| lib/pages/hide_page.dart | |
| // ignore: unused_import | |
| // ignore_for_file: unused_field, sort_child_properties_last, prefer_const_constructors, unused_import, duplicate_ignore | |
| // ignore: unused_import | |
| import 'dart:async'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:login/pages/home_page.dart'; | |
| import 'package:login/pages/login_page.dart'; | |
| // ignore: unused_import | |
| import 'package:login/utils/constants.dart'; | |
| // ignore: unused_import | |
| import 'package:supabase_flutter/supabase_flutter.dart'; | |
| // ignore: unused_import | |
| import 'package:login/pages/authentication_page.dart'; | |
| import 'package:url_launcher/url_launcher.dart'; | |
| class HidePage extends StatefulWidget { | |
| // ignore: use_super_parameters | |
| const HidePage({Key? key}) : super(key: key); | |
| static Route<void> route() { | |
| return MaterialPageRoute( | |
| builder: (context) => const HidePage(), | |
| ); | |
| } | |
| @override | |
| State<HidePage> createState() => HidePageState(); | |
| } | |
| class AlertDialogSample extends StatelessWidget { | |
| const AlertDialogSample({Key? key}) : super(key: key); | |
| @override | |
| Widget build(BuildContext context) { | |
| return AlertDialog( | |
| title: Text('解錠してもいいですか?'), | |
| content: Text('学校外で解錠操作を行なっていませんか?'), | |
| actions: <Widget>[ | |
| ElevatedButton( | |
| child: Text('解錠しない'), | |
| onPressed: () { | |
| Navigator.of(context).pop(); | |
| }, | |
| ), | |
| ElevatedButton( | |
| child: Text('解錠する'), | |
| onPressed: () async { | |
| final channelB = supabase.channel('admin'); | |
| channelB.subscribe((status, error) { | |
| if (status != RealtimeSubscribeStatus.subscribed) { | |
| return; | |
| } | |
| // Send a message once the client is subscribed | |
| channelB.sendBroadcastMessage( | |
| event: 'RequestForUnlocking', | |
| payload: {'payload': 9999, 'user':'teacher'}, | |
| ); | |
| Navigator.of(context).pop(); | |
| }); | |
| }, | |
| ) | |
| ], | |
| ); | |
| } | |
| } | |
| class HidePageState extends State<HidePage> { | |
| // ignore: non_constant_identifier_names | |
| String Door = "Select the operation."; | |
| int _counter = 0; | |
| // ignore: unused_element | |
| void _incrementCounter() { | |
| setState(() { | |
| _counter++; | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar( | |
| title: const Row(children: [ | |
| Icon(Icons.sensor_door), | |
| Text("KERYO STEAM LAB.") | |
| ]), | |
| ), | |
| drawer: Drawer( | |
| child: ListView( | |
| children: [ | |
| DrawerHeader( | |
| child: Center( | |
| child: Text( | |
| 'Menu', | |
| style: TextStyle( | |
| fontWeight: FontWeight.bold, | |
| fontSize: 20, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ListTile( | |
| title: Text('Administrator'), | |
| onTap: () => {Navigator.of(context) | |
| .pushAndRemoveUntil(HidePage.route(), (route) => false) | |
| }, | |
| ), | |
| ListTile( | |
| title: const Text('Log_Search'), | |
| onTap: (){ | |
| final url = Uri.parse('SpreadSheet Link'); | |
| launchUrl(url); | |
| }, | |
| ), | |
| ListTile( | |
| title: const Text('KenyroHP'), | |
| onTap: (){ | |
| final url = Uri.parse('https://kenryo.ed.jp'); | |
| launchUrl(url); | |
| }, | |
| ), | |
| ListTile( | |
| title: const Text('KenryoArchive'), | |
| onTap: (){ | |
| final url = Uri.parse('https://drive.google.com/drive/folders/0AG2mAiSF-9WuUk9PVA'); | |
| launchUrl(url); | |
| }, | |
| ), | |
| ListTile( | |
| title: Text('Logout'), | |
| onTap: () => {Navigator.of(context) | |
| .pushAndRemoveUntil(LoginPage.route(), (route) => false) | |
| }, | |
| ), | |
| ], | |
| ), | |
| ), | |
| body: Column( | |
| mainAxisAlignment : MainAxisAlignment.center, | |
| children: <Widget>[ | |
| Text("Administrator", | |
| style: const TextStyle( | |
| fontSize: 35, | |
| color: Colors.blue | |
| ), | |
| ), | |
| const SizedBox(height: 30), | |
| TextButton.icon( | |
| onPressed: ()async{ | |
| await supabase | |
| .from('action') | |
| .insert({'text': 'Lock','number':424242}); | |
| final channelB = supabase.channel('admin'); | |
| channelB.subscribe((status, error) { | |
| if (status != RealtimeSubscribeStatus.subscribed) { | |
| return; | |
| } | |
| // Send a message once the client is subscribed | |
| channelB.sendBroadcastMessage( | |
| event: 'RequestForLocking', | |
| payload: {'payload': 424242, 'user':'teacher'}, | |
| ); | |
| }); | |
| Door = 'Door is Locked.'; | |
| setState((){}); | |
| }, | |
| icon: const Icon( | |
| Icons.lock, | |
| color:Colors.pink, | |
| size : 50 | |
| ), | |
| label: const Flexible(child: Text('lock', | |
| style: TextStyle( | |
| fontSize: 30,)), | |
| ), | |
| ), | |
| TextButton.icon( | |
| onPressed: ()async{ | |
| await supabase | |
| .from('action') | |
| .insert({'text': 'Lock','number':565656}); | |
| showDialog<void>( | |
| context: context, | |
| builder: (_) { | |
| return AlertDialogSample(); | |
| }); | |
| Door = 'Finish Action.'; | |
| setState((){}); | |
| }, | |
| icon: const Icon( | |
| Icons.lock_open, | |
| color:Colors.blue, | |
| size : 50 | |
| ), | |
| label: const Flexible(child: Text('unlock', | |
| style: TextStyle( | |
| fontSize: 30,)), | |
| ), | |
| ), | |
| Container( | |
| width: double.infinity, | |
| height: 100, | |
| decoration: BoxDecoration( | |
| border: Border.all(color: Colors.green, width: 4), | |
| borderRadius: BorderRadius.circular(8), | |
| ), | |
| margin: const EdgeInsets.all(10), | |
| child: Center( | |
| child: Text( | |
| Door, | |
| style: const TextStyle(fontSize: 36,), | |
| )), | |
| ), | |
| Container( | |
| alignment: Alignment.bottomCenter, | |
| width: double.infinity, | |
| child: const Text("Made By Hinata Misawa, Kotaro Otsuka in 2024 ©") | |
| ), | |
| ],), | |
| floatingActionButton: | |
| FloatingActionButton( | |
| onPressed: () => {Navigator.of(context) | |
| .pushAndRemoveUntil(LoginPage.route(), (route) => false) | |
| }, | |
| child: const Icon(Icons.logout) | |
| ), | |
| ); | |
| } | |
| } | |
HidePageを定義している。LoginPageで教職員アカウントと判断されたアカウントはHome Pageではなくこのページに遷移する。生徒用アカウントとは異なり解錠操作を行うときに2段階認証ページに飛ばされずに確認ダイアログが表示され「解錠する」を選択されたときに解錠操作を行うためのリクエストを送信する。この確認ダイアログは33行目にて定義され、174行目のunlockボタンの動作定義の際に呼び出されている。また、教職員用ページではページの左にメニューバーが設置されておりlog検索システムなどを開くことができるようになっている。それらの定義は86行目から136行目にて定義されている。
10-2.設定の読み込みについて
不審者通知システムの動作時間などの設定はSupabaseに格納されている。これらの設定データをJSON形式で取得してそれぞれの変数に代入するのがA_Setting.pyである。
| A_Setting.py | |
| import datetime | |
| from supabase import create_client | |
| #Supabaseのリンク | |
| PROJECT_URL =URL | |
| API_KEY = API_Key | |
| supabase = create_client(PROJECT_URL, API_KEY) | |
| #supabaseの設定情報を入力したテーブルを取得 | |
| settingdata = supabase.table("setting").select("*").execute() | |
| data ,_= settingdata | |
| settinglist = { d['Item_name']:d for d in data[1]} | |
| #print(A_settinglist) | |
| #DiscordのWebhook URL | |
| webhook_url = settinglist['webhook_url']['1'] | |
| #不審者検知を行いたい時間 | |
| Systemstart = datetime.time(int(settinglist['Systemstart']['1']),int(settinglist['Systemstart']['2']),int(settinglist['Systemstart']['3'])) | |
| Systemend = datetime.time(int(settinglist['Systemend']['1']),int(settinglist['Systemend']['2']),int(settinglist['Systemend']['3'])) | |
| #Disccordのユーザー名 | |
| username = settinglist['username']['1'] | |
| #認証失敗時のメッセージ | |
| description1 = settinglist['description1']['1'] | |
| #tablename | |
| listname1, listname2 = settinglist['listname']['1'], settinglist['listname']['2'] | |
| #動体検知に引っかかったときのメッセージ | |
| description2 = settinglist['description2']['1'] | |
| color_hex = settinglist['color_hex']['1'] | |
| #アプリに使用しているデータベースのURLとAPI_Key | |
| login_database_URL = settinglist['supabase_Authentication']['1'] | |
| login_database_Key = settinglist['supabase_Authentication']['2'] | |
| host_url = settinglist['host_url']['1'] |
このプログラムではSupabaseに保存されているシステム設定を読み込んでいる。このプログラムはそれぞれのプログラムの起動時に実行し、各プログラムで必要な設定情報を返している。データはSupabaseからはJSON形式で取得する。取得したデータの名前を確認してそれぞれの変数に代入している。
10-3.不審者通知システムについて
| B_Main.py | |
| import datetime | |
| import subprocess | |
| from supabase import create_client | |
| import A_Setting | |
| import D_Suspicious # type: ignore | |
| #動体検知システムを作動させない時間帯の設定 | |
| Systemstart = A_Setting.Systemstart | |
| Systemend = A_Setting.Systemend | |
| while True: | |
| #現在時刻の取得 | |
| dt_now = datetime.datetime.now() | |
| nowtime = dt_now.time() | |
| #時間帯によるシステムの切り替え | |
| if nowtime <= Systemstart and nowtime >= Systemend: | |
| #動体検知システムを作動 | |
| proc = subprocess.Popen(D_Suspicious) | |
| proc.communicate |
指定の時間になったら不審者検知システムを起動するためのプログラムである。While trueループで時間を取得し続ける。Supabaseで設定されたシステムの開始時間になったらD_Suspicious.pyを呼び出して不審者検知システムを起動する。
| D_Suspicious | |
| import discord # type: ignore | |
| import aiohttp | |
| import asyncio | |
| import datetime | |
| import cv2 # type: ignore | |
| import cv2 as cv # type: ignore | |
| from discord import Webhook # type: ignore | |
| import A_Setting | |
| #discordのwebhookのURL | |
| webhook_url = A_Setting.webhook_url | |
| #時間の設定 | |
| start = A_Setting.Systemstart | |
| end = A_Setting.Systemend | |
| #カメラで撮影用 | |
| camera = cv.VideoCapture(0) | |
| camera.set(cv.CAP_PROP_FRAME_WIDTH, 640) | |
| camera.set(cv.CAP_PROP_FRAME_HEIGHT, 480) | |
| DELTA_MAX = 255 | |
| DOT_TH = 20 | |
| MOTHON_FACTOR_TH = 0.20 | |
| avg = None | |
| #現在の時刻を取得 | |
| dt_now = datetime.datetime.now() | |
| now = dt_now.time() | |
| while now < start or now >end: | |
| #現在の時刻の更新 | |
| dt_now = datetime.datetime.now() | |
| #sys.stdout = open("_System.log", "a") | |
| print(dt_now) | |
| now = dt_now.time() | |
| #カメラの画像を取得 | |
| ret,frame =camera.read() | |
| motion_detected = False | |
| dt_now = datetime.datetime.now() #データを取得した時刻 | |
| # モノクロにする | |
| gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY) | |
| #比較用のフレームを取得する | |
| if avg is None: | |
| avg = gray.copy().astype("float") | |
| continue | |
| # 現在のフレームと移動平均との差を計算 | |
| cv.accumulateWeighted(gray, avg, 0.6) | |
| frameDelta = cv.absdiff(gray, cv.convertScaleAbs(avg)) | |
| # デルタ画像を閾値処理を行う | |
| thresh = cv.threshold(frameDelta, DOT_TH, DELTA_MAX, cv.THRESH_BINARY)[1] | |
| #モーションファクターを計算する。全体としてどれくらいの割合が変化したか。 | |
| motion_factor = thresh.sum() * 1.0 / thresh.size / DELTA_MAX | |
| motion_factor_str = '{:.08f}'.format(motion_factor) | |
| #モーションファクターがしきい値を超えていれば動きを検知したことにする | |
| if motion_factor > MOTHON_FACTOR_TH: | |
| motion_detected = True | |
| # 動き検出していれば画像を保存する | |
| if motion_detected == True: | |
| title="DoorAleart.jpeg" | |
| cv.imwrite(title, frame) | |
| img = cv2.imread('DoorAleart.jpeg') | |
| img_rotate_180 = cv2.rotate(img, cv2.ROTATE_180) | |
| cv2.imwrite(title, img_rotate_180) | |
| print(title) | |
| dt_now = datetime.datetime.now() | |
| #メッセージの詳細 | |
| username = A_Setting.username | |
| title = (dt_now.strftime('%Y年%m月%d日 %H:%M:%S')) | |
| description = A_Setting.description2 | |
| color_hex = A_Setting.color_hex | |
| image_path = "DoorAleart.jpeg" | |
| pathid = "DoorAleart.jpeg" | |
| #discordに画像を送信する | |
| file = discord.File(image_path, filename="DoorAleart.jpeg") | |
| embed = discord.Embed( | |
| title=title, description=description, color=int(color_hex, 16) | |
| ).set_image(url="attachment://"+pathid) | |
| async def foo(): | |
| async with aiohttp.ClientSession() as session: | |
| webhookdata = Webhook.from_url(webhook_url, session=session) | |
| await webhookdata.send(username=username, embed=embed, file=file) | |
| asyncio.run(foo()) | |
| #sys.stdout = sys.__stdout__ | |
| key = cv.waitKey(1) | |
| #ESCキーが押されたら終了する | |
| if key == 27: | |
| break | |
| camera.release() | |
| cv.destroyAllWindows() |
B_Main.pyに呼び出される不審者検知システムである。32行目のwhile文でシステムが起動時間であることを確認し、超過した場合は終了する。63行目のif文でドアの前が直前と比較し変化したかを確認する。変化していた場合はmotion_detectedという変数に代入されたフラグを変更する。これによって67行目のif文でDiscordへのメッセージ送信を行うことができる。RaspberryPiのストレージは小さいため写真については変化するたびに新規ファイルとして保存するのではなく、DoorAleart.jpegを書き換える形にしてストレージの消費を抑えている。
コード中に出てくるprint文については動作の様子をターミナルと呼ばれるウィンドウに表示するためのものである。特にシステムには影響はないので気にしなくてよい。
10-4.解錠システムについて
| F_Host_Unlock | |
| import asyncio | |
| import datetime | |
| from random import randint | |
| from realtime import AsyncRealtimeClient | |
| import A_Setting | |
| import H_Unlock | |
| import I_Passcode | |
| #Supabaseの設定 | |
| url = A_Setting.host_url | |
| key = A_Setting.login_database_Key | |
| #ブロードキャストの設定 | |
| eventName = "RequestForUnlocking" | |
| channelName = "admin" | |
| Systemstart = A_Setting.Systemstart | |
| Systemend = A_Setting.Systemend | |
| #パスコードの設定 | |
| newPassCodeInterval = 20 | |
| passCode1, passCode2 = None, None | |
| #パスコードを生成する | |
| def randomPassCode(): | |
| return str(randint(1000, 8999)) | |
| #パスコードを変更する(直近2つを保持) | |
| def changePassCodes(): | |
| global passCode1, passCode2 | |
| passCode1 = passCode2 | |
| passCode2 = randomPassCode() | |
| print(f"新しいパスコードが生成されました:{passCode2}") | |
| I_Passcode.passcode(passCode1,passCode2) | |
| #ブロードキャストを受信したときの処理 | |
| def handle_broadcast(data): | |
| dt_now = datetime.datetime.now() | |
| #イベント名が一致しているか確認 | |
| if "event" in data and data["event"] == eventName: | |
| code = data["payload"] | |
| user = data["user"] | |
| print(str(dt_now) +f'\t パスコード受信:{code}') | |
| #パスコードが一致しているか確認 | |
| if code and code == passCode1 or code == passCode2: | |
| dt_now = datetime.datetime.now() | |
| nowtime = dt_now.time() | |
| if nowtime >= Systemstart and nowtime <= Systemend: | |
| #解錠処理 | |
| H_Unlock.unlock("student") | |
| else: | |
| print("時間外労働はお断りします") | |
| elif user == "teacher": | |
| print("特別措置") | |
| H_Unlock.unlock("user") | |
| else: | |
| print("*** パスコードが一致しません") | |
| else: | |
| print(str(dt_now) +f"*** 不明なイベントを受信しました:{data}") | |
| #新しいパスコードを生成する | |
| async def generatePassCodeContinuously(): | |
| while True: | |
| changePassCodes() | |
| await asyncio.sleep(newPassCodeInterval) | |
| #クライアントからのメッセージを受信する | |
| async def listenToBoadcasMessages(): | |
| client = AsyncRealtimeClient(url, key) | |
| await client.connect() | |
| channel = client.channel(channelName) | |
| await channel.on_broadcast(eventName , handle_broadcast).subscribe() | |
| await client.listen() | |
| #メイン処理 | |
| if __name__ == "__main__": | |
| changePassCodes() | |
| loop = asyncio.get_event_loop() | |
| tasks = [generatePassCodeContinuously(), listenToBoadcasMessages()] | |
| try: | |
| loop.run_until_complete(asyncio.gather(*tasks)) | |
| finally: | |
| loop.close() |
このコードではSupabaseから解錠リクエストを受信し、パスコードが一致していた際にH_Unlock.pyを呼び出して解錠操作を行う。def文は関数を定義しておりそれらを84行目からのメイン処理にて呼びだしている。randomPassCodeでは1000から9999の中からランダムに一つ選択し返すものである。changePassCodeではパスコードの変更を行う。また、I_PasscoderandomPassCodeを呼びパスコードを作成しpassCode2に代入する。その時に従来のpassCode2に代入されていたパスコードをpassCode1に代入する。handle_broadcastではSupabaseから解錠リクエストを受信した時の動作を定義している。If文でリクエストの名称が一致しているかを確認している。生徒が解錠操作をした時と教師が解錠操作した時ではリクエストの名称が異なるため、生徒リクエストの場合は操作可能時間かを確認している。教師リクエストの場合は解錠操作を行うためにH_Unlock.pyを呼び出す。生徒リクエストの場合は受信したパスコードを確認し、passCode1かpassCode2に一致した場合のみH_Unlock.pyを呼びだし解錠操作を行う。
| H_Unlock | |
| import pigpio | |
| import time | |
| from supabase import create_client | |
| from functools import partial | |
| import A_Setting | |
| #supabaseにアクセスできるようにする | |
| PROJECT_URL = A_Setting.PROJECT_URL | |
| API_KEY = A_Setting.API_KEY | |
| supabase = create_client(PROJECT_URL, API_KEY) | |
| #サーボモーターの設定 | |
| SERVO_PIN = 18 | |
| pi = pigpio.pi() | |
| #サーボモーターの準備 | |
| def set_angle(angle): | |
| assert 0 <= angle <= 180. | |
| pulse_width = (angle / 180) * (2500 - 500) + 500 | |
| pi.set_servo_pulsewidth(SERVO_PIN, pulse_width) | |
| def afrer(n, started): | |
| return time.time() - started > n | |
| def unlock(user): | |
| #sys.stdout = open("_Application_Unlock.log", "a") | |
| #データベースのテーブル名を取得 | |
| listname1 = A_Setting.listname1 | |
| listname2 = A_Setting.listname2 | |
| #ドアの状態を取得 | |
| Door_Log_datalist = supabase.table(listname1).select("*").execute() | |
| Door_list=[f"{user['door']}" for user in Door_Log_datalist.data] | |
| Door_list= [s for s in Door_list if s != '操作なし'] | |
| Door_condition = (Door_list[0]) | |
| #ドアの状態によって処理を変更 | |
| if Door_condition == "施錠": | |
| set_angle (0) | |
| message_door="解錠" | |
| print ("認証されたユーザーです。ドアを解錠します。\n") | |
| else: | |
| print ("認証されたユーザーです。ドアは解錠されているため追加の操作を行いません。\n") | |
| message_door="操作なし" | |
| #ログを記録 | |
| message_log = "認証済" | |
| UserName = user + "_Application" | |
| logdata = {"username": UserName,"certification": message_log, "door":message_door } | |
| supabase.table(listname2).insert(logdata).execute() | |
| return True | |
| #sys.stdout = sys.__stdout__ |
F_Host_Unlock.pyから呼び出された時に解錠操作を行う。Supabaseから操作履歴を取得し、直前の操作が施錠である場合は解錠操作を行う。呼び出される時に操作リクエストを行うのが教師と生徒のどちらであるかという情報を取得する。それと、解錠操作を行ったという情報を49行目にてJSON形式にし、51行目にてSupabaseに操作ログを追加する。これらの処理が完了すると、プログラムを終了させる。
| I_Passcode | |
| from adafruit_rgb_display.rgb import color565 # type: ignore | |
| from adafruit_rgb_display.ili9341 import ILI9341 | |
| from busio import SPI | |
| from digitalio import DigitalInOut | |
| import board | |
| from PIL import Image, ImageDraw, ImageFont | |
| def passcode (passcode1,passcode2): | |
| # Pin Configuration | |
| cs_pin = DigitalInOut(board.D8) | |
| dc_pin = DigitalInOut(board.D25) | |
| rst_pin = DigitalInOut(board.D24) | |
| # Set up SPI bus | |
| spi = SPI(clock=board.SCK, MOSI=board.MOSI, MISO=board.MISO) | |
| # Create the ILI9341 display: | |
| disp = ILI9341( | |
| spi, | |
| cs=cs_pin, dc=dc_pin, rst=rst_pin, | |
| width=240, height=320, | |
| rotation=90, | |
| baudrate=24000000 | |
| ) | |
| # Define image size (320x240, rotated) | |
| IMAGE_SIZE = (disp.height, disp.width) | |
| # Define fonts | |
| FONT_ROBOTO = ImageFont.truetype("Roboto-Medium.ttf", 15) | |
| FONT_NOTO = ImageFont.truetype("NotoSansCJK-Regular.ttc", 28) | |
| FONT_NOTO_SMALL = ImageFont.truetype("NotoSansCJK-Regular.ttc", 20) | |
| # Define colors | |
| COLOR_WHITE = (236, 239, 241) | |
| COLOR_PURPLE = (239, 154, 154) | |
| # Create an image with black background | |
| image = Image.new("RGB", IMAGE_SIZE, (0, 0, 0)) | |
| # Draw some text | |
| draw = ImageDraw.Draw(image) | |
| text = f"""\ | |
| 解錠用パスコードです。 | |
| {str(passcode2)}""" | |
| for i, line in enumerate(text.split("\n")): | |
| draw.text((0, 24*i), line, font=FONT_NOTO, fill=COLOR_WHITE) | |
| draw.text((40, 200), "縣陵探究ラボ \n", font=FONT_NOTO_SMALL, fill=COLOR_PURPLE) | |
| # Show it on display | |
| disp.image(image) |
F_Host_Unlock.pyのchangePassCodeで呼ばれた時のみ動作する。ディスプレイにパスコードを表示するためのプログラム。46行目のtestで画面に表示する内容を定義している。フォントサイズは32,33行目のFONT_ROBOTO、FONTO_NANOにて定義されている。
10-5.施錠システムについて
| E_Host_Lock | |
| import asyncio | |
| import datetime | |
| from random import randint | |
| from realtime import AsyncRealtimeClient | |
| import A_Setting | |
| import G_Lock | |
| # イベントを受信するための設定 | |
| url = A_Setting.host_url | |
| key = A_Setting.login_database_Key | |
| # イベント名とチャンネル名 | |
| eventName = "RequestForLocking" | |
| channelName = "admin" | |
| # イベントを受信したときの処理 | |
| def handle_broadcast(data): | |
| dt_now = datetime.datetime.now() | |
| #イベント名が一致しているか確認 | |
| if "event" in data and data["event"] == eventName: | |
| code = data["payload"] | |
| user = data["user"] | |
| #コードが一致しているか確認 | |
| if code and code == 424242: | |
| #sys.stdout = open("_Application_Lock.log", "a") | |
| print(str(dt_now) + "施錠命令受信:施錠します" + user) | |
| #sys.stdout = sys.__stdout__ | |
| #施錠処理 | |
| G_Lock.lock("user") | |
| else: | |
| #sys.stdout = open("_Application_Lock.log", "a") | |
| print(str(dt_now) +"施錠失敗") | |
| #sys.stdout = sys.__stdout__ | |
| else: | |
| #sys.stdout = open("_Application_Lock.log", "a") | |
| print(str(dt_now) +f"*** 不明なイベントを受信しました:{data}") | |
| #sys.stdout = sys.__stdout__ | |
| #クライアントからのメッセージを受信する | |
| async def listenToBloadcastMessages(): | |
| client = AsyncRealtimeClient(url, key) | |
| await client.connect() | |
| channel = client.channel(channelName) | |
| await channel.on_broadcast(eventName , handle_broadcast).subscribe() | |
| await client.listen() | |
| #メイン関数 | |
| if __name__ == "__main__": | |
| loop = asyncio.get_event_loop() | |
| tasks = [listenToBloadcastMessages()] | |
| try: | |
| loop.run_until_complete(asyncio.gather(*tasks)) | |
| finally: | |
| loop.close() |
このファイルは解錠システムのF_Host_Unlock.pyを基にして作成しているため詳細の解説は省く。randomPassCodeとchangePassCodeの二つの関数が実行されないため削除している。
| G_Lock | |
| import pigpio | |
| import time | |
| from supabase import create_client | |
| from functools import partial | |
| import A_Setting | |
| #supabaseにアクセスできるようにする | |
| PROJECT_URL = A_Setting.PROJECT_URL | |
| API_KEY = A_Setting.API_KEY | |
| supabase = create_client(PROJECT_URL, API_KEY) | |
| #サーボモーターの設定 | |
| SERVO_PIN = 18 | |
| pi = pigpio.pi() | |
| #サーボモーターの準備 | |
| def set_angle(angle): | |
| assert 0 <= angle <= 180. | |
| pulse_width = (angle / 180) * (2500 - 500) + 500 | |
| pi.set_servo_pulsewidth(SERVO_PIN, pulse_width) | |
| def afrer(n, started): | |
| return time.time() - started > n | |
| def lock(user): | |
| #sys.stdout = open("_Application_Lock.log", "a") | |
| #データベースのテーブル名を取得 | |
| listname1 = A_Setting.listname1 | |
| listname2 = A_Setting.listname2 | |
| #ドアの状態を取得 | |
| Door_Log_datalist = supabase.table(listname1).select("*").execute() | |
| Door_list=[f"{user['door']}" for user in Door_Log_datalist.data] | |
| Door_list= [s for s in Door_list if s != '操作なし'] | |
| Door_condition = (Door_list[0]) | |
| #ドアの状態によって処理を変更 | |
| if Door_condition == "解錠": | |
| set_angle (95) | |
| message_door="施錠" | |
| print ("認証されたユーザーです。ドアを施錠します。\n") | |
| else: | |
| print ("認証されたユーザーです。ドアは施錠されているため追加の操作を行いません。\n") | |
| message_door="操作なし" | |
| #ログを記録 | |
| message_log = "認証済" | |
| UserName = user + "_Application" | |
| logdata = {"username": UserName,"certification": message_log, "door":message_door } | |
| supabase.table(listname2).insert(logdata).execute() | |
| return True | |
| #sys.stdout = sys.__stdout__ |
H_Unlock.pyを基に作成しておりサーボモータを回転させる角度、操作ログの一部以外は共通のコードとなっている。
10-6.検索システムについて
| // GETリクエストを送信する | |
| function getSupabaseData(tableName) { | |
| const supabaseUrl = "URL"; | |
| const supabaseApiKey = "Apikey"; | |
| const serviceRKey = "serviceKey "; | |
| // Set up options for the API request | |
| const options = { | |
| method: 'GET', | |
| headers: { | |
| 'apikey': supabaseApiKey, | |
| 'Content-Type': 'application/json', | |
| 'Authorization': 'Bearer ' + serviceRKey, | |
| }, | |
| }; | |
| const response = UrlFetchApp.fetch(supabaseUrl, options); | |
| const data = JSON.parse(response).reverse(); | |
| const spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); | |
| let number = 1; | |
| let name_of_sheet = 0; | |
| let check = 000000 | |
| for(let row of data){ | |
| let Sheet_name = row["created_at"].slice(0,4) + row["created_at"].slice(5,7); | |
| let new_sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(Sheet_name); | |
| if (name_of_sheet == Sheet_name){ | |
| }else{ | |
| } | |
| //読み込んだシート名が存在するかif文で確認 | |
| if(!new_sheet){ | |
| console.log("create sheet") | |
| // シート名指定でシートを取得 | |
| let sheet1 = spreadsheet.getSheetByName('000000'); | |
| // 『元データ』シートの内容をコピー | |
| new_sheet= sheet1.copyTo(spreadsheet).activate(); | |
| // シートの名前を、取得した名前に変更 | |
| new_sheet.setName(Sheet_name); | |
| let cell = new_sheet.getRange("A2:F"); | |
| cell.clearContent(); | |
| //新しく追加したシートを先頭に移動 | |
| spreadsheet.moveActiveSheet(2); | |
| SpreadsheetApp.getActiveSpreadsheet().getSheetByName(Sheet_name).hideSheet(); | |
| number = 0 | |
| }else{ | |
| } | |
| let ss = SpreadsheetApp.getActiveSpreadsheet(); | |
| ss = ss.getSheetByName(Sheet_name); | |
| if (number === 1){ | |
| let lastRow = ss.getRange(1, 2).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow(); | |
| number = lastRow | |
| }else if(number === 0){ | |
| number = 1 | |
| } | |
| if(Sheet_name !== check){ | |
| let lastRow = ss.getRange(1, 2).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow(); | |
| number = lastRow - 1 | |
| } | |
| //A列のみ最終行まで取得 | |
| let a = ss.getRange("A"+number).getValue(); | |
| log_id = row["log_id"] | |
| number = number + 1 | |
| if(log_id>a){ | |
| new_sheet.getRange("A"+ number).setValue(row["log_id"]); | |
| new_sheet.getRange("B"+ number).setValue(row["created_at"].slice(0,10)); | |
| new_sheet.getRange("C"+ number).setValue(row["created_at"].slice(11,18)); | |
| new_sheet.getRange("D"+ number).setValue(row["username"]); | |
| new_sheet.getRange("E"+ number).setValue(row["certification"]); | |
| new_sheet.getRange("F"+ number).setValue(row["idm"]); | |
| new_sheet.getRange("G"+ number).setValue(row["door"]); | |
| console.log(log_id,Sheet_name,number) | |
| }else{ | |
| number = number - 1 | |
| } | |
| name_of_sheet = row["created_at"].slice(0,4) + row["created_at"].slice(5,7); | |
| check = Sheet_name | |
| } | |
| } | |
3~5行目にてSupabaseのAPIKeyなどを設定している。8行目以降のconst optionにてSupabaseから操作ログ取得の操作を行っている。24行目以降のfor文にてSpreadSheetへのデータの書き込みを行っている。31行目にて書き込みを行うシート名を決定している。66行目以降のif文で操作ログがSpreadSheetに記録されていない場合に記録するための操作を定義している。
10-7.データベースについて
今回の開発ではアプリ管理⽤のデータベースとドアシステム管理⽤のデータベースでプロジェクトを分割しているが⼀緒にしていても問題はない。
まずはWebアプリ管理⽤のデータベースについて解説する。Webアプリのアカウントの作成はAuthentionのUsersで⾏うことができる。右上にあるAdd userをクリックしてユーザーの作成を実行する。作成画面が出てくるのでIDとパスワードを⼊⼒してアカウントを作成する。
次はドアシステムの管理用のデータベースについてである。ここには「Door_log」「setting」の2種類のデータテーブル、「viewforgas」のviewがある。viewを作成するSQLは下のとおりである。
create view viewforgas as
select *
from “Door_log”
order by created_at desc
limit 500 SQLの編集はメニューバーのTableの下にあるSQL Editorにて編集・実行を行なっている。1行目のviewforgasは作成するviewのテーブル名である。3行目のテーブル名は参照するデータの入ったテーブルである。6行目はviewに表示する履歴の数である。
「Door_log」にはドアの操作履歴を格納する。「log_id」「created_at」「certification」「idm」「username」「mamo」「door」「login_num」のデータを格納している。これらのうち、「idm」「memo」「login_num」は開発段階でのテストに使用していたデータであったため、今後削除予定である。そのためGASの仕様変更が必要となる。
11.セキュリティ性の担保について
今回開発したシステムは最終的にオープンソース化することを目指している。これによってセキュリティ性が低下するのではないかと考える人も多いのではないだろうか。しかし、その心配は無用である。今回のシステムで外部から不正な操作をするために必要なのはSupabaseへのアクセス権限である。そのためにはSupabaseのURLとAPIKeyが必要である。しかし、それらに関しては利用者が外部に漏洩しない限りは流出しないようになっている。そのため、API KeyとURLを伏せたソースコードに関して言えばソースコードの公開によるセキュリティ性の低下はほとんどないと断言できる。しかし、URlとAPIKeyが流出してしまった場合のセキュリティ性は著しく低下する可能性は完全に否定することはできない。そのため、URLとAPI Keyの取り扱いには慎重に行うべきである。また、各テーブルへのアクセスなどをPolicyにて細かく設定を行うことが重要だと考える。
12.設置方法について
設置は現在⾏っている。RaspberryPiへの電源供給は瞬間的な停電などに備えるために無停電電源装置(UPS)を経由して⾏っている。WebカメラはUSBケーブルで接続し、ドアの上部に写真15のように設置している。RaspberryPiとサーボモータ、LCDモジュールとの接続はLANケーブルを⽤いて接続する予定である。最終的には写真16にある LCDモジュールとサーボモータを、ドアノブに3Dプリンターにてプリントした機構によって、LCDモジュールをドアノブの窓に内側から設置する予定である。
13.開発のまとめ
今回のシステムは、これまでに販売・配布されたどのスマートドアシステムよりも拡張性の高いものを開発できたと考える。また、少人数で運用する際も、大人数で運用する際でも管理コストを低く抑えるという当初目標を実現することができた。他の目標についても満たすことができており、十分なセキュリティ性を備える安価なスマートドアシステムの実現は達成されたと考える。
14.今後の展望・開発したシステムの将来性
今後は今回開発したシステムの導入マニュアル・管理マニュアルをNoteで作成し公開していこうと考えている。また、ソースコードについてもGitHubにて公開し誰もが、簡単に自宅のシリンダー錠をスマートドア化出来るようにしていきたい。
また、このシステムを拡張させていき、Webアプリを開かずとも鍵の操作ができるようなアップデートや、検索システムをSpreadSheetではなくWebアプリとして利用できるようにするなどの改良を施していきたい。
15. 参考文献・リンク
[1] 長野県警察「統計資料/長野県警察」
https://www.pref.nagano.lg.jp/police/toukei/index.html
2024年12月13日13:30:20閲覧
[2] 株式会社Qrio「Qrio株式会社の詳細情報」
https://www.wantedly.com/companies/qrioinc/about
2024年12月13日 14:04:00閲覧
[3] ミツモアMedia「シリンダー錠とは?種類や防犯性、トラブル時の処置方法を紹介」
https://meetsmore.com/services/lock-exchange/media/84486
2024年12月15日 6:37:39閲覧
[4] レスキューラボ「玄関ドアの電子錠のデメリット総まとめ~現場で良く聞く不満とは~」
https://sq.jbr.co.jp/library/1166 2024年12月15日 6:42:55閲覧
[5] akerun「電気錠とは?メリットやデメリット!電気錠を導入する際におすすめの製品も紹介!」
https://akerun.com/knowledge/20231020/ 2024年12月15日 6:43:59閲覧
[6] Linough「スマートロックは普及する? スマートロックのメリットと普及の可能性を解説」
https://linough.com/blog20240422/ 2024年12月15日 7:00:25閲覧
[7] スキルアップAI Journal「【入門】Pythonとは|活用事例やメリット、できること、学習方法を解説」
https://www.skillupai.com/blog/tech/about-python/
2024年12月16日 10:22:04閲覧
[8] Qiira「Google Apps Scriptの概要・特徴」
https://qiita.com/tags/googleappsscript 2024年12月16日 10:23:14閲覧
[9] Wikipedia「Dart」 https://ja.wikipedia.org/wiki/Dart
2024年12月16日 10:24:23閲覧
[10] Qiita「Flutterってどうなん?実際に商用アプリ開発で使ってみた感想」
https://qiita.com/allJokin/items/35a486c82815e0070f34
2024年12月16日 10:25:10閲覧
[11] Qiita「Vercelをゼロから迅速に使い始める方法、個人プロジェクトのデプロイを手取り足取り教えます」
https://qiita.com/Notta_Engineering/items/1db3a14be8caa9a63ab2
2024年12月16日 14:51:59閲覧
[12] Zenn「Supabaseを布教したい」
https://zenn.dev/kibe/articles/7a1dfc9bbd681c
2024年12月16日 14:56:18閲覧
[13] Wikipedia「Discord」 https://ja.wikipedia.org/wiki/Discord
2024年12月16日 14:00:17閲覧
[14] AKKODiS「GitHubとは?使い方や知っておきたい知識を解説!」
https://www.akkodis.co.jp/candidate/insight/column_30
2024年12月16日 15:05:24閲覧
下記に示す書籍は全て開発段階にて参考にした書籍である。個別のコードに対しどの書籍を引用したなどがないため参考文献とは別に記す。
[16] ⼭⽥祥寛「独習Python」株式会社翔泳社(2024年4⽉20⽇ 第8刷)
[17] クジラ紙⾶⾏機「実践⼒をアップするPythonによるアルゴリズムの教科書」
株式会社マイナビ出版(2023年6⽉27⽇ 初版第1刷)
[18] AI Sweigart「きれいなPythonプログラミング」
株式会社マイナビ出版(2022年2⽉2⽇ 初版第1刷)
[19] CodeMafia 外村将⼤「独習JavaScript 新版」
株式会社翔泳社(2023年12⽉10⽇ 第4刷)
[20] 狩野佑東「これからのJavaScriptの教科書」
SBクリエイティブ株式会社(2024年6⽉10⽇ 初版第1刷)
[21] Mana「1冊ですべて⾝につくJavaScript⼊⾨講座」
SBクリエイティブ株式会社(2023年4⽉1⽇ 電⼦版第1版)
[22] ⾼橋宣成「詳解! GoogleAppsScript完全⼊⾨[第3版]」
株式会社秀和システム(2021年7⽉10⽇ 第1版第1刷)
[23] Rick Umali「独習Git」株式会社翔泳社(2023年9⽉5⽇ 第7刷)
[24] 冨永和⼈「動かして学ぶ! Git⼊⾨」
株式会社翔泳社(2021年11⽉4⽇ 第1刷)
[25] 渡部陽太「Flutter実践開発 ― iPhone/Android両対応アプリ開発のテクニック」
株式会社技術評論社(2024年3⽉7⽇ 初版第1刷)
[26] 藤川慶「ゼロから学ぶFlutterアプリ開発」
株式会社グリープ(2023年12⽉23⽇ 初版第1刷)
[27] 掛内⼀章「動かして学ぶ! Flutter開発⼊⾨」
株式会社翔泳社(2023年5⽉17⽇ 初版第1刷)
[28] 藤治仁・⼩林加奈⼦・⼩林由憲「SwiftUI対応 たった2⽇でマスターできるiPhoneアプリ開発集中講座」(2023年11⽉15⽇ 初版第1刷)
[29] ミック「SQL 第2版 ゼロから始めるデータベース操作」
株式会社翔泳社(2023年12⽉5⽇ 第11刷)
[30] ミック「達⼈に学ぶSQL徹底指南書 第2版 初級者で終わりたくないあなたへ」
株式会社翔泳社(2023年4⽉20⽇ 初版第7刷)
[31] 森下篤「改訂新版 VisualStudioCode実践ガイド」
株式会社技術評論社(2024年1⽉25⽇ 初版第1刷)
[32] ⾦城俊哉「VisualStudioCodeパーフェクトマスター」
株式会社秀和システム(2023年2⽉10⽇ 第1版第1刷)
[33] ⽶⽥聡「これ1冊でできる! ラズベリー・パイPicoではじめる電⼦⼯作超⼊⾨」
株式会社ソーテック社(2023年12⽉ 配信)
[34] 福⽥和宏「電⼦⼯作&サーバー構築徹底解説! ラズパイ5完全ガイド」
JMCインターナショナル(2024年5⽉21⽇ 第1版)
[35] 福⽥和宏「電⼦部品ごとの制御を学べる! RaspberryPi電⼦⼯作 実践講座 改訂第2版」
株式会社ソーテック社(2019年6⽉ 配信)
[36] 松岡貫志「ラズパイ⾃由⾃在 電⼦⼯作パーツ制御完全攻略」
JMCインターナショナル(2022年3⽉8⽇ 第1版)
[37] 仙場⼤也「良いコード/悪いコードで学ぶ設計⼊⾨」
株式会社技術評論社(2022年6⽉17⽇ 第2刷)
[38] 坂上幸⼤「図解 まるわかり データベースのしくみ」
株式会社翔泳社(2023年4⽉5⽇ 初版第3刷)
下記に示すのは設置方法や管理マニュアルを掲載しているサイトである。
(随時更新予定)
[39] Yuki(Note) https://note.com/haruka4201
[40] Yuki「RaspberryPiを用いたドアシステムの導入マニュアル」https://note.com/haruka4201/n/n86a8a6b5e284
[41] Yuki「RaspberryPiを用いたドアシステムの管理マニュアル」https://note.com/haruka4201/n/n46d4bf47efe6
下記に示すのはソースコードを公開したGitHubである。
(随時アップデート予定)
[42] Yuki(GitHub) https://github.com/yuki-4201
[43] Yuki「Door_System_Public」
https://github.com/yuki-4201/Door_System_Public
[44] Yuki「Door_App_Public」
https://github.com/yuki-4201/Door_App_Public



Discussion
No comments yet