利用树莓派Zero远程可视化喂鱼 的教程


眼看要过年了,回老家之后,养的小鱼用不了几天就要见马克思,想着用朋友送的zero来做一个远程喂鱼的小东西,应该不难。

思路:利用双路继电器分别控制灯和水泵,使用mjpg-streamer来获取摄像头的视频流,并在特定的时刻自动开闭继电器。

网络环境:有公网IP的家庭网络,利用路由器的ddns或者花生壳,树莓派作为tcpserver对外提供访问。但这个条件,目前已经很难满足了,一般网络都是大内网,这种情况可以让树莓派作为tcpclient主动请求服务器获取指令,本文介绍的是第一种情况。

鱼食槽暂时未完成,准备搞两个大一点的瓶盖,合起来热熔胶伺候,中间放鱼食,边缘开两个孔,最终固定到步进电机上,转一圈就能完成喂鱼动作。

树莓派的安装和配置,本文不再赘述,本文分“硬件部分”、“软件部分”、“自启动配置”来说明整个项目。

硬件部分
本项目中使用的硬件:
必不可少的大脑:
raspiberry zero
1. 双路继电器

使用 gpio readall 指令来获取树莓派上的所有接口信息。
这里使用BCM方式来控制GPIO接口,选择BCM编号为18和27的插针,也就是GPIO1和GPIO2,作为两路继电器的信号控制,继电器的vcc和gnd,分别接到树莓派的5V和0V接口,先借个图,看起来清晰一点。

接口信息
2路继电器

2. 步进电机及ULN2003控制模块

   步进电机利用4步或8步脉冲信号来驱动电机转动,这里用双4步(ab bc cd da)来控制电机,可以获得比较强的扭矩,同时精度也比单4步要好,这个ULN2003控制模块有个缺点,就是控制间隔不能小于3ms,否则电机只震动,不转动。

步进电机

   连接也很简单,正负极接到zero上,控制脚使用BCM编号为23 24 25 12的针脚,BCM编号见第一张图。

步进电机驱动模块

3. 兼容的USB摄像头

   直接扔到usb集线器上就完事了,树莓派上使用lsusb查看,如果没有,基本是不兼容导致的。

usb摄像头
lsusb

4. 兼容树莓派的USB无线网卡
兼容树莓派的USB无线网卡

5. USB集线器
USB集线器


软件部分

软件也是主要三大块:
1. 继电器控制、定时控制、步进电机控制 (代码文件保存到/home/pi/scripts/MyTcpControl.py)
2. 摄像头实时视频流部署 (启动视频流服务的脚本保存到/home/pi/scripts/startCamera.sh)
3. 安卓远程控制APP

1. 双路继电器控制、自动定时控制、步进电机控制
本模块使用Python语言编写。

  1. 建立TCP服务器,通信端口为7654

  2. 高低电平控制
    由于使用的继电器写低为接通电路,所以代码中,使用GPIO.LOW来接通继电器电路,GPIO.HIGH来关闭继电器电路。

  3. 电机步进序列控制。
    步进电机使用双4步来控制GPIO的电平信号,具体为:

1,1,0,0
0,1,1,0
0,0,1,1
1,0,0,1

MyTcpControl.py完整代码如下

import sys
import os
import _thread
import time
import datetime
from socket import *
import RPi.GPIO as GPIO

host = '0.0.0.0'
port = 7654
buffsize = 4096
ADDR = (host,port)
channel1 = 18
channel2 = 27

IN1 = 23
IN2 = 24
IN3 = 25
IN4 = 12

lightManual = False
pumpManual = False
lightStatus = 0
pumpStatus = 0

def main():
    GPIO.setmode(GPIO.BCM)
    GPIO.setwarnings(False)

    GPIO.setup(channel1,GPIO.OUT,initial=GPIO.HIGH)
    GPIO.setup(channel2,GPIO.OUT,initial=GPIO.HIGH)
    
    GPIO.setup(IN1,GPIO.OUT)
    GPIO.setup(IN2,GPIO.OUT)
    GPIO.setup(IN3,GPIO.OUT)
    GPIO.setup(IN4,GPIO.OUT)
    
    _thread.start_new_thread(autoControlLight, ("light",1))
    _thread.start_new_thread(autoControlPump, ("pump",1))

    server = socket(AF_INET,SOCK_STREAM)
    server.bind(ADDR)
    server.listen(10)
    print("MyControl TcpServer is started")
    while True:
        try:
            client,addr = server.accept()
            _thread.start_new_thread(onAccept, (client,addr))
        except:
            print('Server is interrupted')
    #server.close()
    #server.shutdown()

