У меня есть старый «плоскоэкранный» телевизор, произведённый в 2009 году. Он всё ещё жив потому, что я испытываю к нему странную ностальгическую любовь. А ещё потому, что я написал для него очень хорошо работающую автоматизацию, мигрировать с которой было бы трудно.

Однако у телевизора есть проблема: он очень часто попадает в резонанс со встроенными динамиками, из-за чего корпус устройства начинает достаточно сильно вибрировать и ужасно шуметь.

Чтобы продлить срок жизни этого реликта, я решил вложиться в дешманский саундбар Majority Snowdon II . Благодаря нему удалось решить проблему резонанса, но ей на смену пришли особенности саундбара. А именно, его тупость.

Да, это не смарт-устройство, несмотря на то, что его выпустили в 2022 году. У него есть инфракрасный пульт дистанционного управления, несколько физических кнопок, и на этом всё! Однако когда я покупал его, у меня был план. Я думал, что, наверно, смогу сделать его умнее. Поэтому когда пульт в 87-й раз завалился в щель дивана, я решил вскрыть саундбар и разобраться, что с ним можно сделать.

Начало

Изначально при вскрытии устройства я собирался поискать спецификации его чипов. Однако, когда я изучил материнскую плату, меня очень быстро озарило.

На плате есть два чётко размеченных соединительных разъёма, идущих к дочерней плате. И оказалось, что на дочерней плате находятся все варианты интерфейсов устройства: физические кнопки, светодиод индикатора и приёмник инфракрасного сигнала.

Давайте захватим эти готовые интерфейсы и используем в своих целях!

План атаки

Теперь, когда мы знаем, что проект вполне реализуем, давайте определимся с тем, как должен выглядеть успех. Мы хотим иметь возможность:

  • Получать текущее состояние Snowdon при помощи RGB-линий светодиодов состояния

  • Имитировать сигнал, поступающий по инфракрасной линии, чтобы можно было отправлять Snowdon собственные инфракрасные команды

А ещё мы хотим иметь контроль над новообретёнными сверхспособностями Snowdon. Я уже решил, что для этого проекта буду использовать Raspberry Pi Pico W; на то было несколько причин:

  1. В нём есть WiFi-чип, то есть можно превратить Snowdon в настоящее IoT-устройство

  2. У него есть программируемый ввод-вывод, то есть мы можем написать собственный драйвер для инфракрасных сигналов.

  3. Он может питаться от 5 В, то есть можно подать на него питание непосредственно с материнской платы

  4. В ящике стола у меня их целая куча.

Декодируем светодиод индикатора

На дочерней плате есть четырёхконтактный RGB-светодиод индикатора, способный отображать несколько цветов. Светодиод имеет общий анод, что означает активный низкий уровень управления. Каждый из трёх цветовых контактов выведен на соединительный разъём материнской платы. В руководстве пользователя перечислены все возможные цвета и их значения:

Состояние

Цвет светодиода

Питание отключено

Красный

Режим AUX

Белый

Режим Line In

Зелёный

Оптический режим

Жёлтый

Bluetooth готов

Мигающий синий

Bluetooth подключен

Синий

Этой информации достаточно, чтобы декодировать, какой цвет отображается на светодиоде, и из этого понять состояние.

Чтобы добиться этого, нужно всего лишь подключить к материнской плате три последовательных контакта GPIO.

Соединения

Код

Так как нас интересуют только три контакта (17,18 и 19), то можно создать 32-битную битовую маску, которая будет выбирать только эти контакты:

#define RGB_BASE_PIN 17
const uint32_t RGB_MASK = 1 << RGB_BASE_PIN |     // Контакт 17
                          1 << RGB_BASE_PIN + 1 | // Контакт 18
                          1 << RGB_BASE_PIN + 2;  // Контакт 19

Тогда в основном цикле можно при помощи этой маски инициализировать контакты:

gpio_init_mask(RGB_MASK);

А затем получать текущее состояние каждого из трёх контактов при помощи AND значения регистра GPIO с нашей маской. Затем нужно выполнить сдвиг вправо, чтобы избавиться от нулей, оставшихся от операции AND; в результате получится 3-битное значение, описывающее три цветовых линии:

// Извлекаем нужные биты из GPIO при помощи RGB_MASK и сдвига вправо
// Так мы получаем 3-битное значение в формате 0b<b><g><r>
uint32_t gpio = (gpio_get_all() & RGB_MASK) >> RGB_BASE_PIN;

