发布于 2026-01-06 1 阅读
0

基于 Elixir Nerves 和 Phoenix LiveView 组件的物联网鸟屋 🐦 简介 基本概念 设置新的 Elixir Nerves 项目 Picam DHT 安装 使用 Nadia 结语 DEV's Worldwide Show and Tell Challenge 由 Mux 呈现:展示你的项目!

一个基于 Elixir Nerves 和 Phoenix LiveView 组件的物联网鸟屋🐦

介绍

基本概念

建立一个新的 Elixir Nerves 项目

皮卡姆

二氢睾酮

安装

用法

娜迪亚Elixir CI 内联文档 下午六点 下午

最后的话

由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!

目录

介绍

我们科隆工业大学新开设的本科课程“代码与语境”(Code & Context)的学生们热爱项目式学习。在疫情封锁期间,我们希望与校园内的开放空间建立某种联系。

为了给第二学期画上圆满的句号,我们有一个项目,其愿景是:“远程探索者”。让未来的学生甚至完全不了解校园的人也能有机会探索我们的校园,这不仅很棒,还能让我们感受到与校园更紧密的联系。

我们决定使用 Elixir Nerves 和 Phoenix LiveView 构建一个物联网鸟屋。

Elixir 让我对编程有了全新的认识,我会在未来的项目中尽可能多地使用它。文末附有我推荐的学习资源链接。

你自己试试看

您可以在以下 Git 仓库中找到代码。

带我到 Git 仓库

基本概念

Nerves 是一个易于使用且功能强大的 Elixir 嵌入式系统构建框架。

我推荐观看 Todd Resudek 的演讲“为什么你的下一个(或第一个)硬件项目应该充满勇气地构建”。

我们还可以利用 Phoenix LiveView 构建具有以下功能的实时仪表板:

  • 控制LED灯的开关(在嵌入式系统项目中,怎么可能不控制它呢?)
  • 控制伺服电机以打开和关闭食品容器
  • 实时更新温度和湿度
  • 直播视频
  • 拍摄照片并将其发送到 Telegram 聊天室

Birdhouse 控制面板运行中

鸟屋仪表盘正在运行中

硬件

我们的项目将使用以下硬件:

  • 树莓派3
  • 树莓派摄像头V2
  • 一个简单的LED连接到GPIO引脚18和GND。
  • 一个简单的伺服电机,连接到 GPIO 引脚 23、5V 和 GND。
  • 一个DHT22温湿度传感器连接到GPIO引脚4、3.3V和GND。
接地 3.3伏 5伏 GPIO
DHT22 x x 4
伺服电机 x x 23
引领 x 18

建立一个新的 Elixir Nerves 项目

我非常喜欢 Elixir 生态系统的一点是,你需要的大部分内容都有非常完善的文档记录。

要在 Mac、Linux 或 Windows 上设置开发环境,请访问 Nerves 官方文档:https://hexdocs.pm/nerves/installation.html

创建我们的第一个 Nerves 项目

我们将为我们的应用程序使用“poncho 项目”结构,以便将其拆分为几个较小的应用程序,然后这些应用程序将存在于一个 monorepo 中。

您可以在官方的 Nerves 文档中找到 Poncho 项目的设置:https://hexdocs.pm/nerves/user-interfaces.html

按照指南操作时,请确保不要忘记--live在省略属性时使用our --no-webpack

mix phx.new bird_app_ui --no-ecto --live

Enter fullscreen mode Exit fullscreen mode

通过 SSH 设置 WiFi 并进行更新。

Nerves 为我们提供了一种通过 SSH 轻松设置更新的方法,这样我们就不必不断地将 SD 卡从开发设备换到树莓派上。

更新 WiFi 配置

首先,让我们更新一下 WiFi 配置,以便我们可以使用环境变量来设置 SSID 和 PSK:

# bird_app_firmware/config/target.exs

# ...
# Configure the network using vintage_net
# See <https://github.com/nerves-networking/vintage_net> for more information
config :vintage_net,
  regulatory_domain: "US",
  config: [
    {"usb0", %{type: VintageNetDirect}},
    {"eth0",
     %{
       type: VintageNetEthernet,
       ipv4: %{method: :dhcp}
     }},
     {"wlan0",
     %{
       type: VintageNetWiFi,
       vintage_net_wifi: %{
         key_mgmt: :wpa_psk,
         ssid: System.get_env("NERVES_NETWORK_SSID"),
         psk: System.get_env("NERVES_NETWORK_PSK")
       },
       ipv4: %{method: :dhcp}
     }}
  ]
