The Introduction
Another in the series “using AI to quickly build things to solve small problems”.
The Problem
I have some JVC speakers (one pictured above) connected to my PC. They have an amplifier built in and an infrared remote control to switch them on. To save energy, they switch off when they have not been used for a while. The infrared remote control gets used a lot and, as well as consuming expensive CR2032 batteries, it fails to work 50% of the time. I needed a ‘simple’ solution to replace the control.
The Solution
I decided to make a gadget that I can command from my PC to switch the speakers on. The gadget therefore needs an interface to the PC and an infrared transmitter. I have a Raspberry Pi Pico 2 W to hand, so I can use Wi-Fi to connect to my IoT network. I built a circuit on a breadboard to detect and emit infrared signals.
The Circuit
Infrared is a simple and mature technology, so this part is not particularly difficult. To receive infrared, a Vishay TSOP38238 IR receiver is connected directly to an input port on the Pico. It is powered from the 3.3V supply of the Pico with a 100Ω resistor in series. A 0.1µF capacitor to GND helps with filtering.
To transmit, a Vishay TSAL6200 infrared LED is powered from the 5V supply of the Pico and is driven from an output port on the Pico via a 2N2222 transistor. A 33Ω resistor limits the current through the LED to close to its maximum continuous rating of 100mA. A 1kΩ resistor limits the transistor’s base current to about 2.6mA.

Circuit Diagram