Теперь наконец можно передать это значение в оператор switch и на основании значения трёх битов цвета выводить сообщения:

switch(gpio) {
    case 0b110: // красный
        printf("off\n");
        break;
    case 0b100: // жёлтый
        printf("optical\n");
        break;
    case 0b000: // белый
        printf("aux\n");
        break;
    case 0b101: // зелёный
        printf("line-in\n");
        break;
    case 0b011: // синий
        printf("bluetooth\n");
        break;
    case 0b111: // отключен
        printf("none\n");
        break;
    default:
        printf("unknown\n");
    }
Полный код
#include <stdio.h>
#include "pico/stdlib.h"

#define RGB_BASE_PIN 17
const uint32_t RGB_MASK = 1 << RGB_BASE_PIN |        // Контакт 17
                          1 << RGB_BASE_PIN + 1 |    // Контакт 18
                          1 << RGB_BASE_PIN + 2;     // Контакт 19

int main() {
    stdio_init_all();
    // Включаем контакты 17, 18 и 19 в качестве входов 
    gpio_init_mask(RGB_MASK);
    uint32_t gpio;
    while (true) {
        // Извлекаем нужные биты из GPIO при помощи RGB_MASK и сдвига вправо
        // Так мы получаем 3-битное значение в формате 0b<b><g><r>
        gpio = (gpio_get_all() & RGB_MASK) >> RGB_BASE_PIN;
        // Выполняем сравнения с тремя битами, чтобы определить состояние RGB-светодиода
        switch(gpio) {
            case 0b110: // красный
                printf("off\n");
                break;
            case 0b100: // жёлтый
                printf("optical\n");
                break;
            case 0b000: // белый
                printf("aux\n");
                break;
            case 0b101: // зелёный
                printf("line-in\n");
                break;
            case 0b011: // синий
                printf("bluetooth\n");
                break;
            case 0b111: // отключен
                printf("none\n");
                break;
            default:
                printf("unknown\n");
        }
        sleep_ms(500);
    }
    return 0;
}

Демо

Эта симуляция демонстрирует возможность декодирования цвета RGB-светодиода. Переключатели на макетной плате можно использовать для выставления значения RGB, которую наша программа затем будет декодировать по таймеру на 500 мс.

Имитируем инфракрасный пульт

Инфракрасный (IR) протокол, используемый в Snowdon и в большинстве потребительских товаров, называется NEC. При нажатии кнопки на пульте передаётся одно сообщение NEC, содержащее 32 бита (uint32_t) информации:

  • 8-битный адрес устройства

  • 8-битный адрес устройства (логическая инверсия)

  • 8-битная команда

  • 8-битная команда (логическая инверсия)

Полное сообщение выглядит так:

Оно состоит из следующих частей:

  • Начальный импульс низкого уровня длительностью 9 мс (562,5 мкс x 16)

  • Импульс высокого уровня длительностью 4,5 мс (562,5 мкс x 8)

  • 32 бита информации

  • Завершающий импульс низкого уровня длительностью 562,5 мкс

Каждый бит сообщения начинается с импульса низкого уровня длительностью 562,5 мкс, за которым следует:

  • если кодируемый бит имеет низкий уровень, то импульс высокого уровня в течение 562,5 мкс

  • если кодируемый бит имеет высокий уровень, то импульс высокого уровня в течение 1,6 мс (562,5 мкс x 3)

Стоит отметить, что при обычной передаче через светодиод у протокола NEC есть некоторые нюансы:

  • При передаче через светодиод сообщение инвертируется, что и видно на диаграмме

  • При передаче через светодиод сообщение модулируется 38-килогерцовой несущей волной

Мы можем закрыть на всё это глаза, потому что будем «заходить» не через инфракрасный светодиод, а напрямую подключим Pico к принимающей ИК-линии материнской платы Snowdon.

Теперь, когда мы немного разобрались в протоколе NEC, можно подключать Pico к ИК-линии Snowdon

Соединения

Резистор на 220 Ом необходим, чтобы обеспечить приоритет ИК-приёмопередатчика на дочерней плате; в противном случае ИК-коды, отправляемые пультом, могут теряться.

Код

Для обеспечения таймингов, требуемых для протокола, мы будем писать ассемблерную программу программируемого ввода-вывода (PIO), которая получает на входе 32-битный беззнаковый integer (uint32_t), транслирует его в сообщение формата NEC и передаёт его на GPIO 16.

PIO — довольно сложная тема, поэтому если это ваш первый опыт работы с ним, то рекомендую изучить следующие материалы:

