65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (8投票s)

2022年12月5日

MIT

5分钟阅读

viewsIcon

25157

downloadIcon

125

控制您家中每个房间的 Sonos 系统的声音。

Sonos Remote

引言

我有一个朋友住在世界的另一端,他主要不是程序员,需要我在软件方面帮助他完成他的 Sonos 音响系统 遥控器。硬件方面,我推荐他使用 TTGO T-Display v1,因为它们相对便宜,并且将所有必要的硬件集成在一个设备中,无需额外接线,还可以使用 LIPO 电池供电。

免责声明:我需要警告您,我没有 Sonos 音响系统。在朋友的帮助下,我在他的设置上测试了这段代码,他向我保证它能正常工作。由于我没有这样的系统,如果读者您在项目的这个方面遇到问题,我将无法提供帮助。

更新:已清理代码。代码使用我新的扩展按钮。代码采用双缓冲以避免闪烁。

更新 2:已进一步清理代码。代码使用我新-新的扩展按钮。代码现在包含上一曲功能。

更新 3:代码使用更新的调光器库。帧缓冲区现在是部分的,只重绘屏幕底部。

更新 4:现在 api.txt 的第一个 URL 用于长按,接下来的每个 URL 用于连续短按次数。URL 也已进行 URL 编码。

更新 5:优化了代码,减少了 SPI 流量以节省电池寿命。现在仅在需要时连接,而不是在启动时。使字体和文本大小选择更加模块化。

更新 5:修复了连接代码。

必备组件

您需要安装了 PlatformIOVS Code

您需要一个 Sonos 音响系统

您需要安装并配置网络上的 Node Sonos HTTP API

您需要一个 TTGO T-Display v1

使用这个烂摊子

下载项目后,您需要编辑 /data 文件夹下的几个文件。

  1. 编辑 speakers.csv 以反映您使用的音响或房间。这些名称必须与 Sonos 系统识别的名称相同。
  2. 使用您的 SSID 和密码编辑 wifi.txt
  3. 使用您的 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. 唤醒或开机,连接到网络并加载任何状态。
  2. 等待按钮按下,并根据接收到的按键进行响应。
  3. 超时后睡眠,按下按钮 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日
    • 修复了连接代码
© . All rights reserved.