# ...
Enter fullscreen mode Exit fullscreen mode

要设置环境变量,请在终端中执行以下命令:

export MIX_TARGET=rpi3
export MIX_ENV=dev
export NERVES_NETWORK_SSID=your_wifi_name
export NERVES_NETWORK_PSK=your_wifi_password
Enter fullscreen mode Exit fullscreen mode

设置 SSH 部署

现在我们可以将该nerves_firmware_ssh软件包添加到 Mix 依赖项列表中。

# bird_app_firmware/mix.exs

# ...
  defp deps do
    [
      # Dependencies for all targets
      {:nerves_firmware_ssh, "~> 0.3", targets: @all_targets},
      {:bird_app_ui, path: "../bird_app_ui"},
      {:nerves, "~> 1.6.0", runtime: false},
      # ...
    ]
  end
# ...
Enter fullscreen mode Exit fullscreen mode

现在cd进入你的bird_app_firmware并运行

mix deps.get
mix firmware
# (Connect the SD card)
mix firmware.burn
Enter fullscreen mode Exit fullscreen mode

从现在开始,将 SD 卡插入树莓派后,您可以通过网络访问树莓派(如果您与树莓派位于同一局域网内),ssh nerves.local以便进行调试并从bird_app_firmware网络部署更新。

# create new firmware
mix firmware
# upload firmware via ssh
mix upload
Enter fullscreen mode Exit fullscreen mode

Picam - 设置视频流

我们的项目将使用 Pi NoIR Camera V2 模块。

购买 Pi NoIR 相机 V2 - Raspberry Pi

基本皮卡汀尼设置

GitHub 标志 Elixir-Vision / Picam

Elixir 库用于在 Raspberry Pi 上使用摄像头模块捕获 MJPEG 视频。

皮卡姆

十六进制版本

Picam 是一个 Elixir 库,它提供了一个简单的 API,用于在运行 Linux 的 Raspberry Pi 设备上使用摄像头模块传输 MJPEG 视频和捕获 JPEG 静态图像。

API 目前支持的功能:

  • 设置锐度、对比度、亮度、饱和度、ISO 和快门速度值
  • 设置曝光、传感器、测光和白平衡模式
  • 设置图像和颜色效果
  • 垂直和水平旋转和翻转图像
  • 设置曝光补偿 (EV) 级别
  • 更改图像尺寸
  • 通过质量级别、重启间隔和感兴趣区域来调整 JPEG 保真度。
  • 启用或禁用视频防抖功能
  • 调整视频帧速率
  • 将全屏或窗口化视频预览渲染到 HDMI 和 CSI 显示器

有关上述功能的具体信息,请参阅Hex 文档

要求

要求 笔记
主机设备 树莓派 1、2、3、Zero/W Zero 和 Zero W 需要特殊的扁平电缆
操作系统 Linux 开箱即用

首先,我们将在 bird_app 文件夹内创建一个新项目。

运行mix new bird_app_hardware --sup以创建一个带有主管的新 mix 项目,并将 picam 添加到依赖项列表中。

# bird_app_hardware/mix.exs

# ...
  defp deps do
    [
      {:picam, "~> 0.4.0"}
    ]
  end
# ..
Enter fullscreen mode Exit fullscreen mode

更新bird_app_hardware配置,以便我们可以在开发中使用 FakeCamera。

# bird_app_hardware/config/confix.exs

use Mix.Config

config :picam, camera: Picam.FakeCamera

config :logger,
  level: :debug,
  utc_log: true

config :logger, :console,
  level: :debug,
  format: "$dateT$time [$level] $message\\n"
Enter fullscreen mode Exit fullscreen mode

创建两个新文件,分别bird_app_hardware/lib/bird_app_hardware命名为camera.exconfiguration.ex.

# bird_app_hardware/lib/bird_app_hardware/camera.ex