Первым делом мы настроим для драйвера side set. Side set позволяет нам управлять пятью последовательными контактами, как побочный эффект инструкции PIO ASM. Нас интересует лишь единственный контакт GPIO с инфракрасными данными, поэтому мы объявим об этом компилятору при помощи метки:

.side_set 1

Side setting steals bits from the delay function in PIO. Stealing 1 bit for side setting, as we are doing, reduces this maximum delay value from 31 ticks to 15 ticks

Также в функции инициализации необходимо выполнить настройку, чтобы привязать переменную контакта (GPIO 16) с функцией side set:

sm_config_set_sideset_pins(&c, pin);

Ещё нужно выполнить настроечные функции, чтобы сделать контакт выводом и присвоить ему исходное значение:

pio_gpio_init(pio, pin);                                // Привязываем функцию контакта к GPIO
pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true);  // Устанавливаем направление контакта (вывод) 
pio_sm_set_pins_with_mask(pio, sm, 1u << pin, 1);       // Устанавливаем исходное значение контакта (1) (HIGH)
gpio_pull_up(pin);                                      // Устанавливаем значение контакта по умолчанию (1) (HIGH)

Далее нам нужно сконфигурировать таймер. Посмотрев на диаграмму таймингов, можно подумать, что стоит установить таймер примерно на 560 мкс, чтобы выполнение каждой команды PIO занимало те же 560 мкс. Однако, как будет понятно позже, нам потребуется гибкость для выполнения двух команд в окне примерно 560 мкс. Итак, таймер нужно настроить следующим образом:

// 2 такта за окно 560 мкс 
float div = clock_get_hz(clk_sys) / (2 * (1 / 562.5e-6f));
sm_config_set_clkdiv(&c, div);

Тело программы PIO выглядит так:

.wrap_target
    pull side 1
pulse_init:
    nop side 0 [15] 
    nop side 0 [15]         ; 9 мс вкл 
    nop side 1 [15]         ; задержка 4,5 мс
next:
    out y 1 side 0          ; Считываем следующий бит из OSR в y, side set устанавливается на LOW на 1 такт (280 мкс)
    jmp !y short side 0     ; If y == 0, goto short, side set устанавливается на LOW на 1 такт (280 мкс)
long:
    jmp bit_loop side 1 [4] ; Side set устанавливается на HIGH на 5 тактов (1400 мкс)
short:
    nop side 1              ; Side set устанавливается на HIGH на 1 такт (280 мкс)
bit_loop:
    jmp !osre next side 1   ; goto next, если osr не пустое, side set устанавливается на HIGH на 1 такт (280 мкс)
end_pulse:
    nop side 0 [1]          ; Side set устанавливается на LOW на 2 такта (560 мкс)
.wrap

Давайте разберём её, чтобы понять, как обеспечивается передача NEC.

pull side 1

Команда pull подтягивает в программу 32 бита в качестве входных данных. Этот вызов будет блокироваться, пока мы не передадим uint32_t для кодирования. Также мы:

  • Используем side set для переключения GPIO 16 на HIGH, он будет оставаться HIGH, пока заблокирована команда pull

pulse_init:
    nop side 0 [15] 
    nop side 0 [15]         ; 9 мс вкл 
    nop side 1 [15]         ; задержка 4,5 мс

После пользовательского ввода выполнение продолжается и мы переходим к метке pulse_init, где:

  • Выполняем команду nop, которая ничего не делает в течение одного такта.

  • Откладываем каждую nop на 15 тактов, чтобы каждая команда суммарно занимала 16 тактов.

  • При помощи side set переключаем GPIO 16 на LOW на 32 такта (9 мс), а затем на HIGH на 16 тактов (4,5 мс).

next:
    out y 1 side 0          ; Считываем следующий бит из OSR в y, при помощи side set устанавливаем LOW на 1 такт (280 мкс)
    jmp !y short side 0     ; Если y == 0, переходим к short,  при помощи side set устанавливаем LOW на 1 такт (280 мкс)

Мы проваливаемся к метке next, где:

  • Извлекаем один бит из входных данных в регистр y.

  • Выполняем условный переход; если бит имеет значение 0, то переходим к метке short.

  • При помощи side set устанавливаем LOW для обеих команд, обеспечивая исходный импульс LOW для первого бита.

long:
    jmp bit_loop side 1 [4] ; При помощи side set устанавливаем HIGH на 5 тактов (1400 мкс)

