"大家是不是经常碰到出差或者放长假外出的时候,总是担心家里的鱼儿没人照顾怎么办?利用树莓派,我们 DIY 打造一个可视化远程喂鱼机器吧! 思路:利用双路继电器分别控制灯和水泵,使用 mjpg-streamer 来获取摄像头的视频流,并在特定的时刻自动开闭继电器。 网络环境:有公网 IP 的家庭网络,利用路由器的 ddns .."

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

本贴最后更新于 359 天前,其中的信息可能已经渤澥桑田

大家是不是经常碰到出差或者放长假外出的时候,总是担心家里的鱼儿没人照顾怎么办?利用树莓派,我们 DIY 打造一个可视化远程喂鱼机器吧!

【鱼水圈APP下载iOS】 | 【鱼水圈APP下载Android】

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

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

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

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


硬件部分

本项目中使用的硬件:
必不可少的大脑:

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

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

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

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

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

5. USB 集线器

软件部分

软件也是主要三大块:

  1. 继电器控制、定时控制、步进电机控制 (代码文件保存到 /home/pi/scripts/MyTcpControl.py)
  2. 摄像头实时视频流部署 (启动视频流服务的脚本保存到 /home/pi/scripts/startCamera.sh)
  3. 安卓远程控制 APP>
    1. 双路继电器控制、自动定时控制、步进电机控制
    本模块使用 Python 语言编写。
  4. 建立 TCP 服务器,通信端口为 7654
  5. 高低电平控制
    由于使用的继电器写低为接通电路,所以代码中,使用 GPIO.LOW 来接通继电器电路,GPIO.HIGH 来关闭继电器电路。
  6. 电机步进序列控制。
    步进电机使用双 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>
  1. 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”的文件。
camera.desktop 文件,内容为:

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

tcpserver.desktop 文件,内容为:

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

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

最终完成情况:
盒子巨丑,好在空间大,够放!



安卓 APP,我家宝宝选的图标,巨喜欢 :-)

作者:wszhoho
项目主页:http://make.quwj.com/project/86

文章标题:利用树莓派 Zero 远程可视化喂鱼 - 树莓派实验室

固定链接:https://shumeipai.nxez.com/2018/12/20/use-the-raspberry-pi-zero-to-remotely-feed-fish.html

【鱼水圈APP下载iOS】 | 【鱼水圈APP下载Android】

  • 设备DIY
    17 引用 • 110 回帖
  • 技术讨论

    水族相关的技术交流和讨论。

    16 引用 • 121 回帖 • 128 关注
  • 硬件DIY
    13 引用 • 95 回帖
  • 智能硬件

    智能硬件通过软硬件结合的方式,对传统设备进行改造,进而让其拥有智能化,并实现设备连接的能力,实现互联网服务的加载,形成“云 + 端”的方式,为鱼友提供便捷、智能、科学的服务。

    6 引用 • 39 回帖 • 90 关注
【鱼水圈APP下载iOS】 | 【鱼水圈APP下载Android】
6 回帖   
请输入回帖内容...
  • 我是一只鱼  

    突然发现我的大脑不够用了👍 极客太强了

  • 水中有鱼  

    666,膜拜大神

  • 辛普森  

    有趣,看了看自己手里的树莓派,恨铁不成钢啊

  • 哈哈毅  

    可以,之前我也有用树莓派做推流到斗鱼直播 + 远程喂鱼。不过楼主这个还弄了 app,更完善,膜拜

  • 昭君  

    不会写代码的人只好膜拜一下了👍

  • 鱼水社圈管理员  

    虽然不会,但是养鱼不就是折腾的劲吗?哈哈

请输入回帖内容...