MegadetectorでのAI検知と連動して働く「わんわん威嚇装置」を作ってみた。
この装置を複数台用意し全面、背後、横などイノシシを取り囲むように時間差で
吠えさせればかなり効果が見込める。
必要な部品集め
CPUは小型で周辺部品も最小で済む「秋月電子製 AE-ESP-WROOM-02-DEV」¥1,280にした。


再生アンプは、UART経由で再生制御可能なサウンド基板をアマゾンで見つけた。

Ahvqevn DY-20T音声再生モジュール
12V/24V電源10W/20WアンプMicro-SD
カードMP3ミュージックプレーヤー
購入先 アマゾン 購入価格¥1,428
スピーカーは防水タイプの物を探します。
アマゾンで色々なタイプが見つかります、適当な物を用意して下さい。

Herdio 100W HIFI 2-Way防水型4インチ スピーカー
4Ω 100W ¥3,771 アマゾン
事前テスト
最初に再生基板が正常動作するか確認します。
Windows11 PCにUSB->UART変換ケーブル経由で接続してテストします。


DTECH USB TTL シリアル 変換 ケーブル 3.3V 1m FTDI
チップセット 6ピン 2.54mm ピッチ メス コネクタ
| FT232RL USB UART シリアル コンバーター ケーブル
購入先 アマゾン 購入価格¥1,999
サウンド基板とシリアルコンバーターケーブルを接続します。
Pin1(GND)->GND Pin4(TXD)->RXD Pin5(RXD)->TXD
サウンド基板の電源端子にDC12Vを供給します。
Jupyter Notebookをインストール
Anaconda公式サイトから、お使いのOS(Windows / Mac)に対応したインストーラーをダウンロー
ドします。
ダウンロードしたファイルを実行し、画面の指示に従ってインストールを完了させます。
インストール後、Windowsならスタートメニューから「Anaconda3」を開き、「Jupyter Notebook」をクリックして起動します。
!pip install pyserial --break-system-packages
import time
import serial
def calculate_checksum(command_bytes):
"""コマンドバイト配列のチェックサム(全バイトの和の下位8ビット)を計算"""
return sum(command_bytes) & 0xFF
def send_command(ser, command_type, data_bytes=[]):
"""DY-HV20Tにコマンドを送信する共通関数"""
# フレーム構成: 起始値(0xAA) + コマンドタイプ + データ長 + データ + チェックサム
frame = [0xAA, command_type, len(data_bytes)] + data_bytes
checksum = calculate_checksum(frame)
frame.append(checksum)
# バイナリデータとして送信
ser.write(bytes(frame))
print(f"Sent: {[hex(b) for b in frame]}")
def main():
# COM3ポートを9600bpsでオープン
PORT = "COM3"
BAUDRATE = 9600
try:
with serial.Serial(PORT, BAUDRATE, timeout=1) as ser:
print(f"{PORT} に接続しました。")
time.sleep(2) # 接続安定のためのウェイト
# 1. 指定曲(先頭ファイル: 0x0001)を再生
# コマンド番号: 0x07 (指定パス再生)
# データ: 高位バイト(0x00), 低位バイト(0x01)
print("先頭ファイルを再生します...")
send_command(ser, 0x07, [0x00, 0x02])
# 5秒間再生を維持
time.sleep(5)
# 2. 再生を停止
# コマンド番号: 0x04 (停止)
print("再生を停止します...")
send_command(ser, 0x04)
except serial.SerialException as e:
print(f"エラー: ポートを開けませんでした。 {e}")
if __name__ == "__main__":
main()
COM3 に接続しました。 先頭ファイルを再生します... Sent: ['0xaa', '0x7', '0x2', '0x0', '0x2', '0xb5'] 再生を停止します... Sent: ['0xaa', '0x4', '0x0', '0xae']
正常動作を確認しました。
組み立て配線
秋月電子製 AE-ESP-WROOM-02-DEV は 必要な部品は最初から配線済なのでとても便利です。

配線が必要な部分は、GND->9,15,20番 5V電源供給->10 RXD->12 TXD->16 LED->19 のみです。

サウンドボード側

GND端子->2か所 12V電源端子 RXD端子 TXD端子 外部スピーカー端子 を使用します。
基板に供給する電圧は、12Vですが、ポートの電圧は3.3Vになっています。
電圧は35V迄OKですので24V供給するとより大きい音になるかどうかはテストしていません。
12Vでなるべく大きい音にするには、スピーカーインピーダンスを4Ωタイプを選ぶと20Wが
出力されると説明があります。
犬の吠え声は、なるべく激しい吠え声が良いです。
フリー素材のサイトなどで探して下さい。
https://soundeffect-lab.info/
サウンドは、mp3形式でmicroSDカードに保存して下さい。
MQTTで play コマンドを受信した際に、ESP8266側で「1~3」のランダムな数字を生成し、DY-20Tの「指定パス再生コマンド(0x07)」を使って対象のファイルを狙って再生します。
microSDカードはFAT32でフォーマットしてからその中に吠え声を
00001.mp3 00002.mp3 00003.mp3という名前で保存します。
[Micro-SDカードのルート]
├── 00001.mp3
├── 00002.mp3
└── 00003.mp3
回路図

