TTGO T-Display v1 的 Sonos 扬声器系统遥控器





5.00/5 (8投票s)
控制您家中每个房间的 Sonos 系统的声音。
引言
我有一个朋友住在世界的另一端,他主要不是程序员,需要我在软件方面帮助他完成他的 Sonos 音响系统 遥控器。硬件方面,我推荐他使用 TTGO T-Display v1,因为它们相对便宜,并且将所有必要的硬件集成在一个设备中,无需额外接线,还可以使用 LIPO 电池供电。
更新:已清理代码。代码使用我新的扩展按钮。代码采用双缓冲以避免闪烁。
更新 2:已进一步清理代码。代码使用我新-新的扩展按钮。代码现在包含上一曲功能。
更新 3:代码使用更新的调光器库。帧缓冲区现在是部分的,只重绘屏幕底部。
更新 4:现在 api.txt 的第一个 URL 用于长按,接下来的每个 URL 用于连续短按次数。URL 也已进行 URL 编码。
更新 5:优化了代码,减少了 SPI 流量以节省电池寿命。现在仅在需要时连接,而不是在启动时。使字体和文本大小选择更加模块化。
更新 5:修复了连接代码。
必备组件
您需要安装了 PlatformIO 的 VS Code。
您需要一个 Sonos 音响系统。
您需要安装并配置网络上的 Node Sonos HTTP API。
您需要一个 TTGO T-Display v1。
使用这个烂摊子
下载项目后,您需要编辑 /data 文件夹下的几个文件。
- 编辑 speakers.csv 以反映您使用的音响或房间。这些名称必须与 Sonos 系统识别的名称相同。
- 使用您的 SSID 和密码编辑 wifi.txt。
- 使用您的 API 命令 URL 编辑 api.txt。您只需要更改 URL 的基本部分,保留
/%s
和其后的任何内容。
之后,您需要在 **PIO**|**Project Tasks**|**Platform** 下选择 **Upload Filesystem Image**。
完成后,您可以上传项目,它将自动连接。
在方向上,按钮应位于显示屏的右侧,但文本说明很清楚。
顶部的按钮用于更改当前所在的音响/房间。
单击底部的按钮可在该房间/音响之间切换播放和暂停。长按该按钮可跳到下一曲。快速单击两次可返回上一曲。
一段时间后,设备会变暗并进入睡眠状态以节省电池。按下顶部的按钮将其唤醒。
编写这堆乱七八糟的代码
我对这段源代码进行了大量注释,但这里也会进行一些介绍。这段代码大量使用了我的 IoT 生态系统,包括 我的图形库、我的按钮库,以及我的 背光管理库。
我应该提到,logo.hpp (一个 JPG) 和 SonosFont.hpp 都是使用我基于浏览器的 这里提供的 header 代码生成器 生成的。它大部分是自解释的。只需将文件拖到它上面并生成,它就会产生一个相应类型的对象——无论是字体还是适合 draw::text<>()
或 draw::image<>()
的流。请注意,如果您以这种方式绘制图像,如果您想再次绘制它,则必须将流重置到开头。为了便于从 HTTP 和类似位置加载,该函数不会自动重置流。
这段代码的核心,包括所有核心逻辑,都在 main.cpp 中。
#include <Arduino.h>
#include <config.h>
#include <gfx.hpp>
#include <htcw_button.hpp>
#include <st7789.hpp>
#include <tft_io.hpp>
#include <lcd_miser.hpp>
#include <fonts/SonosFont.hpp>
#include <logo.hpp>
#include <SPIFFS.h>
#include <WiFi.h>
#include <HTTPClient.h>
using namespace arduino;
using namespace gfx;
// configure the display
using bus_t = tft_spi_ex<LCD_HOST,
PIN_NUM_CS,
PIN_NUM_MOSI,
PIN_NUM_MISO,
PIN_NUM_CLK,
SPI_MODE0,
true,
LCD_WIDTH*LCD_HEIGHT*2+8,2>;
using display_t = st7789<LCD_WIDTH,
LCD_HEIGHT,
PIN_NUM_DC,
PIN_NUM_RST,
-1 /* PIN_NUM_BCKL */,
bus_t,
1,
true,
400,
200>;
using color_t = color<typename display_t::pixel_type>;
// background color for the display (24 bit, followed by display's native pixel type)
constexpr static const rgb_pixel<24> bg_color_24(/*R*/12,/*G*/12,/*B*/12);
constexpr static const display_t::pixel_type bg_color = convert<rgb_pixel<24>,display_t::pixel_type>(bg_color_24);
static display_t dsp;
// configure the buttons
using button_1_t = button_ex<PIN_BUTTON_1,
10,
true>;
using button_2_t = button_ex<PIN_BUTTON_2,
10,
true>;
static button_1_t button_1;
static button_2_t button_2;
// configure the backlight manager
static lcd_miser<PIN_NUM_BCKL> dimmer;
// function prototypes
static void ensure_connected();
static void draw_room(int index);
static const char* room_for_index(int index);
static const char* string_for_index(const char* strings,int index);
static void do_request(int index,const char* url_fmt);
// font
static const open_font& speaker_font = SonosFont;
static const uint16_t speaker_font_height = 35;
// global state
static HTTPClient http;
// current speaker/room
static int speaker_index = 0;
// number of speakers/rooms
static int speaker_count = 0;
// series of concatted null
// termed strings for speakers/rooms
static char* speaker_strings = nullptr;
// how many urls are in api txt
static int format_url_count = 0;
// the format string urls
static char* format_urls = nullptr;
// temp for formatting urls
static char url[1024];
static char url_encoded[1024];
// the Wifi SSID
static char wifi_ssid[256];
// the Wifi password
static char wifi_pass[256];
// temp for using a file
static File file;
// begin fade timestamp
static uint32_t fade_ts=0;
// rather than draw directly to the display, we draw
// to a bitmap, and then draw that to the display
// for less flicker. Here we create the bitmap
using frame_buffer_t = bitmap<typename display_t::pixel_type>;
// reversed due to LCD orientation:
constexpr static const size16 frame_buffer_size({LCD_HEIGHT,speaker_font_height});
static uint8_t frame_buffer_data[frame_buffer_t::sizeof_buffer(frame_buffer_size)];
static frame_buffer_t frame_buffer(frame_buffer_size,frame_buffer_data);
static void button_1_on_click(int clicks,void* state) {
// if we're dimming/dimmed we don't want
// to actually increment
if(!dimmer.dimmed()) {
// move to the next speaker
speaker_index+=clicks;
while(speaker_index>=speaker_count) {
// wrap around
speaker_index -= speaker_count;
}
// redraw
draw_room(speaker_index);
}
// reset the dimmer
dimmer.wake();
}
static void button_2_on_click(int clicks,void* state) {
if(clicks<format_url_count) {
const char* fmt_url = string_for_index(format_urls, clicks);
if(fmt_url!=nullptr) {
do_request(speaker_index, fmt_url);
}
}
// reset the dimmer
dimmer.wake();
}
static void button_2_on_long_click(void* state) {
// play the first URL
if(format_urls!=nullptr) {
do_request(speaker_index,format_urls);
}
// reset the dimmer
dimmer.wake();
}
static char *url_encode(const char *str, char *enc){
for (; *str; str++){
int i = *str;
if(isalnum(i)|| i == '~' || i == '-' || i == '.' || i == '_') {
*enc=*str;
} else {
sprintf( enc, "%%%02X", *str);
}
while (*++enc);
}
return( enc);
}
static void do_request(int index, const char* url_fmt) {
const char* room = string_for_index(speaker_strings, index);
url_encode(room,url_encoded);
snprintf(url,1024,url_fmt,url_encoded);
// connect if necessary
ensure_connected();
// send the command
Serial.print("Sending ");
Serial.println(url);
http.begin(url);
http.GET();
http.end();
}
static void ensure_connected() {
// if not connected, reconnect
if(WiFi.status()!=WL_CONNECTED) {
Serial.printf("Connecting to %s...\n",wifi_ssid);
WiFi.begin(wifi_ssid,wifi_pass);
while(WiFi.status()!=WL_CONNECTED) {
delay(10);
}
Serial.println("Connected.");
}
}
static void draw_center_text(const char* text) {
// set up the font
open_text_info oti;
oti.font = &speaker_font;
oti.text = text;
// 35 pixel high font
oti.scale = oti.font->scale(speaker_font_height);
// center the text
ssize16 text_size = oti.font->measure_text(
ssize16::max(),
spoint16::zero(),
oti.text,
oti.scale);
srect16 text_rect = text_size.bounds();
text_rect.center_horizontal_inplace((srect16)frame_buffer.bounds());
draw::text(frame_buffer,text_rect,oti,color_t::white,bg_color);
}
static const char* string_for_index(const char* strings,int index) {
if(strings==nullptr) {
return nullptr;
}
// move through the string list
// a string at a time until the
// index is hit, and return
// the pointer when it is
const char* sz = strings;
for(int i = 0;i<index;++i) {
sz = sz+strlen(sz)+1;
}
return sz;
}
static void draw_room(int index) {
draw::wait_all_async(dsp);
// clear the frame buffer
frame_buffer.fill(frame_buffer.bounds(), bg_color);
// get the room string
const char* sz = string_for_index(speaker_strings, index);
// and draw it. Note we are only drawing the text region
draw_center_text(sz);
srect16 bmp_rect(0,0,frame_buffer.dimensions().width-1,speaker_font_height-1);
bmp_rect.center_vertical_inplace((srect16)dsp.bounds());
bmp_rect.offset_inplace(0,23);
draw::bitmap_async(dsp,bmp_rect,frame_buffer,frame_buffer.bounds());
}
void setup() {
char *sz = (char*)malloc(0);
sz = strchr("",1);
// start everything up
Serial.begin(115200);
SPIFFS.begin();
dimmer.initialize();
button_1.initialize();
button_2.initialize();
// set the button callbacks
button_1.on_click(button_1_on_click);
button_2.on_click(button_2_on_click);
button_2.on_long_click(button_2_on_long_click);
// parse speakers.csv into speaker_strings
file = SPIFFS.open("/speakers.csv");
String s = file.readStringUntil(',');
size_t size = 0;
while(!s.isEmpty()) {
if(speaker_strings==nullptr) {
speaker_strings = (char*)malloc(s.length()+1);
if(speaker_strings==nullptr) {
Serial.println("Out of memory loading speakers (malloc)");
while(true);
}
} else {
speaker_strings = (char*)realloc(
speaker_strings,
size+s.length()+1);
if(speaker_strings==nullptr) {
Serial.println("Out of memory loading speakers");
while(true);
}
}
strcpy(speaker_strings+size,s.c_str());
size+=s.length()+1;
s = file.readStringUntil(',');
++speaker_count;
}
file.close();
// parse api.txt into our url format strings
size = 0;
file = SPIFFS.open("/api.txt");
s=file.readStringUntil('\n');
s.trim();
while(!s.isEmpty()) {
if(format_urls==nullptr) {
format_urls = (char*)malloc(s.length()+1);
if(format_urls==nullptr) {
Serial.println("Out of memory loading API urls (malloc)");
while(true);
}
} else {
format_urls = (char*)realloc(
format_urls,
size+s.length()+1);
if(format_urls==nullptr) {
Serial.println("Out of memory loading API urls");
while(true);
}
}
++format_url_count;
strcpy(format_urls+size,s.c_str());
size+=s.length()+1;
s = file.readStringUntil('\n');
s.trim();
}
file.close();
// parse wifi.txt
file = SPIFFS.open("/wifi.txt");
s = file.readStringUntil('\n');
s.trim();
strcpy(wifi_ssid,s.c_str());
s = file.readStringUntil('\n');
s.trim();
strcpy(wifi_pass,s.c_str());
file.close();
// when we sleep we store the last room
// so we can boot with it. it's written
// to a /state file so we see if it exists
// and if so, set the speaker_index to the
// contents
if(SPIFFS.exists("/state")) {
file = SPIFFS.open("/state","rb");
file.read(
(uint8_t*)&speaker_index,
sizeof(speaker_index));
file.close();
// in case /state is stale relative to speakers.csv:
if(speaker_index>=speaker_count) {
speaker_index = 0;
}
}
// initial connect
ensure_connected();
// draw logo to screen
draw::image(dsp,dsp.bounds(),&logo);
// clear the remainder
// split the remaining rect by the
// rect of the text area, and fill those
rect16 scrr = dsp.bounds().offset(0,47).crop(dsp.bounds());
rect16 tr(scrr.x1,0,scrr.x2,speaker_font_height-1);
tr.center_vertical_inplace(dsp.bounds());
tr.offset_inplace(0,23);
rect16 outr[4];
size_t rc = scrr.split(tr,4,outr);
// we're only drawing part of the screen
// we don't draw later
for(int i = 0;i<rc;++i) {
draw::filled_rectangle(dsp,outr[i],bg_color);
}
// initial draw
draw_room(speaker_index);
}
void loop() {
// pump all our objects
dimmer.update();
button_1.update();
button_2.update();
// if we're faded all the way, sleep
if(dimmer.faded()) {
// write the state
file = SPIFFS.open("/state","wb",true);
file.seek(0);
file.write((uint8_t*)&speaker_index,sizeof(speaker_index));
file.close();
dsp.sleep();
// make sure we can wake up on button_1
esp_sleep_enable_ext0_wakeup((gpio_num_t)button_1_t::pin,0);
// go to sleep
esp_deep_sleep_start();
}
}
基本阶段是:
- 唤醒或开机,连接到网络并加载任何状态。
- 等待按钮按下,并根据接收到的按键进行响应。
- 超时后睡眠,按下按钮 1 唤醒。
绘图本身有一些棘手之处。问题在于,我想避免重复写入同一个像素,以通过减少 SPI 通信来节省电池寿命。为此,我创建了一个小型帧缓冲区,其宽度与屏幕相同,高度与字体相同。这是显示屏上唯一会改变的部分。除此之外,在 setup()
中,我们加载 JPG,并绘制背景的所有部分,除了将要绘制文本的区域。这就是 split()
函数的用武之地。我们使用它在 JPG 下方的区域打出一个矩形空洞。该矩形的大小等于帧缓冲区——即屏幕上动态变化的部分。然后,我们只填充帧缓冲区之上和之下的矩形。这就是 split()
之后的循环所做的。
剩下的就是按钮处理代码。这些代码会构建要发送到 HTTP API 的 Web 请求,以运行命令。按下第二个按钮长按时,我们发送 api.txt 中的 URL 1(跳到下一曲),或者如果是一次或多次短按,我们根据 api.txt 的第二个和第三个条目跳到下一曲或上一曲。
请注意,我们从不取消分配内存。因为在 IoT 中我们不关闭设备——我们是断电,所以在传统的应用程序中用于清理的许多代码,对于这些小型平台来说通常是不必要的,因为没有操作系统可以返回,程序基本上永不结束。
历史
- 2022年12月5日
- 初始提交
- 2022年12月7日
- 更新以包含改进的用户界面
- 2022年12月9日
- 更新为使用新的按钮代码
- 清理。
- 添加了上一曲命令
- 2022年12月11日
- 更新为使用新的调光器库
- 使用部分帧缓冲区
- 2022年12月12日
- URL 编码房间
- api.txt 和点击次数可扩展
- 2022年12月13日
- 优化了绘图代码
- 延迟连接到 WiFi
- 使字体和字体大小选择更容易
- 2022年12月13日
- 修复了连接代码