Если условный переход не выполняется, то мы проваливаемся к метке long, где:

  • Выполняем безусловный переход к метке bit_loop.

  • При помощи side set устанавливаем HIGH с задержкой в 4 такта, суммарно получая 5 тактов

short:
    nop side 1              ; При помощи side set устанавливаем HIGH на 1 такт (280 мкс)

В противном случае, мы выполняем условный переход к метке short, где:

  • Не делаем ничего (nop). Благодаря размещению меток мы можем просто провалиться к метке bit_loop

  • При помощи side set устанавливаем HIGH на GPIO 16 на один такт

bit_loop:
    jmp !osre next side 1   ; если osr не пустой, переходим к next, при помощи side set устанавливаем HIGH на 1 такт (280 мкс)

Вне зависимости от пути ветвления мы оказываемся на метке bit_loop, где:

  • Выполняем условный переход к метке next, если ещё остаются входные биты для транскодирования

  • При помощи side set устанавливаем HIGH на GPIO 16 на один такт. Это значит, что мы устанавливали для GPIO HIGH на 6 тактов, если добрались сюда через long, или на 2 такта, если через short!

А затем начинаем снова с next, пока не обработаем все 32 бита:

end_pulse:
    nop side 0 [1]          ; При помощи side set устанавливаем LOW на 2 такта (560 мкс)

После исчерпания всех битов мы наконец переходим к метке end_pulse, где:

  • Ничего не делаем в течение одного такта

  • При помощи side set устанавливаем LOW на GPIO 16 на 2 такта, обеспечивая завершающий импульс

  • Возвращаемся к первой команде pull, чтобы ждать следующих входных данных

Полный код
; Реализация инвертированного инфракрасного протокола NEC БЕЗ несущего сигнала
; для использования в проводном соединении с инфракрасной линией. Каждая команда занимает 280 мкс
.program nec
.side_set 1
.wrap_target
    pull side 1
pulse_init:
    nop side 0 [15] 
    nop side 0 [15]         ; включено 9 мк 
    nop side 1 [15]         ; задержка 4,5 мс
next:
    out y 1 side 0          ; Считываем следующий бит из OSR в y, при помощи side set устанавливаем LOW на 1 такт (280 мкс)
    jmp !y short side 0     ; Если y == 0, переходим к short, при помощи side set устанавливаем LOW на 1 такт (280 мкс)
long:
    jmp bit_loop side 1 [4] ; При помощи side set устанавливаем HIGH на 5 тактов (1400 мкс)
short:
    nop side 1              ; При помощи side set устанавливаем HIGH на 1 такт (280 мкс)
bit_loop:
    jmp !osre next side 1   ; переходим к next, если osr не пустой, при помощи side set устанавливаем HIGH на 1 такт (280 мкс)
end_pulse:
    nop side 0 [1]          ; При помощи side set устанавливаем LOW на 2 такта (560 мкс)
.wrap

% c-sdk {
#include "hardware/clocks.h"
static inline void nec_transmit_program_init(PIO pio, uint sm, uint offset, uint pin) {
    pio_sm_config c = nec_program_get_default_config(offset);
    sm_config_set_sideset_pins(&c, pin);

    pio_gpio_init(pio, pin);
    pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true);
    pio_sm_set_pins_with_mask(pio, sm, 1u << pin, 1);
    gpio_pull_up(pin);
    
    sm_config_set_out_shift(&c, true, false, 32);
    
    // 2 такта на окно 560 мкс 
    float div = clock_get_hz(clk_sys) / (2 * (1 / 562.5e-6f));
    sm_config_set_clkdiv(&c, div);

    // Инициируем конечный автомат pio с PC на offset
    pio_sm_init(pio, sm, offset, &c);
    // Запускаем sm
    pio_sm_set_enabled(pio, sm, true);
}
%}

Демо

Эта симуляция демонстрирует способность драйвера имитировать инфракрасный пульт дистанционного управления. Когда переключатель повёрнут налево, он принимает ввод непосредственно от пульта через ИК-приёмник.

Когда переключатель повёрнут вправо, программа PIO каждые 500 мс отправляет по GPIO 16 случайный код пульта:

Код
... 
#define TX_PIN 16
...
uint32_t remote_codes[] = {
    0x5da2ff00,  //POWER                                                            
    0xdd22ff00,  //TEST                                                             
    0xfd02ff00,  //PLUS                                                             
    0x3dc2ff00,  //BACK                                                             
    0x1de2ff00,  //MENU
    0x6f90ff00,  //NEXT
    0x57a8ff00,  //PLAY
    0x1fe0ff00,  //PREV
    0x9768ff00,  //0
    0x6798ff00,  //MINUS
    0x4fb0ff00,  //C
    0x857aff00,  //3
    0xe718ff00,  //2
    0xcf30ff00,  //1
    0xef10ff00,  //4
    0xc738ff00,  //5
    0xa55aff00,  //6
    0xad52ff00,  //9
    0xb54aff00,  //8
    0xbd42ff00,  //7
};
...
while (true) {
    pio_sm_put_blocking(PIO_INSTANCE, tx_sm, 
                        remote_codes[rand() % ARRAY_SIZE(remote_codes)]);
...
    sleep_ms(500);
}

Создаём с нуля тупой HTTP-сервер

Когда я начинал этот проект (в сентябре 2022 года), мне не удалось найти онлайн примеров HTTP-сервера, использующего C SDK для Pico W. Однако из прошлого опыта работы с ESP32 я знал, что микроконтроллер может быть очень тупым, и при этом общаться по HTTP, так что давайте развернём собственный HTTP-сервер!

Я реализовал его, переделав пример TCP-сервера из официального репозитория pico-examples на Github.

Что такое вообще HTTP?

В своей основе Hypertext Transfer Protocol (HTTP) — это просто TCP-сокет со сложными строками.
Есть отличный вводный курс @fasterthanlime, который я рекомендую прочитать.

Как же нам отправлять эти сложные строки?

Давайте отправим стандартный HTTP-запрос PUT при помощи инструмента командной строки curl, и посмотрим, как это выглядит:

curl -X PUT http://api.int/api/v1.0/pc\?code\=status

Ответ:

{"status": "on"}

Если вытащить запрос при помощи wireshark, то можно увидеть, что полезная нагрузка TCP содержит следующую информацию:

PUT /api/v1.0/pc?code=status HTTP/1.1\r\n
Host: api.int\r\n
User-Agent: curl/7.68.0\r\n
Accept: */*\r\n
\r\n

Давайте разберём её. В первой строке указано следующее:

Параметр

Значение

Описание

Method

GET

Метод вызова, обычно используются GET, POST, PUT, HEAD

Target

/api/v1.0/pc?code=status

Указывает подпуть вызова, позволяет выполнять маршрутизацию на HTTP-сервере, который хостит множество разных конечных точек. В этой строке также можно указать пары «ключ-значение»

Version

HTTP/1.1

Это версия HTTP вызова, в нашем случае это всегда будет HTTP/1.1

  • Конец каждой строки обозначен возвратом каретки \r и символом перевода строки \n.

  • Каждая строка после первой содержит переменную заголовка в виде пары «ключ-значение».

  • Последняя строка содержит \r\n только для того, чтобы обозначить конец данных

Данные ответа выглядят так:

HTTP/1.1 200 OK\r\n
Content-Length: 17\r\n
Content-Type: application/json\r\n
Date: Wed, 30 Nov 2022 10:40:24 GMT\r\n
Server: waitress\r\n
\r\n
{"status": "on"}

Они имеют очень похожий формат. В первой строке указано следующее:

Параметр

Значение

Описание

Version

HTTP/1.1

Это версия HTTP для вызова, в нашем случае она всегда будет HTTP/1.1

Status Code

200

Числовое представление статуса ответа; должно быть задокументированным валидным статусом HTTP 

Status Text

OK

Текстовое представление статуса ответа, должно соответствовать коду статуса

Каждая последующая строка — это тоже переменная заголовка «ключ-значение». Однако стоит отметить, что HTTP-сервер выбрал отправлять JSON-ответ.

Чтобы реализовать это, ответ должен содержать заголовок Content-Length, сообщающий клиенту, сколько данных он собирается отправить. После этого он прикрепляет JSON-строку после последней строки \r\n тела HTTP.

Эта концепция создания заголовка Content-Length относится и к клиенту, если мы хотим отправлять в ответе JSON.

На самом деле, этот протокол достаточно прост для того, чтобы мы могли просто разобрать HTTP-запрос и собирать ответы при помощи простых манипуляций со строками. Чтобы говорить на этом языке, нам даже не нужно понимать ничего в HTTP и JSON!

Код

Первое (и самое сложное), что нам нужно сделать в нашем маленьком HTTP-сервере — это обрабатывать сообщения HTTP-запросов.

Это можно реализовать, разбивая строку на её отдельные токены. Для этого мы используем строковую функцию strpbrk.

На странице man функции strpbrk говорится следующее:

СИНОПСИС
       #include <string.h>

       char *strpbrk(const char *s, const char *accept);

ОПИСАНИЕ
       Функция strpbrk() находит в строке s первое вхождение
       любых байтов строки accept.

ВОЗВРАЩАЕМОЕ ЗНАЧЕНИЕ
       Функция strpbrk() возвращает указатель на байт в s, соответствующий
       одному из байтов в accept, или NULL, если такой байт не найден.

То есть эта функция получает строку и множество разделителей, выводя указатель на первый разделитель, который она встретит в строке.

В первой строке HTTP-действительно есть множество односимвольных разделителей, обозначающих определённые части строки:

PUT /api/v1.0/pc?code=status HTTP/1.1\r\n

То есть мы можем извлечь токен, выполнив последовательность шагов.

Определим наше сообщение в строке (char *) и создадим указатель на первый символ:

char http_message[] = {
    "PUT /api/v1.0/pc?code=status HTTP/1.1\r\n"
    "Host: api.int\r\n"
    "User-Agent: curl/7.68.0\r\n"
    "Accept: */*\r\n"
    "\r\n"
};
char *p1 = http_message;