スクリプトの解説
- 固定IP指定: 指定された 192.168.128.100 を local_IP として設定しています。お使いのルーター環境に合わせて gateway の値を変更してください。
- SoftwareSerial(ポート4, 5): SoftwareSerial mySerial(4, 5) により、IO4をRX(受信)、IO5をTX(送信)として定義しています。
- DY-20Tコマンド仕様: DY-20Tは AA [コマンド] [データ長] [データ…] [チェックサム] というバイナリ形式で通信します。これを sendCommand 関数で動的に計算して送信しています。
- play: 0x02(再生)
- stop: 0x04(停止)
- sound 0-30: 0x13(音量設定)+ 指定値
- 動作確認LED: Wi-Fi接続が確立するとIO16のLEDが常時点灯します。また、MQTTでメッセージを受信するたびに一瞬消灯するインジケーターの役割を持たせています。
- 吠え声のバリエーションを 4種類以上(例: 00004.mp3)に増やす場合(コードの random(1, 4) の数字を変更するだけで増やせます)
- 毎回同じ吠え声では野生動物に学習されてしまいます。
再生するたびに完全にランダムを作ることができます。 - MQTT経由で「stop」->停止 「sound」->音量(0~30)を送信すれば制御出来ます。
topic->sequ/wan payload->play stop sound 26 のように送信します。
soundはsoundと音量数値の間にスペース1個挟みます。
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
#include <SoftwareSerial.h>
const char* SSID = "SSID";//Set Your SSID
const char* PWD = "PASSWD";//Set Your PASSWD
// MQTT
const char* MQTT_SERVER = "192.168.1.200";//Set Your MQTT Server Address
const uint16_t MQTT_PORT = 1883;
const char* MQTT_TOPIC = "sequ/wan";
// ESP-WROOM-02: GPIO4 (RX), GPIO5 (TX)
// ESP GPI 5/TX -> DY RX DY TX -> ESP GPI 4/RX
SoftwareSerial audioSerial(4, 5);
const uint8_t LED_PIN = 16;
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
void sendAudioCommand(uint8_t command, uint8_t dataLength, const uint8_t* data) {
uint8_t frame[20];
if (dataLength > sizeof(frame) - 4) return;
frame[0] = 0xAA;
frame[1] = command;
frame[2] = dataLength;
uint8_t checksum = frame[0] + frame[1] + frame[2];
for (uint8_t i = 0; i < dataLength; i++) {
frame[3 + i] = data[i];
checksum += data[i];
}
frame[3 + dataLength] = checksum;
audioSerial.write(frame, 4 + dataLength);
Serial.print("UART sent:");
for (uint8_t i = 0; i < 4 + dataLength; i++) {
Serial.printf(" %02X", frame[i]);
}
Serial.println();
}
void playTrack(uint16_t trackNumber) {
uint8_t data[2] = {
(uint8_t)(trackNumber >> 8),
(uint8_t)(trackNumber & 0xFF)
};
sendAudioCommand(0x07, 2, data);
}
void callback(char* topic, byte* payload, unsigned int length) {
String message;
message.reserve(length);
for (unsigned int i = 0; i < length; i++) {
message += (char)payload[i];
}
message.trim();
Serial.print("MQTT topic: ");
Serial.println(topic);
Serial.print("MQTT message: [");
Serial.print(message);
Serial.println("]");
digitalWrite(LED_PIN, LOW);
if (String(topic) == MQTT_TOPIC && message == "play") {
// random(min, max)
uint16_t trackNumber = random(1, 4);
Serial.printf("Playing random track %u\n", trackNumber);
playTrack(trackNumber);
} else if (String(topic) == MQTT_TOPIC && message == "stop") {
Serial.println("Stopping playback");
sendAudioCommand(0x04, 0, nullptr);
} else if (String(topic) == MQTT_TOPIC && message.startsWith("sound ")) {
int volume = message.substring(6).toInt();
if (volume < 0) volume = 0;
if (volume > 30) volume = 30;
uint8_t data[1] = { (uint8_t)volume };
Serial.printf("Setting volume to %d\n", volume);
sendAudioCommand(0x13, 1, data);
} else {
Serial.println("Unknown command. Use play, stop, or sound 0-30.");
}
delay(30);
digitalWrite(LED_PIN, HIGH);
}
void connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.config(
IPAddress(192, 168, 1, 100),
IPAddress(192, 168, 1, 1),
IPAddress(255, 255, 255, 0)
);
WiFi.begin(SSID, PWD);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print('.');
}
Serial.println();
Serial.print("Wi-Fi connected. IP: ");
Serial.println(WiFi.localIP());
}
void reconnectMQTT() {
while (!mqttClient.connected()) {
Serial.print("Connecting to MQTT... ");
if (mqttClient.connect("ESP8266_DY20T_Client")) {
Serial.println("connected");
if (mqttClient.subscribe(MQTT_TOPIC)) {
Serial.print("Subscribed: ");
Serial.println(MQTT_TOPIC);
} else {
Serial.println("Subscribe failed");
}
} else {
Serial.print("failed, state=");
Serial.println(mqttClient.state());
delay(2000);
}
}
}
void setup() {
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, HIGH);
Serial.begin(115200);
audioSerial.begin(9600);
randomSeed(ESP.getChipId() ^ micros());
Serial.println();
Serial.println("MQTT dog sound device starting");
connectWiFi();
mqttClient.setServer(MQTT_SERVER, MQTT_PORT);
mqttClient.setCallback(callback);
}
void loop() {
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi disconnected; reconnecting");
connectWiFi();
}
if (!mqttClient.connected()) reconnectMQTT();
mqttClient.loop();
}
NODE-REDの設定方法について
テスト用フロー (動作テストに使用するフローです)

