Аудиоплеер, управляемый RFID-карточками

Что это такое?

В этой статье мы расскажем о том как собрать управляемый при помощи RFID-меток аудиоплеер. Устройство состоит из двух частей:

  1. Пульт, который управляет воспроизведением музыки. Чтобы включить определённую мелодию, достаточно всего лишь поднести к нему нужную RFID-метку. В нашем случае это карточки от Московского метрополитена.
  2. Аудиоплеер воспроизводит выбранную мелодию.

Что для этого необходимо?

Для изготовления пульта нам понадобятся:

  1. Пара карточек от Московского метрополитена

Для изготовления аудиоплеера нам понадобятся:

  1. SD карта объемом ≥4,5 ГБ
  2. Акустические колонки и кабель для них.

Сборка пульта

  1. При помощи Arduino IDE запрограммируйте Arduino Leonardo скетчем rfid_wifi.ino.
  2. Установите Wireless Shield на Arduino Leonardo.
  3. Установите Wi-Fi Bee v2.0 на Wireless Shield. Обратите внимание: переключатель на Wireless Shield должен стоять в положении «MICRO».
  4. Установите RFID-сканер на Breadboard Mini. Поместите получившуюся конструкцию на Wireless Shield.
  5. Соедините 11-й пин Arduino с ножкой RX RFID-сканера.
  6. Соедините 10-й пин Arduino с ножкой TX RFID-сканера.
  7. Соедините контакты питания RFID-сканера с контактами питания Arduino (красный провод — 5 V, чёрный — GND).
  8. Поместите конструкцию в корпус пульта и подсоедините питание.

Сборка аудиоплеера

  1. Залейте образ Raspbian от Wolfson на SD-карту.
  2. Установите аудиокарту Wolfson на Raspberry Pi.
  3. Вставьте SD-карту в Raspberry Pi.
  4. Подключите питание и сетевой кабель.
  5. Залогиньтесь по SSH на Raspberry Pi (по умолчанию логин — pi, пароль — raspberry)

    Обратите внимание: для подключения к Raspberry Pi по сети вам потребуется узнатьIP-адрес, назначенный Raspberry Pi вашим маршрутизатором. Для этого можно воспользоваться Web-интерфейсом маршрутизатора или специализированными программами, такими как Advanced IP scanner.

  6. Установите Ruby, выполнив в командной строке команду:
    sudo apt-get install ruby
  7. Клонируйте репозиторий кода:
    git clone https://github.com/amperka-projects/becha-rfid
    cd ./becha-rfid
  8. Для установки сервера выполните команды:
    cd ./server
    ./prepare_wolfson
    sudo gem install bechad

    После установки сервера мы можем выполнять три простых действия: привязать композицию к карточке (добавление трека в playlist), запустить и остановить сервер.

  9. Для добавления трека в playlist:
    bechad record <path-to-track>
  10. Для запуска сервера:
    bechad start
  11. Для остановки сервера:
    bechad stop

Как это работает?

Протокол связи аудиоплеера с пультом

Протокола связи с пультом как такового нет. Пульт опрашивает RFID-сканер каждые 300 мс. При обнаружении карты производится проверка: не та ли это карта, которая была опрошена 300 мс назад? Чтобы даже при медленном поднесении карты к пульту не происходило более одной посылки команды, пульт выжидает 5 секунд до момента, когда старая карта снова может быть принята. Как только карта будет обнаружена, её идентификационный номер будет считан и отправлен по Wi-Fi на IP-адрес сервера.

Сервер ожидает UDP-пакеты размером до 32 байт на порте 2014. Пакеты бо́льшей длины обрезаются до 32-х байтов. Принятые пакеты сервер воспринимает как ID метки, один пакет — одна метка. Есть выделенный ID для остановки сервера - «quit» (посылается вызовом команды bechad stop).

Сервер

Аудиосервер работает на Raspberry Pi B. Сервер представляет собой консольное Ruby-приложение, работающее в режиме демона. Вызывая приложение с нужными аргументами можно запустить/остановить демона или составить плейлист. Последний представляет собой массив пар «уникальный код карты» → «путь к композиции».