defmodule BirdAppHardware.Camera do
    use GenServer
    alias BirdAppHardware.Configuration
    require Logger

    def get_config(), do: GenServer.call(__MODULE__, :get_config)

    def set_size(width, height) do
      GenServer.call(__MODULE__, {:set_size, width, height})
    end

    def set_img_effect(effect) do
      GenServer.call(__MODULE__, {:set_img_effect, effect})
    end

    defdelegate next_frame(), to: Picam

    def start_link(opts \\\\ []) do
      GenServer.start_link(__MODULE__, opts, name: __MODULE__)
    end

    def init(_opts) do
      Logger.info("Configuring camera")
      conf = %Configuration{}
      Picam.set_size(conf.size.width, conf.size.height)
      Picam.set_img_effect(conf.img_effect)
      {:ok, conf}
    end

    def handle_call(:get_config, _from, conf), do: {:reply, conf, conf}

    def handle_call({:set_size, width, height}, _from, conf) do
      case Picam.set_size(width, height) do
        :ok ->
          if width > 1280 do
            Picam.set_quality(5)
          else
            Picam.set_quality(15)
          end

          conf = %{conf | size: %{width: width, height: height}}
          {:reply, :ok, conf}

        err ->
          {:reply, err, conf}
      end
    end

    def handle_call({:set_img_effect, effect}, _from, conf) do
      case Picam.set_img_effect(effect) do
        :ok ->
          conf = %{conf | img_effect: effect}
          {:reply, :ok, conf}

        err ->
          {:reply, err, conf}
      end
    end
end
Enter fullscreen mode Exit fullscreen mode
# bird_app_hardware/lib/bird_app_hardware/configuration.ex

defmodule BirdAppHardware.Configuration do
  defstruct size: %{width: 640, height: 480},
            img_effect: :normal

  @typedoc @moduledoc
  @type t ::
          %__MODULE__{
            size: dimensions(),
            img_effect: img_effect()
          }

  @type dimensions ::
          %{width: non_neg_integer(), height: non_neg_integer()}

  @type img_effect ::
          :normal
          | :sketch
          | :oilpaint
end
Enter fullscreen mode Exit fullscreen mode

现在我们可以将picam子摄像头添加到我们的监控器中application.ex

# bird_app_hardware/lib/bird_app_hardware/application.ex

# ...
def start(_type, _args) do
    children = [
      # Starts a worker by calling: BirdAppHardware.Worker.start_link(arg)
      # {BirdAppHardware.Worker, arg}
      Picam.Camera,
      BirdAppHardware.Camera
    ]
# ...
Enter fullscreen mode Exit fullscreen mode

最后将bird_app_hardware项目添加到bird_app_firmware mix.exs文件依赖项中。

# bird_app_firmware/mix.exs

# ...
  defp deps do
    [
      # Dependencies for all targets
      {:bird_app_hardware, path: "../bird_app_hardware"},
      # ...
    ]
  end
# ...
Enter fullscreen mode Exit fullscreen mode

构建 MJPG 流

既然我们想要公开我们的摄像头直播流,那就把它添加到我们的系统中bird_app_ui,创建一个直播模块。

# bird_app_ui/lib/bird_app_ui_web/streamer.ex

defmodule BirdAppUi.Streamer do
  @moduledoc """
  Plug for streaming an image
  """
  import Plug.Conn

  @behaviour Plug
  @boundary "w58EW1cEpjzydSCq"

  def init(opts), do: opts

  def call(conn, _opts) do
    conn
    |> put_resp_header("Age", "0")
    |> put_resp_header("Cache-Control", "no-cache, private")
    |> put_resp_header("Pragma", "no-cache")
    |> put_resp_header("Content-Type", "multipart/x-mixed-replace; boundary=#{@boundary}")
    |> send_chunked(200)
    |> send_pictures
  end

  defp send_pictures(conn) do
    send_picture(conn)
    send_pictures(conn)
  end

  defp send_picture(conn) do
    jpg = BirdAppHardware.Camera.next_frame
    size = byte_size(jpg)
    header = "------#{@boundary}\\r\\nContent-Type: image/jpeg\\r\\nContent-length: #{size}\\r\\n\\r\\n"
    footer = "\\r\\n"
    with {:ok, conn} <- chunk(conn, header),
         {:ok, conn} <- chunk(conn, jpg),
         {:ok, conn} <- chunk(conn, footer),
      do: conn
  end
end
Enter fullscreen mode Exit fullscreen mode

将以下内容添加到我们的router.ex……

# bird_app_ui/lib/bird_app_ui_web/router.ex

# ...
forward "/video.mjpg", BirdAppUi.Streamer
Enter fullscreen mode Exit fullscreen mode

要更新固件,请将光盘插入bird_app_firmware并运行mix firmware&& mix upload