コマンドのテストが出来ます。
[{"id":"918794ccc5072d70","type":"mqtt out","z":"df7fe98051533576","name":"","topic":"","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"a7f27199b8eac3bd","x":630,"y":500,"wires":[]},{"id":"71b7b8b296a5805a","type":"inject","z":"df7fe98051533576","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"sequ/wan","payload":"sound 20","payloadType":"str","x":410,"y":500,"wires":[["918794ccc5072d70"]]},{"id":"4389d61c3c6072f7","type":"mqtt out","z":"df7fe98051533576","name":"","topic":"","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"a7f27199b8eac3bd","x":630,"y":560,"wires":[]},{"id":"907826f01a53c1cc","type":"inject","z":"df7fe98051533576","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"sequ/wan","payload":"stop","payloadType":"str","x":430,"y":560,"wires":[["4389d61c3c6072f7"]]},{"id":"64b33d93066721f3","type":"mqtt out","z":"df7fe98051533576","name":"","topic":"","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"a7f27199b8eac3bd","x":630,"y":620,"wires":[]},{"id":"319e650982f73132","type":"inject","z":"df7fe98051533576","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"sequ/wan","payload":"play","payloadType":"str","x":430,"y":620,"wires":[["64b33d93066721f3"]]},{"id":"a7f27199b8eac3bd","type":"mqtt-broker","name":"","broker":"192.168.1.200","port":1883,"clientid":"","autoConnect":true,"usetls":false,"protocolVersion":4,"keepalive":60,"cleansession":true,"autoUnsubscribe":true,"birthTopic":"","birthQos":"0","birthRetain":"false","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closeRetain":"false","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willRetain":"false","willPayload":"","willMsg":{},"userProps":"","sessionExpiry":""}]
本ブログで紹介した「Megadetectorイノシシ威嚇用システム」で使用するフロー

[{"id":"0b84606f58d67bae","type":"function","z":"df7fe98051533576","name":"animal検出ならplay","func":"// Megadetector JSONの例: { detected: true, objects: [{ class: \"person\" }, ...] }\nvar data = msg.payload;\n\n// JSONノードを通さず文字列で届いた場合にも対応\nif (typeof data === \"string\") {\n try { data = JSON.parse(data); } catch (e) {\n node.warn(\"payloadをJSONとして解析できません\");\n return null;\n }\n}\n\nif (!data || typeof data !== \"object\") return null;\nif (data.detected === false) return null;\n\nvar objects = data.objects;\nif (!Array.isArray(objects)) {\n if (objects && typeof objects === \"object\") objects = [objects];\n else return null;\n}\n\nfunction className(item) {\n if (typeof item === \"string\") return item.toLowerCase();\n if (!item || typeof item !== \"object\") return \"\";\n var value = item.class || item.label || item.name || item.category;\n if (value && typeof value === \"object\") {\n value = value.name || value.label || value.class;\n }\n return (value == null ? \"\" : String(value)).toLowerCase();\n}\n\nvar hasPerson = objects.some(function(item) {\n return className(item) === \"animal\";\n});\n\nif (!hasPerson) return null;\nmsg.topic = \"sequ/wan\";\nmsg.payload = \"play\";\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":630,"y":120,"wires":[["e52415840e4d3deb"]]}]