def autoControlLight(tName,para):
    global lightManual
    global lightStatus
    while True:
        timeNow1 = datetime.datetime.now()
        h = timeNow1.hour
        m = timeNow1.minute
        if h==0 and m==0:
            lightManual = False
        if h==8 and m==0 and lightManual==False:
            GPIO.output(channel1,GPIO.LOW)
            lightStatus = 1
        if h==17 and m==0:
            GPIO.output(channel1,GPIO.HIGH)
            lightStatus = 0
         
        time.sleep(60)
        
def autoControlPump(tName,para):
    global pumpManual
    global pumpStatus
    while True:
        timeNow2 = datetime.datetime.now()
        h = timeNow2.hour
        m = timeNow2.minute
        if h==0 and m==0:
            pumpManual = False
        if h==8 and m==0 and pumpManual==False:
            GPIO.output(channel2,GPIO.LOW)
            pumpStatus = 1
        if h==17 and m==0:
            GPIO.output(channel2,GPIO.HIGH)
            pumpStatus = 0
         
        time.sleep(30)
        
def opDrive():
    forwardDrive(0.008,512)
    stopDrive()

def onAccept(sock, addr):
    recvData = sock.recv(buffsize).decode('gbk')
    print('recvData:'+recvData) #print data
    retInfo=""
    global lightManual
    global lightStatus
    global pumpManual
    global pumpStatus
    try:
        if recvData=="open_close":
            retInfo = "opDrive success"
            sock.send(retInfo.encode('gbk'))
            sock.close()
            opDrive()
        else:
            if recvData=="open1":
                GPIO.output(channel1,GPIO.LOW)
                lightManual = True
                lightStatus = 1
                retInfo = "light 1"
            elif recvData=="close1":
                GPIO.output(channel1,GPIO.HIGH)
                lightManual = True
                lightStatus = 0
                retInfo = "light 0"
            elif recvData=="open2":
                GPIO.output(channel2,GPIO.LOW)
                pumpManual = True
                pumpStatus = 1
                retInfo = "pump 1"
            elif recvData=="close2":
                GPIO.output(channel2,GPIO.HIGH)
                pumpManual = True
                pumpStatus = 0
                retInfo = "pump 0"
            elif recvData=="reboot":
                os.system("sudo reboot")
                retInfo = "reboot success"
            elif recvData=="getStatus":
                retInfo=str(lightStatus)+","+str(pumpStatus)
            elif recvData=="test":
                retInfo="test ok"
            
            sock.send(retInfo.encode('gbk'))
            sock.close()
    except Exception as err:
        retInfo = str(err)
        sock.send(retInfo.encode('gbk'))
        sock.close()
    
def setStep(w1,w2,w3,w4):
    GPIO.output(IN1,w1)
    GPIO.output(IN2,w2)
    GPIO.output(IN3,w3)
    GPIO.output(IN4,w4)
    
def stopDrive():
    setStep(0,0,0,0)
        
def forwardDrive(delay,steps):
    for i in range(0,steps):
        setStep(1,1,0,0)
        time.sleep(delay)
        setStep(0,1,1,0)
        time.sleep(delay)
        setStep(0,0,1,1)
        time.sleep(delay)
        setStep(1,0,0,1)
        time.sleep(delay)

if __name__ == '__main__':
    main()

2. 摄像头实时视频流部署
尝试了motion组件,发现巨卡,转而使用mjpg-streamer,很流畅,推荐使用!
(1)安装依赖库

sudo apt-get install libjpeg62-dev
sudo apt-get install libjpeg8-dev

(2)树莓派浏览器访问https://github.com/jacksonliam/mjpg-streamer 下载源码,默认到/home/pi/Downloads目录,完成后解压缩。
由于市面上大部分摄像头是YUYV格式输出,所以要修改mjpg-streamer项目的代码文件,让其默认支持此格式的摄像头。
使用nano指令,或TextEditor打开mjpg-streamer-experimental/plugins/input_uvc/input_uvc.c这个文件,找到input_init函数,修改
“format = V4L2_PIX_FMT_MJPEG” 为
“format = V4L2_PIX_FMT_YUYV”。

(3) 编译、部署mjpg-streamer项目

sudo apt-get install cmake
cd /home/pi/Downloads/mjpg-streamer-master/mjpg-streamer-experimental
sudo make clean all

编译完成后,复制相关文件到指定目录

sudo cp mjpg_streamer /usr/local/bin
sudo cp output_http.so input_uvc.so /usr/local/lib/
sudo cp -R www /usr/local/www

最后,使用指令来启动视频组件

LD_LIBRARY_PATH=/usr/local/lib mjpg_streamer -i "input_uvc.so -r 320x240 -f 12" -o "output_http.so -p 12001 -w /usr/local/www"

