イノシシ威嚇用 わんわん装置の制作

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



回路図



スクリプトの解説

  1. 固定IP指定: 指定された 192.168.128.100 を local_IP として設定しています。お使いのルーター環境に合わせて gateway の値を変更してください。
  2. SoftwareSerial(ポート4, 5): SoftwareSerial mySerial(4, 5) により、IO4をRX(受信)、IO5をTX(送信)として定義しています。
  3. DY-20Tコマンド仕様: DY-20Tは AA [コマンド] [データ長] [データ…] [チェックサム] というバイナリ形式で通信します。これを sendCommand 関数で動的に計算して送信しています。
  • play: 0x02(再生)
  • stop: 0x04(停止)
  • sound 0-30: 0x13(音量設定)+ 指定値
  1. 動作確認LED: Wi-Fi接続が確立するとIO16のLEDが常時点灯します。また、MQTTでメッセージを受信するたびに一瞬消灯するインジケーターの役割を持たせています。
  2. 吠え声のバリエーションを 4種類以上(例: 00004.mp3)に増やす場合(コードの random(1, 4) の数字を変更するだけで増やせます)
  3. 毎回同じ吠え声では野生動物に学習されてしまいます。
    再生するたびに完全にランダムを作ることができます。
  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"]]}]