一个基于 Elixir Nerves 和 Phoenix LiveView 组件的物联网鸟屋🐦
介绍
基本概念
建立一个新的 Elixir Nerves 项目
皮卡姆
二氢睾酮
安装
用法
娜迪亚
最后的话
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
目录
介绍
我们科隆工业大学新开设的本科课程“代码与语境”(Code & Context)的学生们热爱项目式学习。在疫情封锁期间,我们希望与校园内的开放空间建立某种联系。
为了给第二学期画上圆满的句号,我们有一个项目,其愿景是:“远程探索者”。让未来的学生甚至完全不了解校园的人也能有机会探索我们的校园,这不仅很棒,还能让我们感受到与校园更紧密的联系。
我们决定使用 Elixir Nerves 和 Phoenix LiveView 构建一个物联网鸟屋。
Elixir 让我对编程有了全新的认识,我会在未来的项目中尽可能多地使用它。文末附有我推荐的学习资源链接。
你自己试试看
您可以在以下 Git 仓库中找到代码。
带我到 Git 仓库
基本概念
Nerves 是一个易于使用且功能强大的 Elixir 嵌入式系统构建框架。
我推荐观看 Todd Resudek 的演讲“为什么你的下一个(或第一个)硬件项目应该充满勇气地构建”。
VIDEO
我们还可以利用 Phoenix LiveView 构建具有以下功能的实时仪表板:
控制LED灯的开关(在嵌入式系统项目中,怎么可能不控制它呢?)
控制伺服电机以打开和关闭食品容器
实时更新温度和湿度
直播视频
拍摄照片并将其发送到 Telegram 聊天室
鸟屋仪表盘正在运行中
硬件
我们的项目将使用以下硬件:
树莓派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
基本皮卡汀尼设置
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.ex和 configuration.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软件包:
用于读取 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 - 2 x 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 - 3 xl 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 包。
用 Elixir 编写的 Telegram Bot API 封装器
娜迪亚
用 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打印机打印一些额外的配件,例如摄像头支架和食物容器。
我没有详细讲解每个组件。如果您对应用程序的开发感兴趣,请查看 git 仓库 并试用一下!
感谢 Elixir、Nerves 和 Phoenix 团队💫
我的学习资源
文章来源:https://dev.to/dasky/an-iot-birdhouse-with-elixir-nerves-phoenix-liveview-components-5cb2