Вызовем функцию strpbrk, чтобы получить указатель на первое вхождение одного из наших разделителей:

char *p2 = strpbrk(p1, " ?=\r");

Теперь давайте завершим нулём p2. Поместив \0 в позицию, отмеченную p2, мы преобразуем разделитель в последний символ строки:

*p2 = '\0';

Если теперь вывести p1, то вывод будет выполнен до первого \0, по сути, превращая его в первый токен:

printf("%s\n", p1);
> PUT

Мы можем усовершенствовать эту методику, чтобы итеративно обходить каждый токен в первой строке HTTP-запроса, а отслеживая разделители, мы можем точно понимать, какой токен извлекаем.

Полный код
#include <string.h>
#include <stdio.h>


int main() {
  char http_message[] = {
    "PUT /api/v1.0/pc?other=monkey&code=status HTTP/1.1\r\n"
    "Host: api.int\r\n"
    "User-Agent: curl/7.68.0\r\n"
    "Accept: */*\r\n"
    "\r\n"
  };

  // Обрабатываем тело HTTP-сообщения, пример: "PUT /api/v1.0/pc?code=status HTTP/1.1"
  // char *message_body = http_message;
  char *message_body = http_message;
  char *delim = " ?=&\r";
  char next_delim;
  char current_delim = '\0';
  char *token;

  while(1) {
    if (*message_body == '\0') { break; }
    token = message_body;
    message_body = strpbrk(message_body, delim);
    if (message_body == NULL) { break; }
    next_delim = *message_body;
    *message_body = '\0';

    switch(current_delim) {
      case '\0':
        printf("Method\t\t%s\n", token);
        break;
      case ' ':
        if (next_delim != '\r') {
          printf("Target\t\t%s\n", token);
        } else {
          printf("Version\t\t%s\n", token);
        }
        break;
      case '?':
      case '&':
        printf("Key\t\t%s\n", token);
        break;
      case '=':
        printf("Value\t\t%s\n", token);
        break;
    }
    current_delim = next_delim;
    message_body++;
    // Возврат каретки указывает на то, что мы достигли конца первой строки тела сообщения
    if (current_delim == '\r') { break; }
  }

  return 0;
}

В готовой программе мы можем сделать ещё один шаг и использовать ту же методику для обхода JSON, как двухмерного файла, если он окажется частью HTTP-запроса.

Демо

Дополнительно: задействуем неиспользуемые контакты для SWD-отладки

Снова взглянув на материнскую плату, мы можем увидеть четырёхконтактный разъём USB с неиспользуемыми контактами:

Я проверил оба контакта NONE осциллографом и определил, что при обычной работе на этих контактах нет активности. Оказалось, что на панели сзади вырезано отверстие для разъёма USB, закрытое тонким слоем майларной ленты:

Поэтому я решил подключить к неиспользуемым контактам контакты SWD-отладки Pico, чтобы можно было выполнять отладку без необходимости повторного вскрытия устройства:

Я крайне рекомендую НЕ делать этого, потому при использовании этих контактов что я умудрился сжечь SWD-контакты одного из моих отладчиков

Соединяем всё вместе

В процессе реализации этого проекта я воспользовался интересными возможностями Pico. Теперь мы можем:

  • Получать текущее состояние Snowdon через RGB-светодиод

  • Отправлять закодированные NEC команды на Snowdon

  • Декодировать произвольные HTTP-запросы