现在你应该可以访问了nerves.local/video.mjpeg。但是仍然存在一些问题。由于默认配置,60 秒后会超时。要解决这个问题,我们需要将idle_timeout超时时间改为无穷大。

# bird_app_firmware/config/target.exs

use Mix.Config

# When we deploy to a device, we use the "prod" configuration:
import_config "../../bird_app_ui/config/config.exs"
import_config "../../bird_app_ui/config/prod.exs"

config :bird_app_ui, BirdAppUiWeb.Endpoint,
  # Nerves root filesystem is read-only, so disable the code reloader
  code_reloader: false,
  http: [
    port: 80,
    protocol_options: [
      idle_timeout: :infinity
    ]
  ],
  # Use compile-time Mix config instead of runtime environment variables
  load_from_system_env: false,
  # Start the server since we're running in a release instead of through `mix`
  server: true,
  url: [host: "nerves.local", port: 80]
#...
Enter fullscreen mode Exit fullscreen mode

现在我们的便携式摄像机可以完美地进行连续直播了。

添加单张图片快照插件

为了轻松地为视频中的单个图像添加路由,我们可以添加一个利用我们next_frame()功能的插件。

# bird_app_ui/lib/bird_app_ui_web/snap_plug.ex

defmodule BirdAppUiWeb.SnapPlug do
  import Plug.Conn

  def init(opts), do: opts
  def call(conn, _opts) do
    conn
    |> put_resp_header("Age", "0")
    |> put_resp_header("Cache-Control", "no-cache, private")
    |> put_resp_header("Pragma", "no-cache")
    |> put_resp_header("Content-Type", "image/jpeg")
    |> send_resp(200, BirdAppHardware.Camera.next_frame())
  end

end
Enter fullscreen mode Exit fullscreen mode

并转发一条通往/snap.jpg该地点的路线。

# bird_app_ui/lib/bird_app_ui_web/router.ex

# ...
forward "/snap.jpg", BirdAppUiWeb.SnapPlug
# ...
Enter fullscreen mode Exit fullscreen mode

温度和湿度传感器的实时视图组件

我使用DHT22作为温湿度传感器,连接到GPIO 4引脚,并使用以下hex软件包:

GitHub 标志 jjcarstens / dht

用于读取 DHT11 和 DHT22 传感器的 Elixir 实现

二氢睾酮

DHT 11、DHT 22 和 AM2302 温湿度传感器的驱动程序

安装

它将Adafruit Python DHT库的 C 源代码移植到引脚读取功能。

目前仅支持有效的 Nerves 目标,但将来将可在任何具有 GPIO 的 Elixir 环境(如 rasbian)中使用。

对于不支持的平台(如主机、MacOS 等),读数仍然有效,但会随机生成。

def  deps () do 
  { :dht , " ~> 0.1 " }
 end
Enter fullscreen mode Exit fullscreen mode

更多信息请参阅产品规格表:

用法

读取数据时,需要指定 GPIO 引脚编号和传感器类型。传感器类型可以是目标传感器的字符串、原子或整数表示形式:

iex ( ) >  DHT.read ( 6 , : dht22 )
{ :ok , %{ temperature:  22.6 , humidity:  50.5 }}
 iex () >  DHT .
Enter fullscreen mode Exit fullscreen mode

将dht添加到依赖项列表中。

# bird_app_hardware/mix.exs

# ...
  defp deps do
    [
      {:dht, "~> 0.1"}
    ]
  end
# ..
Enter fullscreen mode Exit fullscreen mode

我们将再次使用 GenServer,DHT.start_polling(4, :dht22, 2)一旦 GenServer 初始化,我们将开始每 2 秒轮询一次数据,这也将使我们能够利用遥测事件。

# bird_app_hardware/lib/bird_app_hardware/dht.ex

