前期准备
前情提要
由于在学校宿舍有四个人,当遇到有同学不带钥匙出门/隔壁宿舍来串门/外卖到了等情况需要我停下音乐,放下耳机,挣扎着从座位/床上爬出来然后开门真的是很难受,有时候一晚会重复上述情况数次,非常影响工作(游戏)效率,正巧前几个月买了个树莓派来玩看到各路大神也有做类似的东西于是便有了自己做一个远程开锁装置的想法.
基本思路
当时想的就是至少能满足2种场景
1.我坐在电脑前有人开门我在电脑上随便按点什么能打开宿舍的门
2.我回到宿舍门前可以用手机APP开门
于是想到用Web网页的接口进行控制,刚好我有个Web服务器,这样甚至能实现在任何地方只要有网络就能进行控制.
然后树莓派通过校园网wifi连接服务器,接收服务器信息,当接收到开锁的信息后通过GPIO控制电机进行开锁,具体怎么样实现到时候再说,先把材料准备好.
材料清单
树莓派3B*1
SG90陀机(升级版)*1
排插*1
基本工具箱(螺丝刀,剪线钳,双面胶,透明胶等)
杜邦线若干
一米导线*3
Web服务器*1
校园网wifi*1
技术清单
Web服务器搭建
PHP
Python
树莓派GPIO开发
基于Java的安卓开发
开始工作
鉴于网购的东西还没到,先把服务器和客户端写了.
按道理来说我的树莓派应该作为服务器,开个Web服务,然后通过网页访问树莓派执行操作.但是我实在没办法从外网连接到处于校园网的树莓派,只能选择服务器搭载云端,然后树莓派作为客户端访问服务器建立连接后监听服务器发送的数据,一旦服务器发送解锁指令,就调用GPIO控制电机解锁宿舍门.
不知道为什么觉得树莓派上用Python应该会好点,可能是因为我不想写C,于是只学过python基本语法的我搜集了一堆资料后拼凑出了这样的代码:
服务器端
#!/usr/bin/python3
# coding=utf-8
# filename:server.py
import select
import socket
# import queue
import sys
import os
import os.path
import time
import logging
# 配置日志信息
LOG_FORMAT = "[%(asctime)s]%(levelname)s:%(message)s"
DATE_FORMAT = "%m-%d %H:%M:%S"
logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT, datefmt=DATE_FORMAT)
# 监视文件路径
fdir= '/www/wwwroot/file.txt'
# 创建 socket 对象
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 获取本地主机名和端口号
host = socket.gethostname()
port = 9989
# 设置为非阻塞
server.setblocking(False)
# 绑定端口号
while True:
try:
server.bind((host, port))
except Exception as e:
logging.warning('server boot error,retry in 3s:{}'.format(e))# 可能会遇到端口占用错误
time.sleep(3)
else:
break
# 所有连接进来的对象都放在inputs
inputs=[server,]# 自己也要监控,因为server本身也是个对象
# 需要发送数据的对象
outputs=[]
# 对外发送数据的队列,记录到字典中
message_queues = {}
f_text = {}
def start():
# 设置并启动TCP监听器 设置最大连接数,超过后排队
server.listen(1)
logging.info("Server Ready.")
check()
# clear()仅仅是清除缓存和刷新当前连接,并非停止连接,需要开始输入start(),需要停止连接请再输入stop()
def clear():
# 2018年11月14日 20:32:42测试通过,没事别乱改
for s in outputs:
inputs.remove(s)
outputs.remove(s)
s.close()
logging.info('All connection refreshed! Use start() or stop() to continue.')
def check():
# 主循环采用select进行阻塞,内容主要参考https://www.cnblogs.com/bigberg/p/8044581.html
while True:
time.sleep(0.1)
readable, writable, exceptional = select.select(inputs, outputs, inputs, 1)# ///???
# 如果没有任何fd就绪,那程序就会一直阻塞在这里
for s in readable: # 每一个s就是有个socket
# Readable list 中的socket 可以有3种可能状态,第一种是如果这个socket是main "server" socket,它负责监听客户端的连接,如果这个main server socket出现在readable里,那代表这是server端已经ready来接收一个新的连接进来了,为了让这个main server能同时处理多个连接,在下面的代码里,我们把这个main server的socket设置为非阻塞模式。
if s is server:
# 别忘记,上面我们server自己也当做一个fd放在了inputs列表里,传给了select,如果这个s是server,代表server这个fd就绪了,
# 就是有活动了, 什么情况下它才有活动? 当然 是有新连接进来的时候
# 新连接进来了,接受这个连接(没有新连接根本不会select进来)
conn, client_addr = s.accept()
logging.info("new connection from:{}".format(client_addr))
conn.setblocking(0)
inputs.append(conn)
outputs.append(conn)
logging.info("Start checking...")
f_text[conn] = open(fdir, mode='r').read()# 读取初始值
# 为了不阻塞整个程序,我们不会立刻在这里开始接收客户端发来的数据, 把它放到inputs里, 下一次loop时,这个新连接
# 就会被交给select去监听,如果这个连接的客户端发来了数据 ,那这个连接的fd在server端就会变成就续的,select就会把这个连接返回,
# 返回到readable 列表里,然后你就可以loop readable列表,取出这个连接,开始接收数据了, 下面就是这么干的
# message_queues[conn] = queue.Queue()
# 接收到客户端的数据后,不立刻返回 ,暂存在队列里,以后发送
else: # s不是server的话,那就只能是一个与客户端建立的连接的fd了
# 客户端的数据过来了,在这接收
logging.info('recving')
# 在客户端未接受服务器数据时断开会报错,故try
try:
data = s.recv(1024)
except Exception as e:
logging.warning('recv error:{}'.format(e))
data = None
if data:
logging.info('received [{}] from {}'.format(data,s.getpeername()[0]))
# message_queues[s].put(data) # 收到的数据先放到queue里,一会返回给客户端
# if s not in outputs:
# outputs.append(s) # 为了不影响处理与其它客户端的连接 , 这里不立刻返回数据给客户端
else: # 如果收不到data代表客户端断开了
logging.info("client [{}] closed".format(s))
if s in outputs:
# 既然客户端都断开了,我就不用再给它返回数据了,
# 所以这时候如果这个客户端的连接对象还在outputs列表中,就把它删掉
outputs.remove(s)
inputs.remove(s) # 这个连接必然在inputs中,也删掉
s.close()
# 关闭的连接在队列中也删除
# del message_queues[s]
for s in writable:
# 对于writable list中的socket,也有几种状态,如果这个客户端连接在跟它对应的queue里有数据,就把这个数据取出来再发回给这个客户端,否则就把这个连接从output list中移除,这样下一次循环select()调用时检测到outputs list中没有这个连接,那就会认为这个连接还处于非活动状态
# 判断文件是否被更改
if(os.path.exists(fdir)):
ntext = open(fdir, mode='r').read()
if(f_text[s] != ntext):
f_text[s] = ntext
changed = True
else:
changed = False
#发送信息
if(changed):
try:
s.send('asdas'.encode('utf-8'))
except Exception as e:
logging.warning('message send error:{}'.format(e))
inputs.remove(s)
outputs.remove(s)
s.close()
break
else:
logging.info("message send!")
for s in exceptional:
# 最后,如果在跟某个socket连接通信过程中出了错误,就把这个连接对象在inputs\outputs\message_queue中都删除,再把连接关闭掉
logging.info('handling exceptional condition for:{}'.format(s.getpeername()[0]))
# 从inputs中删除
if s in inputs:
inputs.remove(s)
if s in outputs:
outputs.remove(s)
s.close()
# 删除队列
del message_queues[s]
def stop():
clear()
server.close()
logging.info('Server stoped please close the program!')
# Check and send
def cas(clientsocket,msg):
logging.info("Start checking...")
ftext = open(fdir, mode='r').read()# 读取初始值
while True:
time.sleep(0.1)
# 判断文件是否被更改
if(os.path.exists(fdir)):
ntext = open(fdir, mode='r').read()
if(ftext != ntext):
ftext = ntext
changed = True
else:
changed = False
#发送信息
if(changed):
try:
clientsocket.send(msg.encode('utf-8'))
except Exception as e:
logging.warning('message send error:{}'.format(e))
break
else:
logging.info("message send!")
服务器端监视’/www/wwwroot/file.txt’文件,若改文件改变,则发送信息给客户端.
运行该服务:
客户端:
#!/usr/bin/python3
# coding=utf-8
# filename:client.py
import socket
import select
import sys
import time
import logging
# 配置日志信息
LOG_FORMAT = "[%(asctime)s]%(levelname)s:%(message)s"
DATE_FORMAT = "%m-%d %H:%M:%S"
logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT, datefmt=DATE_FORMAT)
# 设置主机名
host = '192.168.1.101'
# 设置端口号
port = 9989
def link(funRun=None,funClose=None):
while True:
# 创建 socket 对象
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) #在客户端开启心跳维护
client.setsockopt(socket.SOL_TCP, socket.TCP_KEEPIDLE, 20) #空闲时间
client.setsockopt(socket.SOL_TCP, socket.TCP_KEEPINTVL, 1) #发送间隔
client.setsockopt(socket.SOL_TCP, socket.TCP_KEEPCNT, 3) #允许失败
# 所有连接进来的对象都放在inputs
inputs=[client,]# 自己也要监控,因为server本身也是个对象
# 需要发送数据的对象
outputs=[]
# 对外发送数据的队列,记录到字典中
message_queues = {}
# 连接循环
while True:
try:
logging.info('connecting...')
# 连接服务,指定主机和端口
client.connect((host, port))
except socket.error as e:
logging.warning("link error retry 3s later:{}".format(e))
time.sleep(3)
else:
break
logging.info('server connected!')
client.setblocking(0)
# 监听循环
while True:
try:
time.sleep(0.1)
logging.debug('slecting...')
readable, writable, exceptional = select.select(inputs, outputs, inputs, 1)# 设置超时1以接受CTRL+C
except Exception as e:
logging.warning("Exception:{}".format(e))# 防止未结束连接导致服务器端口占用
client.shutdown(2)
client.close()
break
except:
logging.warning("KeyboardInterrupt")# 防止未结束连接导致服务器端口占用
client.shutdown(2)
client.close()
if(funClose!=None):
funClose()
return
if(readable):
logging.debug('ready to recv')
try:
msg = client.recv(1024).decode('utf-8')
except (socket.error) as e:
logging.warning("BlockingIOError:{}".format(e))
continue
except:
logging.warning("Unexpected error:{}".format(sys.exc_info()[0]))
break
if(msg == "asdas"):
if(funRun!=None):
funRun()
logging.info("run...")
else:
logging.warning("unexapable message,connecting close,start retrying")
break
if(exceptional):
logging.warning('handling exceptional condition for{}'.format(client.getpeername()[0]))
break
client.close()
客户端中的link方法用于连接服务器,那两个参数分别是接收到服务器指令后需要执行的函数和客户端结束时执行的清理函数.
用客户端连接服务器:
尝试更改服务器监视的文件,服务器发送信息,客户端接受信息然后print了run…测试完成.
服务器Web接口
服务器Web接口就用PHP写吧,主要功能就是访问这个接口后更改同一目录下的file.txt文件.
<?php
if(array_key_exists('key',$_REQUEST)){
$key = $_REQUEST['key'];
if($key=='key'){
file_put_contents("file.txt", date("ymdhis"));
echo 'file changed!';
}
}
?>
丢到服务器上后通过浏览器访问该接口"192.168.1.101/test.php?key=key"发现服务器和客户端执行了相应行为,测试完成.
GPIO控制
陀机到了,SG90陀机用三根线控制,红色接GPIO的4接口,灰色接6,橙色接12
控制代码:
#!/usr/bin/python
# filename:base.py
import RPi.GPIO as GPIO
import time
import signal
import atexit
GPIO.setwarnings(False)
GPIO.cleanup()
servopin = 12
GPIO.setmode(GPIO.BOARD)
GPIO.setup(servopin, GPIO.OUT, initial=False)
p = GPIO.PWM(servopin,50) #50HZ
def run(t=1,s=2,a1=4,a2=10):
p.start(0)
for i in range(0,t):
p.ChangeDutyCycle(a1)
time.sleep(.5)
p.ChangeDutyCycle(0)
time.sleep(s)
p.ChangeDutyCycle(a2)
time.sleep(.5)
p.ChangeDutyCycle(0)
time.sleep(s)
p.ChangeDutyCycle(0)
def exit():
GPIO.cleanup()
在python中运行run方法,正常情况陀机转动大约60度然后过2秒又转回来.
在测试时发现一个问题,用Python2运行良好而Python3会出现奇怪的错误,于是将就着用Python2来跑了.
将控制程序和客户端结合起来使用只需要启动python然后
import client as c
import base as b
c.link(b.run(),b.exit())
就可以打开网页测试了.
设备安置
测试成功后将陀机和门锁中某个结构用绳子绑起来,当陀机转动时会带动门锁开门(期间由于各种原因弄坏了2个SG90陀机,于是换了个SG90升级版(金属齿轮)希望不要再坏了).
用双面胶将陀机粘在门锁旁边,完成后如下图
测试完毕后就开始布线,将排插用双面胶黏在门旁边的墙壁上,排插上再粘一个盒子用来装树莓派,完成后如图:
整体布局如图:
效果
测试
主要使用方法就是访问服务器上的某网页,然后门就开了.
经过几星期的实际使用,发现了不少BUG并且成功修复了.之后试过连续使用一个星期不出故障,应该是没问题了.
其中一个比较严重的的问题是:
后来买了钉子把排插钉墙上了.
APP控制
由于舍友反馈说这玩意儿每次开门都要打开网页输入网址很麻烦,于是便想办法弄一个手机APP进行控制.
打开AndroidStudio建立工程
设计布局页面Activity_main.xml的代码:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.example.table.MainActivity">
<ImageView
android:id="@+id/Table"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/UnlockButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/table" />
<ImageButton
android:id="@+id/UnlockButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:srcCompat="@mipmap/ic_launcher_round" />
</android.support.constraint.ConstraintLayout>
其中res/drawable/table是一张课程表的图片,让APP不会过于单调,UnlockButton的图片随便用了一张系统自带的.
然后以能用就行的原则写了以下的代码:
MainActivity.java的代码
package com.example.table;
import android.os.StrictMode;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.ImageButton;
import java.net.HttpURLConnection;
import java.net.URL;
public class MainActivity extends AppCompatActivity {
private ImageButton imageButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//配置多线程
StrictMode.setThreadPolicy(
new StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().detectNetwork().penaltyLog().build()
);
StrictMode.setVmPolicy(
new StrictMode.VmPolicy.Builder().detectLeakedSqlLiteObjects().penaltyLog().penaltyDeath().build()
);
setContentView(R.layout.activity_main);
imageButton = (ImageButton)findViewById(R.id.UnlockButton);
imageButton.setOnClickListener(new mClick());
}
class mClick implements OnClickListener {
public void onClick(View v){
imageButton.setEnabled(false);
Connect();
}
private void Connect(){
try {
URL url = new URL("http://192.168.1.101/test.php?key=key");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000);
conn.setRequestMethod("GET");
conn.setInstanceFollowRedirects(true);
if (conn.getResponseCode() == 200){
MainActivity.this.setTitle("发送成功");
}
} catch (Exception e) {
MainActivity.this.setTitle("网络连接错误");
}
imageButton.setEnabled(true);
}
}
}
在res/AndroidManifest.xml文件的<manifest标签下添加以下语句以获取网络权限:
<uses-permission android:name="android.permission.INTERNET" />
测试的时候由于我用一台用了挺久的红米3手机进行测试,这个手机经常会出现某一个APP无法联网的情况.因为这个问题我用了一个下午来怀疑人生,直到手机没电重启后那个现象恢复了我才意识到这个问题.
最后实现了用手机打开APP点击一个按钮后就能开门了.