Сервер открывает слушающий UDP-сокет и ждёт пакеты. При этом он пишет в log-файл (по умолчанию — ./becha.log) сообщения о получении меток, старте, останове; во второй log-файл (по умолчанию — ./becha.error.log) сервер пишет ошибки исполнения. Пути к log-файлу и файлу ошибок задаются опциями -e и -E соответственно.

Приём метки, соответствующей уже проигрываемой композиции, приводит к воспроизведению композиции с начала. Приём неизвестной метки приводит к остановке проигрывания. Таким образом, незапрограммированную метку можно использовать в качестве метки останова.

Список композиций хранится в YAML-формате. Для внесения новой записи в список композиций, необходимо выполнить команду record. При выполнении этой команды сервер не переходит в режим демона. Вместо этого он ожидает метку, которую привязывает к пути аудиокомпозиции, переданному в качестве аргумента команде record. Для успешного выполнения команды record сервер должен быть остановлен. Чтобы очистить playlist, просто удалите файл, в котором он хранится.

Для старта сервера нужно выполнить команду start, для останова — stop.

Исходные коды

Пульт. Скетч для Arduino

rfid_wifi.ino
/*
We use Arduino Leonardo
*/
 
//#define WIFI_DEBUG
 
#include <SoftwareSerial.h>
#include <WiFly.h>
 
// Настройки Wi-Fi
 
// Вместо «MySSID» необходимо указать имя вашей беспроводной сети
#define SSID      "MySSID"
 
// Вместо «MyWifiPassword» необходимо указать пароль от вашей беспроводной сети
#define KEY       "MyWifiPassword"   
 
// WIFLY_AUTH_OPEN / WIFLY_AUTH_WPA1 / WIFLY_AUTH_WPA1_2 / WIFLY_AUTH_WPA2_PSK
#define AUTH      WIFLY_AUTH_WPA1_2
 
// Вместо 192.168.10.159 необходимо указать IP-адрес, 
// который получила Raspberry PI от вашего маршрутизатора
#define UDP_HOST_IP        "192.168.10.159"
 
#define UDP_REMOTE_PORT    2014
#define UDP_LOCAL_PORT     2014
 
WiFly wifly(Serial1);
 
SoftwareSerial rfidReader(10, 11);
 
const char getReadID[] =  { 0xAA , 0x00, 0x03, 0x25, 0x26, 0x00, 0x00, 0xBB };
const char nothing[] =    { 0xAA , 0x00, 0x02, 0x01, 0x83, 0x80, 0xBB }; //
 
#define GET_READ_ID_LEN   (sizeof(getReadID) / sizeof(getReadID[0]))
#define NOTHING_LEN       (sizeof(nothing) / sizeof(nothing[0]))
#define RFID_DATA_LEN     13
 
#define THROTTLE_DELAY    300
#define REPEAT_DELAY      5000
 
 
char incomingData[RFID_DATA_LEN];
char sendData[RFID_DATA_LEN];
 
unsigned long timePoint = 0;
unsigned long timeClearPoint = 0;
 
#define LED_PIN 13
 
void setup()
{
  Serial1.begin(9600);
 
  rfidReader.begin(9600);
 
  Serial.begin(9600);
 
#ifdef WIFI_DEBUG
  while (!Serial)
    ;
#endif
  setupWiFly();
 
  pinMode(LED_PIN, OUTPUT);
 
}
 
 
void loop()
{
  while (Serial1.available())
    Serial1.read(); //clear buffer
 
  // send an UDP packet periodically
  if (millis() - timePoint > THROTTLE_DELAY) {
 
    readRfidData();
 
    bool dataDiffers = memcmp(sendData, incomingData, RFID_DATA_LEN);
    bool hasData = memcmp(incomingData, nothing, NOTHING_LEN);
 
    if (dataDiffers && hasData) {
      memcpy(sendData, incomingData, RFID_DATA_LEN);
 
      for (int i = 0; i < RFID_DATA_LEN; ++i)
        Serial1.print(sendData[i], HEX);
 
      Serial1.print("\r\n");
    }
 
    timePoint = millis();
 
    digitalWrite(LED_PIN, !digitalRead(LED_PIN));
  }
 
  if (millis() - timeClearPoint > REPEAT_DELAY) {
    // clear stored data after to repeat send
    memset(sendData, 0, RFID_DATA_LEN);
    timeClearPoint = millis();
  }
}
 
 
void readRfidData()
{
  for (int counter = 0; counter < GET_READ_ID_LEN; ++counter)
    rfidReader.write(getReadID[counter]);
 
  while (!rfidReader.available())
    ;
 
  delay(10); // wait for RX buffer fill up
 
  int i = 0;
  while (rfidReader.available() && i < RFID_DATA_LEN)
    incomingData[i++] = rfidReader.read();
 
  while (rfidReader.available())
    rfidReader.read(); //clear buffer
}
 
 
void setupWiFly()
{
  Serial.println("--------- WIFLY UDP --------");
 
  wifly.reset();
 
  while (1) {
    Serial.println("Try to join " SSID );
    if (wifly.join(SSID, KEY, AUTH)) {
      Serial.println("Succeed to join " SSID);
      wifly.clear();
      break;
    } else {
      Serial.println("Failed to join " SSID);
      Serial.println("Wait 1 second and try again...");
      delay(1000);
    }
  }
 
  setupUDP(UDP_HOST_IP, UDP_REMOTE_PORT, UDP_REMOTE_PORT);
 
  delay(1000);
  wifly.clear();
}
 