defmodule BirdAppHardware.Dht do
  use GenServer
  require Logger

  @dht_pin Application.get_env(:bird_app_hardware, :dht_pin, 4)

  def start_link(state \\ []) do
    GenServer.start_link(__MODULE__, state, name: __MODULE__)
  end

  def init(_state) do
    Logger.info("Starting DHT Sensor")
    DHT.start_polling(@dht_pin, :dht22, 2)
    {:ok, %{temperature: "Loading...", humidity: "Loading..."}}
  end

  def read() do
    GenServer.call(__MODULE__, :read)
  end

  def handle_call(:read, _from, state) do
    {:reply, state, state}
  end

  def handle_cast({:update, measurements}, _state) do
    {:noreply,
     %{
       humidity: floor(measurements.humidity),
       temperature: floor(measurements.temperature)
     }}
  end

  def handle_event(_event, measurements, _metadata, _config) do
    GenServer.cast(__MODULE__, {:update, measurements})
    |> broadcast(:dht_update, %{
      humidity: floor(measurements.humidity),
      temperature: floor(measurements.temperature)
    })
  end

  def subscribe do
    Phoenix.PubSub.subscribe(BirdAppUi.PubSub, "dht")
  end

  defp broadcast(:ok, event, data) do
    Phoenix.PubSub.broadcast(BirdAppUi.PubSub, "dht", {event, data})
    {:ok, data}
  end
end
Enter fullscreen mode Exit fullscreen mode
# bird_app_ui/lib/bird_app_ui/application.ex

# ...
:telemetry.attach("dht", [:dht, :read], &BirdAppHardware.Dht.handle_event/4, nil)
# ...
Enter fullscreen mode Exit fullscreen mode

现在让我们创建一个 LiveComponent,它将存在于我们的 LiveView 中,并在连接建立后立即订阅这些事件。

LiveView 将处理广播并向每个 LiveComponent 发送更新。

# bird_app_ui/lib/bird_app_ui_web/live/components/stats_component.ex

defmodule BirdAppUiWeb.StatsComponent do
  use BirdAppUiWeb, :live_component

  @impl true
  def mount(socket) do
    {:ok, socket}
  end

  @impl true
  def update(assigns, socket) do
    {:ok, assign(socket, assigns)}
  end

  @impl true
  def render(assigns) do
    ~L"""
    <div class="w-full md:w-1/3 p-3">
      <div class="rounded shadow-lg p-2">
          <div class="flex flex-row items-center">
              <div class="flex-shrink pr-4">
                  <!-- empty div so bg-colors don't get purged while we load the css class from the @color variable -->
                  <div class="bg-green-600 bg-blue-600 bg-orange-600 hidden"></div>

                  <div class="rounded p-3 bg-<%= @color %>-600"><i class="fa <%= @icon %> fa-2x fa-fw fa-inverse"></i></div>
              </div>
              <div class="flex-1 text-right md:text-center">
                  <h5 class="font-bold uppercase text-gray-400"><%= @stats_name %></h5>
                  <h3 class="font-bold text-3xl text-gray-600"><%= @stats %><%= @character %></h3>
              </div>
          </div>
      </div>
    </div>
    """
  end
end

Enter fullscreen mode Exit fullscreen mode
# bird_app_ui/lib/bird_app_ui_web/live/page_live.ex

# ...
  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket), do: BirdAppHardware.Dht.subscribe()

    measurements = BirdAppHardware.Dht.read()

    {:ok,
     assign(socket,
       temperature: measurements.temperature,
       humidity: measurements.humidity
     )}
  end

  @impl true
  def handle_info({:dht_update, measurements}, socket) do
    send_update(BirdAppUiWeb.StatsComponent, id: "humidity", stats: measurements.humidity)
    send_update(BirdAppUiWeb.StatsComponent, id: "temperature", stats: measurements.temperature)
    {:noreply, socket}
  end
# ...
Enter fullscreen mode Exit fullscreen mode

# bird_app_ui/lib/bird_app_ui_web/live/page_live.html.leex

# ...
<%= live_component @socket, BirdAppUiWeb.StatsComponent, id: "temperature", stats: @temperature, stats_name: "Temperature", color: "orange", icon: "fa-thermometer-half", character: "°C" %>
<%= live_component @socket, BirdAppUiWeb.StatsComponent, id: "humidity", stats: @humidity, stats_name: "Humidity", color: "blue", icon: "fa-tint", character: "%" %>
# ...
Enter fullscreen mode Exit fullscreen mode

您可能已经注意到,我在这个项目中使用了 Tailwind CSS。如果您想了解如何在 Elixir 项目中配置 Tailwind CSS,请访问https://andrich.me/learn-elixir-and-phoenix-add-tailwind-css

向 Telegram 聊天室发送快照

如果你在直播中碰巧看到一只鸟,最好把这一刻保存下来。接下来我将向你展示如何在你的应用程序中集成一个简单的 Telegram 机器人。

我打算使用 nadia telegram bot hex 包。