Чтобы объединить всё это, достаточно добавить немного клеевого кода, транслирующего HTTP-запрос в вызов драйвера PIO или декодера светодиода.

В программе это реализуется извлечением значения из одного из пользовательских параметров, переданных в HTTP-запросе (code), отправив их во вспомогательную функцию. Эта функция транслирует переданную пользователем строку в 32-битный код NEC для драйвера PIO или в команду HTTP_CODE_LOOKUP_STATUS для декодера светодиода:

static uint32_t http_code_lookup(char *code) {
    if (!strcmp(code, "status"))        { return HTTP_CODE_LOOKUP_STATUS; }
    if (!strcmp(code, "power"))         {return 0x807F807F;}
    if (!strcmp(code, "input"))         {return 0x807F40BF;}
    if (!strcmp(code, "mute"))          { return 0x807FCC33; }
    if (!strcmp(code, "volume_up"))     { return 0x807FC03F; }
    if (!strcmp(code, "volume_down"))   { return 0x807F10EF; }
    if (!strcmp(code, "previous"))      { return 0x807FA05F; }
    if (!strcmp(code, "next"))          { return 0x807F609F; }
    if (!strcmp(code, "play_pause"))    { return 0x807FE01F; }
    if (!strcmp(code, "treble_up"))     { return 0x807FA45B; }
    if (!strcmp(code, "treble_down"))   { return 0x807FE41B; }
    if (!strcmp(code, "bass_up"))       { return 0x807F20DF; }
    if (!strcmp(code, "bass_down"))     { return 0x807F649B; }
    if (!strcmp(code, "pair"))          { return 0x807F906F; }
    if (!strcmp(code, "flat"))          { return 0x807F48B7; }
    if (!strcmp(code, "music"))         { return 0x807F946B; }
    if (!strcmp(code, "dialog"))        { return 0x807F54AB; }
    if (!strcmp(code, "movie"))         { return 0x807F14EB; }
    return HTTP_CODE_LOOKUP_UNKNOWN_VALUE;
}

Поэтому мы просто можем выполнить в программе соответствующий вызов и вернуть в ответе HTTP красивую JSON-строку:

if (state->message_body->code == HTTP_CODE_LOOKUP_STATUS) {
    uint32_t gpio;
    do{
        gpio = (gpio_get_all() & RGB_MASK) >> RGB_BASE_PIN;
        switch(gpio) {
            case 0b110: // красный
                http_generate_response(arg, "{\"onoff\": \"off\", \"input\": \"off\"}\n", "200 OK");
                break;
            case 0b100: // жёлтый
                http_generate_response(arg, "{\"onoff\": \"on\", \"input\": \"optical\"}\n", "200 OK");
                break;
            case 0b000: // белый
                http_generate_response(arg, "{\"onoff\": \"on\", \"input\": \"aux\"}\n", "200 OK");
                break;
            case 0b101: // зелёный
                http_generate_response(arg, "{\"onoff\": \"on\", \"input\": \"line-in\"}\n", "200 OK");
                break;
            case 0b011: // синий
                http_generate_response(arg, "{\"onoff\": \"on\", \"input\": \"bluetooth\"}\n", "200 OK");
                break;
            case 0b111: // отключено (скорее всего, переходное состояние)
                busy_wait_ms(50);
                continue;
        }
    } while (gpio == 0b111);
    return;
}
if (state->message_body->code > HTTP_CODE_LOOKUP_NO_VALUE) {
    pio_sm_put_blocking(PIO_INSTANCE, 0, state->message_body->code);
    http_generate_response(arg, "{\"status\": \"ok\"}\n", "200 OK");
}

По сути, это всё, что нужно нам для развёртывания собственного RESTful API. Разобравшись с этим, осталось подключить всё и скомпилировать!

Подключение

Для обычной работы SWDIO и SWCLK не требуются, они раскрывают контакты SWD-отладки неиспользуемого разъёма USB для удобства доступа

Инструкция по компиляции

Все подробности о настройке среды сборки см. в официальном руководстве Getting Started

Так как в ПО содержатся жёстко прописанные учётные данные WiFi, я не могу поделиться готовым uf2. Поэтому нужно установить Pico SDK и собрать его с нуля.

Устанавливаем зависимости для SDK:

sudo apt update
sudo apt install git cmake gcc-arm-none-eabi libnewlib-arm-none-eabi build-essential

Клонируем SDK:

cd ~/
git clone https://github.com/raspberrypi/pico-sdk.git
cd pico-sdk
git submodule update --init