void setupUDP(const char *host_ip, uint16_t remote_port, uint16_t local_port)
{
  char cmd[32];
 
  wifly.sendCommand("set w j 1\r", "AOK");   // enable auto join
 
  wifly.sendCommand("set i p 1\r", "AOK");
  snprintf(cmd, sizeof(cmd), "set i h %s\r", host_ip);
  wifly.sendCommand(cmd, "AOK");
  snprintf(cmd, sizeof(cmd), "set i r %d\r", remote_port);
  wifly.sendCommand(cmd, "AOK");
  snprintf(cmd, sizeof(cmd), "set i l %d\r", local_port);
  wifly.sendCommand(cmd, "AOK");
  wifly.sendCommand("save\r");
  wifly.sendCommand("reboot\r");
}

Аудиоплеер. Скрипт для Raspberry Pi B

bechad
#!/usr/bin/env ruby
 
require 'pathname'
require 'optparse'
require 'optparse/time'
require 'ostruct'
require 'socket'
require 'yaml'
 
 
class Pathname
  def extname= (extension)
    @path = @path[0..@path.length-extname.length-1]
    if extension.length > 0
      @path << '.' if extension[0] != '.'
      @path << extension.to_s
    end
  end
 
  def chext (extension)
    res = self.clone
    res.extname = extension
 
    res
  end
end
 
 
APP_VERSION = 'BECHA-server v1.0.4'
ARGV_COUNT = 1
 
DEFAULT_PLAYLIST_PATH = './becha.playlist.conf'
DEFAULT_LOG_PATH       = './becha.log'
DEFAULT_ERRLOG_PATH    = './becha.error.log'
DEFAULT_PORT          = 2014 
 
 
def parse_options(args)
    options = OpenStruct.new
 
    opt_parser = OptionParser.new do |opts|
      opts.banner = "Usage: bechad [options] cmd [args]\n\n" +
      "Avalaible commands:\n" +
      "  start\t\t- Power ON the tape recorder\n" +
      "  stop\t\t- Power OFF the tape recorder\n" +
      "  record track-path\t\t- Record track into the tape recorder"
 
      options.verbose = false
      options.playlist_path = Pathname.new(DEFAULT_PLAYLIST_PATH).expand_path
      options.log_path = Pathname.new(DEFAULT_LOG_PATH).expand_path
      options.errlog_path = Pathname.new(DEFAULT_ERRLOG_PATH).expand_path
      options.port = DEFAULT_PORT
 
      opts.separator ""
      opts.separator "Common options:"
 
      opts.on("-l", "--playlist path", String,
              "Define playlist path (default - " + DEFAULT_PLAYLIST_PATH.to_s + ")") do |path|
        options.playlist_path = Pathname.new path
      end
 
      opts.on("-p", "--port num", Integer, 
              "Define UDP port number (default - " + DEFAULT_PORT.to_s + ")") do |port|
        options.port = port
      end
 
      opts.on("-e", "--log path", String, 
              "Define log file (default - " + DEFAULT_LOG_PATH.to_s + ")") do |path|
        options.log_path = Pathname.new path
      end
 
      opts.on("-E", "--errlog path", String, 
              "Define log file for errors (default - " + DEFAULT_ERRLOG_PATH.to_s + ")") do |path|
        options.errlog_path = Pathname.new path
      end
 
      opts.on("--verbose", "Print extra information") do
        options.verbose = true
      end
 
      opts.on_tail("-h", "--help", "Show this message") do
        puts opts
        exit
      end
 
      opts.on_tail("--version", "Show version") do
        puts APP_VERSION
        exit
      end
    end
 
    opt_parser.parse!(args)
    [options, opt_parser]
  end
 