GitHub 标志 zhyu / nadia

用 Elixir 编写的 Telegram Bot API 封装器

娜迪亚Elixir CI 内联文档 下午六点 下午

用 Elixir 编写的 Telegram Bot API 封装器(文档

安装

将 Nadia 添加到您的mix.exs依赖项中:

def  deps  do 
  [{ :nadia , " ~> 0.7.0 " }]
 end
Enter fullscreen mode Exit fullscreen mode

然后跑$ mix deps.get

配置

在 中,像这样config/config.exs添加你的 Telegram Bot 令牌

配置:nadia 
  token: "机器人令牌"
Enter fullscreen mode Exit fullscreen mode

您还可以添加可选的 recv_timeout 参数(以秒为单位,默认为 5 秒)。

配置:nadia接收超时: 10
Enter fullscreen mode Exit fullscreen mode

您还可以添加代理支持。

配置:nadia代理: " http://proxy_host:proxy_port " , #或 {:socks5, 'proxy_host', proxy_port},
  代理认证: { " user " , " password " },
   ssl: [ versions: [ :'tlsv1.2' ]]
Enter fullscreen mode Exit fullscreen mode

如果出于某种原因需要,您也可以配置 API 的基本 URL。

config :nadia ,
   # Telegram API。默认值:https://api.telegram.org/bot 
  base_url:  " http://my-own-endpoint.com/whatever/ " ,

  # Telegram Graph API。默认值:https://api.telegra.ph 
  graph_base_url:  " http://my-own-endpoint.com/whatever/ "
Enter fullscreen mode Exit fullscreen mode

环境…

首先安装必要的依赖项

# bird_app_ui/mix.exs

# ...
{:nadia, "~> 0.7.0"}
# ...
Enter fullscreen mode Exit fullscreen mode

在 config/config.exs 文件中,像这样添加你的 Telegram Bot 令牌

# bird_app_ui/config/config.exs

# ...
config :nadia,
  token: System.get_env("TELEGRAM_BOT_TOKEN"),
  chat_id: System.get_env("TELEGRAM_CHAT_ID")
# ...
Enter fullscreen mode Exit fullscreen mode

现在我们将创建一个新的 LiveComponent:

# bird_app_ui/lib/bird_app_ui_web/live/telegram_snap_component.ex

defmodule BirdAppUiWeb.TelegramSnapComponent do
  use BirdAppUiWeb, :live_component

  alias BirdAppHardware.Camera

  @chat_id Application.get_env(:nadia, :chat_id)

  @impl true
  def mount(socket) do
    {:ok, socket}
  end

  @impl true
  def render(assigns) do
    ~L"""
    <button class="w-full font-bold bg-white hover:bg-gray-100 text-gray-800 py-2 px-4 border border-gray-400 rounded shadow my-2" phx-click="snap" phx-target="<%= @myself %>">
      Snap
    </button>
    """
  end

  @impl true
  def handle_event("snap", _, socket) do
    File.write!("/tmp/snap.jpg", Camera.next_frame())
    Nadia.send_photo(@chat_id, "/tmp/snap.jpg")
    {:noreply, socket}
  end
end
Enter fullscreen mode Exit fullscreen mode

# bird_app_ui/lib/bird_app_ui_web/live/page_live.html.leex

# ...
<%= live_component @socket, BirdAppUiWeb.TeleGramSnapComponent, id: "telegram-snap" %>
# ...
Enter fullscreen mode Exit fullscreen mode

当我们点击按钮时,组件会将snap事件发送到自身,我们首先会写入一个临时文件,最终可以将该文件snap.jpg的内容发送到 Telegram。BirdAppHardware.Camera.next_frame()

最后的话

说实话,我用 Elixir 开发和构建东西真的很有趣,以后还会做更多。

我的待办事项清单上还有一些工作要做,比如实时鸟类检测。我们还需要把所有功能集成到下面的实体鸟屋里,并用3D打印机打印一些额外的配件,例如摄像头支架和食物容器。

https://dev-to-uploads.s3.amazonaws.com/i/zyjp507berbv1vyyl46e.png

我没有详细讲解每个组件。如果您对应用程序的开发感兴趣,请查看git 仓库并试用一下!

感谢 Elixir、Nerves 和 Phoenix 团队💫

我的学习资源

文章来源:https://dev.to/dasky/an-iot-birdhouse-with-elixir-nerves-phoenix-liveview-components-5cb2