Экспортируем переменную PICO_SDK_PATH:

export PICO_SDK_PATH=$HOME/pico-sdk

Клонируем Snowdon-II-WiFi:

git clone https://github.com/kennedn/snowdon-ii-wifi.git

Создаём папку сборки и конфигурируем cmake с учётными данными WiFi:

cd snowdon-ii-wifi
mkdir build
cd build
# Заменить <SSID> и <PASSWORD> собственными значениями
cmake -DPICO_BOARD=pico_w -DWIFI_SSID="<SSID>" -DWIFI_PASSWORD="<PASSWORD>" ..

Компилируем программу:

cd src
make

Если всё сделано правильно, то в ~/snowdon-ii-wifi/build/src/ должен появиться файл snowdon.uf2.

Теперь можно подключить Pico через USB, удерживая кнопку BOOTSEL, и перенести файл uf2 в точку подключения тома.

API

RESTful API открыт на порту 8080:

http://<ip_address>:8080

Конечная точка ожидает единственный параметр code, который можно отправить или через кодировку URL, или через тело JSON запроса, например:

curl -X PUT http://192.168.1.238:8080?code=status
# или
curl -X PUT http://192.168.1.238:8080 -H 'Content-Type: application/json' -d '{"code": "power"}'

А вот полный список возможных значений code:

Значение

Описание

JSON-ответ

power

Инфракрасный код

{"status": "ok"}

input

Инфракрасный код

{"status": "ok"}

mute

Инфракрасный код

{"status": "ok"}

volume_up

Инфракрасный код

{"status": "ok"}

volume_down

Инфракрасный код

{"status": "ok"}

previous

Инфракрасный код

{"status": "ok"}

next

Инфракрасный код

{"status": "ok"}

play_pause

Инфракрасный код

{"status": "ok"}

treble_up

Инфракрасный код

{"status": "ok"}

treble_down

Инфракрасный код

{"status": "ok"}

bass_up

Инфракрасный код

{"status": "ok"}

bass_down

Инфракрасный код

{"status": "ok"}

pair

Инфракрасный код

{"status": "ok"}

flat

Инфракрасный код

{"status": "ok"}

music

Инфракрасный код

{"status": "ok"}

dialog

Инфракрасный код

{"status": "ok"}

movie

Инфракрасный код

{"status": "ok"}

status

Запрос RGB-светодиода

{"onoff": power_state, "input": input_state}

power_state имеет следующие возможные значения:

Значение

on

off

input_state имеет следующие возможные значения:

Значение

off

optical

aux

line-in

bluetooth

Завершение

Благодаря всему этому мы теперь можем отправлять на Pico HTTP-команды:

И это большая победа, потому что теперь мой пульт дистанционного управления может спокойно отправляться в приключения по глубинам дивана, а я по-прежнему смогу включать саундбар:

Комментарии (8)


  1. PerroSalchicha
    30.10.2025 05:33

    Вот настоящий, правильный инженер: реверс-инжиниринг, изучение девайса, чертежи, схемы, новое железо, написание прошивки, и только в самом финальном видосе становится понятно, что это всё для того, чтобы включать саундбар с часов :)


    1. norguhtar
      30.10.2025 05:33

      А можно было просто купить инфракрасный пульт с wifi


  1. Zara6502
    30.10.2025 05:33

    удивлён что в общей схеме нет ядерного реактора )


  1. alex_mtb
    30.10.2025 05:33

    Мое почтение, как говорится)


  1. iamkisly
    30.10.2025 05:33

    По моему PicoW это оверкилл, и тут хватило бы и esp8266


    1. netmaniac
      30.10.2025 05:33

      Да и возможно в телеке уже есть hdmi cec, и он спокойно сам может включать саундбар, и переключая его на нужный вход.
      Но ставить пику чтобы "просто моргать светодиодами" даже с таким реверсом - жесть


  1. NightBlade74
    30.10.2025 05:33

    А что произойдет, если команда от контроллера придет одновременно с сигналом от родного пульта?


  1. Jack007
    30.10.2025 05:33

    Мы не ищем легких путей - статья-супер))) Но я бы для начала вскрыл корпус телевизора и попробовал бы задемпфировать его динамики или изменить частоту резонанса путем подклейки на стенки корпуса изнутри вибромастики/звукопоглотителя и т.д. (зависит от конструкции ТВ) Так было у меня на стареньком Филипсе - бухтел на низах. Теперь -звук отличный ;-)