res = parse_options(ARGV)
options = res[0]
opt_parser = res[1]
 
if ARGV.count < ARGV_COUNT
  p opt_parser
 
  exit 1
end
 
argv = ARGV.reverse
 
cmd = argv.pop
 
 
case cmd
when 'start'
  playlist_path = Pathname.new(options.playlist_path.expand_path)
  playlist = YAML::load(IO.read playlist_path.expand_path)
 
  raise 'Config file is corrupted' unless playlist.class == Hash
 
  puts "Starting daemon..."
 
  Process.daemon
 
  STDOUT.reopen options.log_path.expand_path, 'a'
  STDERR.reopen options.errlog_path.expand_path, 'a'
 
  $stdout = STDOUT
  $stderr = STDERR
 
  STDOUT.sync = true
  STDERR.sync = true
 
  sock = UDPSocket.new
  sock.bind '', options.port
 
  puts "[#{Time.now}] Server started!"
 
  loop do
    packet = sock.recvfrom 32 
 
    if packet[0] == "quit"
      puts "[#{Time.now}] Exiting..."
      Process.kill "KILL", $mplayer_pid unless $mplayer_pid.nil?
      exit 0
    end
 
    puts "[#{Time.now}] Received data: #{packet[0].to_s}"
 
    if playlist.has_key? packet[0].to_s
      puts "[#{Time.now}] Start playing: #{playlist[packet[0].to_s]}"
 
      begin
        Process.kill "KILL", $mplayer_pid unless $mplayer_pid.nil?
        $mplayer_pid = fork do
          `mplayer "#{playlist[packet[0].to_s]}"`
        end
      rescue => error
        puts 'Error playing'
        p error
        p "#{playlist[packet[0].to_s]}"
        $mplayer_pid = nil
      end
 
    else
      puts "[#{Time.now}] Item not found"
 
      begin
        Process.kill "KILL", $mplayer_pid unless $mplayer_pid.nil?
        $mplayer_pid = nil
      rescue
        $mplayer_pid = nil
      end
    end
  end
  loop { } 
when 'stop'
  sock = UDPSocket.new
  sock.send 'quit', 0, 'localhost', options.port
when 'record'
  playlist_path = Pathname.new options.playlist_path.expand_path
  begin
    playlist = YAML::load IO.read playlist_path.expand_path
  rescue
    playlist = {}
  end
 
  raise 'Config file is corrupted' unless playlist.class == Hash
 
  track_path = argv.pop
 
  p opt_parser if track_path.nil?
 
  track_path = Pathname.new(track_path).expand_path
 
  sock = UDPSocket.new
  sock.bind '', options.port
 
  loop do
    packet = sock.recvfrom 32
 
    if packet[0].length == 0
      puts "Received error packet, retrying..."
    else
      playlist[packet[0]] = track_path
      IO.write playlist_path.expand_path, YAML::dump(playlist)
 
      if playlist == YAML::load(IO.read playlist_path.expand_path)
        puts "Recording successful: #{packet[0]} -> #{track_path}"
        break
      else
        puts "Recording error!"
        p playlist
        p '---'
        p YAML::load(IO.read playlist_path.expand_path)
      end
    end
  end
else
  p 'Undefined command'
  p opt_parser
  exit 1
end
 
exit 0

Демонстрация работы устройства

Что ещё можно сделать?

  1. Добавить к функционалу сервера веб-интерфейс, чтобы запись композиций, управление процессом проигрывания и другие действия можно было выполнять удалённо.
  2. Добавить функцию «пауза». Привязать к этой функции отдельную метку, которой можно будет приостанавливать и возобновлять проигрывание.
  3. Научить сервер доставать композиции из плейлиста аккаунта соцсети в интернете (например, из vk.com).