在谷歌浏览器中,就可以看到视频了,预览地址为http://树莓派IP:12001/?action=stream
视频预览

3. 安卓远程控制APP
使用Android Studio作为IDE,利用webview控件作为人机交互,简单快速。
(1) fish.html文件,放入assets目录

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="shortcut icon" href="/favicon.ico" />
    <link rel="bookmark" href="/favicon.ico" type="image/x-icon"   />
    <title>远程喂鱼</title>
    <link rel="shortcut icon" href="favicon.ico">
    <link href="css/bootstrap.min.css?v=3.3.6" rel="stylesheet">
    <link href="css/font-awesome.css?v=4.4.0" rel="stylesheet">
    <link href="css/animate.css" rel="stylesheet">
    <link href="css/style.css?v=4.1.0" rel="stylesheet">
</head>

<body class="gray-bg">
<div class="wrapper wrapper-content" style="padding:10px;">

    <div class="row">
        <div class="col-sm-4">
            <div class="ibox float-e-margins" style="margin-bottom:5px;">
                <div class="ibox-content no-padding">
                    <div class="panel-body">
                        8:00自动开灯和水泵,17:00自动关灯和水泵
                    </div>
                </div>
            </div>
        </div>
    </div>

    <div class="row">
        <div class="col-sm-4">
            <div class="ibox float-e-margins" style="margin-bottom:5px;">
                <div class="ibox-title">
                    <h5>实时视频</h5>
                </div>
                <div class="ibox-content no-padding">
                    <div class="panel-body">
                        <img style="width:100%;height:240px;" src="http://树莓派IP:12001/?action=stream" />
                    </div>
                </div>
            </div>
        </div>
    </div>

    <div class="row">
        <div class="col-sm-4">
            <div class="ibox float-e-margins" style="margin-bottom:5px;">
                <div class="ibox-content no-padding">
                    <div class="panel-body" style="text-align:center;">
                        <button id="lightBtn" class="btn btn-w-m btn-success" type="button"></button>  
                        <button id="pumpBtn" class="btn btn-w-m btn-success" type="button"></button>
                        <!--<button class="btn btn-w-m btn-success" type="button" onclick="control('resetvideo')">重启视频</button>  -->
                        <button class="btn btn-w-m btn-success" type="button" onclick="control('reboot')">重启控制器</button>  
                        <button id="fishBtn" class="btn btn-w-m btn-success" type="button" onclick="control('open_close')">喂食</button>
                    </div>
                </div>
            </div>
        </div>
    </div>

</div>
<script src="js/jquery.min.js?v=2.1.4"></script>
<script src="js/bootstrap.min.js?v=3.3.6"></script>
<script>
        function control(op) {
            if (op == "open_close")
                $("#fishBtn").removeClass("btn-success").addClass("btn-default").attr('disabled', 'disabled');

            var ret = "";
            if (op == "resetvideo") {
                if (confirm("确定要重启视频模块吗?")) {
                    ret = window.JSHook.execTcpCmd(op);
                }
            }
            else if (op == "reboot") {
                if (confirm("确定要重启控制器?")) {
                    ret = window.JSHook.execTcpCmd(op);
                }
            }
            else
                window.setTimeout(function () {
                    ret = window.JSHook.execTcpCmd(op);
                    controlCallback(op, ret);
                }, 0);
        }
        function controlCallback(op, ret) {
            if (op == "getStatus") {
                var lightStatus = ret.split(",")[0];
                var pumpStatus = ret.split(",")[1];
                if (lightStatus == "1")
                    $("#lightBtn").removeClass("btn-default").addClass("btn-success").text("关灯").unbind("click").click(function () {
                        control("close1");
                    });
                else
                    $("#lightBtn").removeClass("btn-success").addClass("btn-default").text("开灯").unbind("click").click(function () {
                        control("open1");
                    });
                if (pumpStatus == "1")
                    $("#pumpBtn").removeClass("btn-default").addClass("btn-success").text("关水泵").unbind("click").click(function () {
                        control("close2");
                    });
                else
                    $("#pumpBtn").removeClass("btn-success").addClass("btn-default").text("开水泵").unbind("click").click(function () {
                        control("open2");
                    });
            }
            else if (op == "open1" && ret == "light 1") { //开灯
                $("#lightBtn").removeClass("btn-default").addClass("btn-success").text("关灯").unbind("click").click(function () {
                    control("close1");
                });
            }
            else if (op == "close1" && ret == "light 0") {//关灯
                $("#lightBtn").removeClass("btn-success").addClass("btn-default").text("开灯").unbind("click").click(function () {
                    control("open1");
                });
            }
            else if (op == "open2" && ret == "pump 1") {//开水泵
                $("#pumpBtn").removeClass("btn-default").addClass("btn-success").text("关水泵").unbind("click").click(function () {
                    control("close2");
                });
            }
            else if (op == "close2" && ret == "pump 0") {//关水泵
                $("#pumpBtn").removeClass("btn-success").addClass("btn-default").text("开水泵").unbind("click").click(function () {
                    control("open2");
                });
            }
            else if (op == "open_close" && ret == "opDrive success") {
                alert("喂食成功");
                $("#fishBtn").removeClass("btn-default").addClass("btn-success").removeAttr("disabled");
            }
        }
        control("getStatus");
    </script>