Circuit Built on a Breadboard
The Software
The Raspberry Pi Pico 2 W can be programmed in MicroPython, CircuitPython, C, or C++. I am not a Python fan, so I chose to use C++. The unofficial arduino-pico core has the dual advantages of being somewhat easier to use than the official framework and has good support for the Arduino IRremote library .
I initially used the IRremote library to capture the on/off signal from the original IR remote. This is the code to capture:
// IR receiver test: TSOP38238 on GP21. Prints each received code to serial
// (115200 baud). The onboard LED flickers whenever IR is being received.
// Unknown protocols (e.g. air-con remotes) are also dumped as raw timings.
#define RAW_BUFFER_LENGTH 750 // room for long air-con frames
#include <IRremote.hpp>
const uint8_t IR_RECEIVE_PIN = 21;
const uint8_t IR_SEND_PIN = 22;
void setup() {
pinMode(IR_SEND_PIN, OUTPUT); // keep the transmitter LED off
digitalWrite(IR_SEND_PIN, LOW);
Serial.begin(115200);
while (!Serial && millis() < 3000) {}
IrReceiver.begin(IR_RECEIVE_PIN, ENABLE_LED_FEEDBACK);
Serial.println("IR receive test on GP21 - point a remote at the receiver and press buttons");
}
void loop() {
if (!IrReceiver.decode()) return;
Serial.println();
IrReceiver.printIRResultShort(&Serial);
if (IrReceiver.decodedIRData.flags & IRDATA_FLAGS_WAS_OVERFLOW) {
Serial.println("Overflow: frame longer than RAW_BUFFER_LENGTH");
} else if (IrReceiver.decodedIRData.protocol == UNKNOWN) {
Serial.println("Unknown protocol, raw timings (us):");
IrReceiver.printIRResultRawFormatted(&Serial, true);
} else {
IrReceiver.printIRSendUsage(&Serial);
}
IrReceiver.resume();
}Protocol=NEC Address=0xBD02, Command=0x53, Raw-Data=0xAC53BD02, 32 bits, LSB first, Gap=3276750us, Duration=67100us
Send with: IrSender.sendNEC(0xBD02, 0x53, <numberOfRepeats>);
Protocol=NEC Address=0xBD02, Command=0x53, Repeat, Gap=40900us, Duration=11850us
Protocol=NEC Address=0xBD02, Command=0x53, Repeat, Gap=96050us, Duration=11900us
I tested that the captured IR command actually worked using the transmitter, and then it was time to put the whole project together. The Pico connects to my IoT network and runs a (very) simple web server. I can send an HTTP POST request to the Pico to trigger the IR playback. That’s it! Code below:
// IR web remote for the Pico 2 W. Joins WiFi and serves a small REST API that
// replays stored IR codes from the IR LED on GP22.
//
// GET / Browser page with a button per code
// GET /api/status Network and device status
// GET /api/codes List stored codes
// POST /api/codes/{name}/send Send a code (optional ?repeats=N)
// POST /api/learn?name={name} Learn a code from the receiver on GP21
// (returns 501 while IR_LEARN_ENABLED is 0)
//
// The same actions are available as line commands on USB serial (115200):
// status | list | send <name> | learn <name>
// Pressing BOOTSEL sends the first code in the table.
//
// Learned codes are held in RAM and lost on reset.
#define IR_LEARN_ENABLED 0 // set to 1 to enable the receiver and /api/learn
#if IR_LEARN_ENABLED
#define RAW_BUFFER_LENGTH 750 // room for long air-con frames
#define USE_16_BIT_TIMING_BUFFER // keep raw timings above 12.75 ms exact
#endif
#define NO_LED_FEEDBACK_CODE // the onboard LED is on the WiFi chip, too slow for IR feedback
#include <IRremote.hpp>
#include <WiFi.h>
#include <WebServer.h>
#include <uri/UriBraces.h>
#include <SimpleMDNS.h>
#include <vector>
#include "secrets.h"
const char *HOSTNAME = "irmogging";
const uint8_t IR_RECEIVE_PIN = 21;
const uint8_t IR_SEND_PIN = 22;
const uint32_t LEARN_TIMEOUT_MS = 10000;
const uint32_t WIFI_RETRY_MS = 30000;
const int MAX_REPEATS = 20;
// One stored IR code. Codes in a protocol IRremote recognises are sent from
// protocol/address/command; UNKNOWN codes are replayed from raw timings.
struct IrCode {
String name; // letters, digits, '-' and '_' only (used in URLs)
decode_type_t protocol;
uint16_t address;
uint16_t command;
int_fast8_t repeats; // extra "button held" frames
std::vector<uint16_t> raw; // mark/space durations in us, for UNKNOWN only
uint8_t rawKHz;
};
std::vector<IrCode> codes = {
{"default", NEC, 0xBD02, 0x53, 0, {}, 38},
};
WebServer server(80);
bool wifiConnected = false;
bool mdnsStarted = false;
// ---------------------------------------------------------------------------
// Code table
bool isValidName(const String &name) {
if (name.length() == 0 || name.length() > 32) return false;
for (char c : name) {
if (!isalnum(c) && c != '-' && c != '_') return false;
}
return true;
}
IrCode *findCode(const String &name) {
for (auto &code : codes) {
if (code.name == name) return &code;
}
return nullptr;
}
void storeCode(const IrCode &code) {
IrCode *existing = findCode(code.name);
if (existing) *existing = code;
else codes.push_back(code);
}
String hex4(uint16_t value) {
char buf[8];
snprintf(buf, sizeof(buf), "0x%04X", value);
return buf;
}
String codeToJson(const IrCode &code) {
String json = "{\"name\":\"" + code.name + "\",\"protocol\":\"" + String(getProtocolString(code.protocol)) + "\"";
if (code.protocol == UNKNOWN) {
json += ",\"raw_length\":" + String(code.raw.size()) + ",\"khz\":" + String(code.rawKHz);
} else {
json += ",\"address\":\"" + hex4(code.address) + "\",\"command\":\"" + hex4(code.command) + "\"";
}
json += ",\"repeats\":" + String(code.repeats) + "}";
return json;
}
String codesToJson() {
String json = "[";
for (size_t i = 0; i < codes.size(); i++) {
if (i) json += ",";
json += codeToJson(codes[i]);
}
return json + "]";
}
String statusToJson() {
return "{\"hostname\":\"" + String(HOSTNAME) + "\",\"ip\":\"" + WiFi.localIP().toString() +
"\",\"wifi_connected\":" + (wifiConnected ? "true" : "false") +
",\"rssi\":" + String(WiFi.RSSI()) + ",\"uptime_ms\":" + String(millis()) +
",\"learn_enabled\":" + (IR_LEARN_ENABLED ? "true" : "false") +
",\"codes\":" + String(codes.size()) + "}";
}
// ---------------------------------------------------------------------------
// IR send and learn
bool sendCode(const IrCode &code, int_fast8_t repeats) {
digitalWrite(LED_BUILTIN, LOW); // brief LED blink marks each send
bool sent = true;
if (code.protocol == UNKNOWN) {
for (int i = 0; i <= repeats; i++) {
if (i) delay(40);
IrSender.sendRaw(code.raw.data(), code.raw.size(), code.rawKHz);
}
} else {
sent = IrSender.write(code.protocol, code.address, code.command, repeats) > 0;
}
digitalWrite(LED_BUILTIN, wifiConnected ? HIGH : LOW);
Serial.printf("%s %s (repeats %d)\r\n", sent ? "Sent" : "Cannot send", code.name.c_str(), repeats);
return sent;
}
#if IR_LEARN_ENABLED
// Waits for one IR frame (ignoring repeat frames and noise) and fills `out`.
bool learnCode(const String &name, uint32_t timeoutMs, IrCode &out) {
Serial.printf("Learning '%s': press a remote button within %lu s\r\n", name.c_str(), timeoutMs / 1000);
IrReceiver.start();
bool learned = false;
uint32_t start = millis();
while (!learned && millis() - start < timeoutMs) {
if (!IrReceiver.decode()) {
delay(1);
continue;
}
IRData &data = IrReceiver.decodedIRData;
auto ¶ms = IrReceiver.irparams;
bool ignore = (data.flags & (IRDATA_FLAGS_IS_REPEAT | IRDATA_FLAGS_WAS_OVERFLOW)) ||
(data.protocol == UNKNOWN && params.rawlen < 8);
if (!ignore) {
out = {name, data.protocol, data.address, data.command, 0, {}, 38};
if (data.protocol == UNKNOWN) {
// rawbuf[0] is the gap before the frame; odd entries are marks
for (IRRawlenType i = 1; i < params.rawlen; i++) {
uint32_t us = params.rawbuf[i] * MICROS_PER_TICK;
out.raw.push_back((i & 1) ? us - MARK_EXCESS_MICROS : us + MARK_EXCESS_MICROS);
}
}
learned = true;
}
IrReceiver.resume();
}
IrReceiver.stop();
Serial.println(learned ? "Learned " + codeToJson(out) : String("No IR code received"));
return learned;
}
#endif
// ---------------------------------------------------------------------------
// HTTP handlers
void sendJson(int status, const String &json) {
server.send(status, "application/json", json);
}
void sendError(int status, const String &message) {
sendJson(status, "{\"error\":\"" + message + "\"}");
}
void handleRoot() {
String html = F("<!doctype html><html><head><meta name=viewport content='width=device-width'>"
"<title>IRMogging</title></head><body style='font-family:sans-serif'><h1>IRMogging</h1>");
for (auto &code : codes) {
html += "<p><button style='font-size:1.2em' onclick=\"fetch('/api/codes/" + code.name +
"/send',{method:'POST'}).then(r=>r.text()).then(t=>out.textContent=t)\">" + code.name + "</button></p>";
}
html += F("<pre id=out></pre></body></html>");
server.send(200, "text/html", html);
}
void handleStatus() {
sendJson(200, statusToJson());
}
void handleCodes() {
sendJson(200, codesToJson());
}
void handleSend() {
IrCode *code = findCode(server.pathArg(0));
if (!code) return sendError(404, "unknown code");
int_fast8_t repeats = server.hasArg("repeats") ? constrain(server.arg("repeats").toInt(), 0, MAX_REPEATS)
: code->repeats;
if (!sendCode(*code, repeats)) return sendError(500, "protocol not supported for sending");
sendJson(200, "{\"sent\":\"" + code->name + "\",\"repeats\":" + String(repeats) + "}");
}
void handleLearn() {
#if IR_LEARN_ENABLED
String name = server.arg("name");
if (!isValidName(name)) return sendError(400, "name must be 1-32 letters, digits, '-' or '_'");
IrCode learned;
if (!learnCode(name, LEARN_TIMEOUT_MS, learned)) return sendError(408, "no IR code received");
storeCode(learned);
sendJson(200, codeToJson(learned));
#else
sendError(501, "learning is disabled in this build (IR_LEARN_ENABLED)");
#endif
}
void handleNotFound() {
sendError(404, "not found");
}
// ---------------------------------------------------------------------------
// Serial commands
void handleSerialLine(String line) {
line.trim();
int space = line.indexOf(' ');
String cmd = space < 0 ? line : line.substring(0, space);
String arg = space < 0 ? String() : line.substring(space + 1);
arg.trim();
if (cmd == "status") {
Serial.println(statusToJson());
} else if (cmd == "list") {
for (auto &code : codes) Serial.println(codeToJson(code));
} else if (cmd == "send") {
IrCode *code = findCode(arg);
if (code) sendCode(*code, code->repeats);
else Serial.println("Unknown code: " + arg);
} else if (cmd == "learn") {
#if IR_LEARN_ENABLED
IrCode learned;
if (!isValidName(arg)) Serial.println("Name must be 1-32 letters, digits, '-' or '_'");
else if (learnCode(arg, LEARN_TIMEOUT_MS, learned)) storeCode(learned);
#else
Serial.println("Learning is disabled in this build (IR_LEARN_ENABLED)");
#endif
} else if (cmd.length()) {
Serial.println("Commands: status | list | send <name> | learn <name>");
}
}
void pollSerial() {
static String line;
while (Serial.available()) {
char c = Serial.read();
if (c == '\r' || c == '\n') {
if (line.length()) handleSerialLine(line);
line = "";
} else if (line.length() < 80) {
line += c;
}
}
}
// ---------------------------------------------------------------------------
// WiFi
void connectWiFi() {
Serial.printf("Connecting to WiFi '%s'...\r\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD); // blocks for up to 15 s
}
void pollWiFi() {
static uint32_t lastAttempt = 0;
static uint32_t lastBlink = 0;
bool connected = WiFi.status() == WL_CONNECTED;
if (connected && !wifiConnected) {
Serial.printf("WiFi connected: http://%s/ or http://%s.local/\r\n", WiFi.localIP().toString().c_str(), HOSTNAME);
if (!mdnsStarted) {
mdnsStarted = MDNS.begin(HOSTNAME);
MDNS.addService("http", "tcp", 80);
}
digitalWrite(LED_BUILTIN, HIGH);
} else if (!connected && wifiConnected) {
Serial.println("WiFi disconnected");
lastAttempt = millis();
}
wifiConnected = connected;
if (!connected) {
static bool ledOn = false;
if (millis() - lastBlink >= 500) { // slow blink while offline
lastBlink = millis();
ledOn = !ledOn;
digitalWrite(LED_BUILTIN, ledOn);
}
if (millis() - lastAttempt >= WIFI_RETRY_MS) {
lastAttempt = millis();
connectWiFi();
}
}
}
// ---------------------------------------------------------------------------
void pollBootsel() {
static uint32_t lastCheck = 0;
static bool wasPressed = false;
if (millis() - lastCheck < 50) return; // reading BOOTSEL briefly stalls the CPU
lastCheck = millis();
bool pressed = BOOTSEL;
if (pressed && !wasPressed && !codes.empty()) sendCode(codes[0], codes[0].repeats);
wasPressed = pressed;
}
void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
IrSender.begin(IR_SEND_PIN);
#if IR_LEARN_ENABLED
IrReceiver.begin(IR_RECEIVE_PIN, DISABLE_LED_FEEDBACK);
IrReceiver.stop(); // only listens while learning
#endif
WiFi.mode(WIFI_STA);
WiFi.setHostname(HOSTNAME);
connectWiFi();
server.enableCORS(true);
server.on("/", HTTP_GET, handleRoot);
server.on("/api/status", HTTP_GET, handleStatus);
server.on("/api/codes", HTTP_GET, handleCodes);
server.on(UriBraces("/api/codes/{}/send"), HTTP_POST, handleSend);
server.on("/api/learn", HTTP_POST, handleLearn);
server.onNotFound(handleNotFound);
server.begin();
}
void loop() {
pollWiFi();
server.handleClient();
pollSerial();
pollBootsel();
}
The Hardware
I built a small circuit board to replace the breadboard. I used a Pico-specific prototyping board and I hid the components under the Pico itself.
Finished Soldered Circuit Board Case Without Lid

All that was left to do was program a button on my Stream Deck to send the trigger and the project was complete. Now, a press of the “Speaker” button on the Stream Deck switches the speakers on.
Conclusion
This was a low-cost project, probably around £10 in parts, cheaper than replacing the failing remote control. It solved a problem that at first seemed trivial but solving it makes life just that little bit easier and more seamless. AI helped fill the gaps in my knowledge about infrared detection and transmission.