</body>
</html>

(2)Activity里就一个WebView组件,主窗体后端代码MainActivity.java

package com.wszhoho.viewfish;

import android.annotation.SuppressLint;
import android.os.Bundle;
import android.os.Vibrator;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.view.WindowManager;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;

import java.lang.ref.WeakReference;
import java.util.Random;

public class MainActivity extends AppCompatActivity {
    static WeakReference<WebView> _webView;
    Vibrator vibrator;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE);
        Random rnd = new Random(100);
        int v = rnd.nextInt();
        String webViewUrl = "file:///android_asset/fish.html?v=" + v;
        initWebView(webViewUrl);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
    }

    @SuppressLint("SetJavaScriptEnabled")
    private void initWebView(String url) {
        _webView = new WeakReference<>(findViewById(R.id.webView));
        //重新设置WebSettings
        WebSettings webSettings = _webView.get().getSettings();
        webSettings.setDisplayZoomControls(false);
        webSettings.setSupportZoom(false);
        webSettings.setAppCacheEnabled(true);
        webSettings.setAllowFileAccess(true);
        webSettings.setUseWideViewPort(true);
        webSettings.setLoadWithOverviewMode(true);
        webSettings.setSaveFormData(false);
        webSettings.setDomStorageEnabled(true);
        webSettings.setSupportMultipleWindows(true);
        webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
        webSettings.setJavaScriptEnabled(true);
        _webView.get().addJavascriptInterface(this, "JSHook");
        _webView.get().setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY);
        _webView.get().canGoBack();
        _webView.get().requestFocus();

        _webView.get().setWebChromeClient(new WebChromeClient());
        _webView.get().loadUrl(url);
    }

    @JavascriptInterface
    public String execTcpCmd(String op) {
        try {
            if (!op.equals("getStatus"))
                vibrator.vibrate(100);
            String ret = TcpClient.SendMsg(op);
            return ret;
        } catch (Exception ignored) {
            return "-1";
        }
    }
}

(3)TcpClient.java

package com.wszhoho.viewfish;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;


class TcpClient {
    private static ReentrantLock lock = new ReentrantLock();

    static String SendMsg(String msg) {
        lock.lock();
        AtomicReference<String> retStr = new AtomicReference<>("");
        new Thread(() -> {
            Socket client = null;
            try {
                client = new Socket(树莓派IP, 7654);

                BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));

                OutputStream os = client.getOutputStream();
                os.write(msg.getBytes("utf-8"));
                os.flush();

                retStr.set(in.readLine());
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if (client != null) {
                    try {
                        client.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
        while (retStr.get().equals("")) {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        lock.unlock();
        return retStr.get();
    }
}

(4)AndroidManifest.xml权限配置

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.VIBRATE" />

自启动配置
首先更改系统默认的python运行版本:

sudo rm /usr/bin/python
sudo ln -s /usr/bin/python3 /usr/bin/python

进入/home/pi/.config目录,建立autostart文件夹,进入该文件夹,建立两个后缀名为".desktop"的文件。
(1) camera.desktop文件,内容为:

[Desktop Entry]
Type=Application
Exec=/home/pi/scripts/startCamera.sh

(1) tcpserver.desktop文件,内容为:

[Desktop Entry]
Type=Application
Exec=python /home/pi/scripts/MyTcpControl.py

完成后,重启树莓派,所有配置全部完成。

最终完成情况:
盒子巨丑,好在空间大,够放!
集成到盒子里
集成到盒子里
安卓APP,我家宝宝选的图标,巨喜欢 :-)
养了个鱼APP图标
养了个鱼APP界面


> 利用树莓派Zero远程可视化喂鱼

组件清单

  • 1 Raspberry pi zero × 1
  • 2 兼容的无线网卡 × 1
  • 3 usb hub × 1
  • 4 双路继电器 × 1
  • 5 ULN2003步进电机驱动模块及电机 × 1
  • 6 索尼ccd usb摄像头 × 1
  • 7 杜邦线10根 × 1