Compare commits
112 Commits
Author | SHA1 | Date | |
---|---|---|---|
ab51af637e | |||
913a64d465 | |||
b2cf9d6277 | |||
076a6993c7 | |||
13e62fea19 | |||
15a65f7391 | |||
b9df55012f | |||
fb6b5bced6 | |||
3718f45983 | |||
7dcb0cb673 | |||
6989248970 | |||
cf433a48b2 | |||
9a39b00a65 | |||
aed9c416bf | |||
2908d23e60 | |||
3272921db2 | |||
6ddf1efd62 | |||
45dfe0cfe0 | |||
4840c150c2 | |||
9c31f70c57 | |||
978b25c34d | |||
dcca828197 | |||
fa208858d9 | |||
6d452ecbc0 | |||
23fbddb055 | |||
fe2a209e44 | |||
82905a8cdd | |||
3751904cb4 | |||
bcf7625285 | |||
4a3e79f02e | |||
68e1073858 | |||
f73d45404f | |||
ecc7c46b8d | |||
0dd5937707 | |||
547080acf5 | |||
d3c699aefa | |||
a8d19cd6e1 | |||
38d48ab0e4 | |||
51bef05465 | |||
4eef69516e | |||
9175193b67 | |||
65118fbc42 | |||
076f0e9dfd | |||
571e969bc4 | |||
8e15f87cd3 | |||
dd9e1538c8 | |||
001e275131 | |||
196021bef5 | |||
63b9616677 | |||
d4c9a6d582 | |||
5fe66fdaef | |||
6445dc0fb8 | |||
7a20cf4b04 | |||
bbf77c6b1e | |||
b805d1b183 | |||
07b1ea3a5c | |||
3b0410f560 | |||
8f19b990ff | |||
519ac0e3bd | |||
651843fb06 | |||
fcbbdce118 | |||
6f8683ba9d | |||
710b8a2cdc | |||
b989784fb9 | |||
94489618ca | |||
82d8f07eea | |||
20041dd483 | |||
4f9174d362 | |||
68ecc05712 | |||
5fad39ee0e | |||
01f513c97b | |||
3bfbea92d8 | |||
d818624287 | |||
d92388d11f | |||
37df309127 | |||
be8a124803 | |||
104236dd0c | |||
e1dd004cf5 | |||
b5ec78ab41 | |||
fff9d9bc61 | |||
ef47c771ef | |||
9f442259e9 | |||
8e5a3195b9 | |||
cc4729eb6b | |||
f7c4b0d70a | |||
566068f7cd | |||
5c15a7d4cb | |||
b9a4770ff2 | |||
e471a57578 | |||
6e05900b5a | |||
15f6d78128 | |||
b32f7d1228 | |||
45fef23bad | |||
e20e6b7d3e | |||
0531b599fe | |||
a5751eec79 | |||
e02b8571f6 | |||
303a8d3877 | |||
6d00474315 | |||
46fb4c7615 | |||
48c93ed043 | |||
2d1f049444 | |||
c313f6eb72 | |||
cccdc9cedb | |||
429979c6d1 | |||
e28a541fe9 | |||
0f2b8c6564 | |||
5f682c303f | |||
dcbb42f5ef | |||
235ef8c39d | |||
4d59c66354 | |||
25fa963752 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
||||
.pio
|
||||
.pioenvs
|
||||
.piolibdeps
|
||||
.vscode
|
||||
include/config.h
|
8
README.md
Normal file
8
README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# ESMP3
|
||||
|
||||
## Audio files
|
||||
System messages are created using:
|
||||
* https://ttsmp3.com/
|
||||
* German / Vicki
|
||||
* "Dies ist ein Text.<break time="1s"/>"
|
||||
* Download as MP3.
|
3
bin/update.manifest
Normal file
3
bin/update.manifest
Normal file
@ -0,0 +1,3 @@
|
||||
VERSION=1
|
||||
IMAGE_PATH=https://files.schle.nz/esmp3/firmware.bin
|
||||
IMAGE_MD5=00000000000000000000000000000000
|
2
build_version.sh
Executable file
2
build_version.sh
Executable file
@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
echo -n "-DVERSION=\\\"`git describe --tags --dirty`\\\""
|
53
deploy.sh
Executable file
53
deploy.sh
Executable file
@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
#set -x
|
||||
set -e
|
||||
|
||||
if ! git diff-index --quiet HEAD ; then
|
||||
echo "Git isn't clean. Cant deploy."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
branch_name=$(git symbolic-ref -q HEAD)
|
||||
branch_name=${branch_name##refs/heads/}
|
||||
branch_name=${branch_name:-HEAD}
|
||||
|
||||
if [ "$branch_name" != "master" ]; then
|
||||
echo "We are not on master branch. Can't deploy."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo
|
||||
echo "Last tags:"
|
||||
vers=`git tag --sort=-version:refname | head -n 5`
|
||||
while read version; do
|
||||
echo " $version"
|
||||
done <<< "$vers"
|
||||
|
||||
read -p "Version to generate: " VERSION
|
||||
|
||||
OTA_VERSION=`grep "VERSION=" bin/update.manifest | cut -d"=" -f2`
|
||||
OTA_VERSION=$(( $OTA_VERSION + 1 ))
|
||||
|
||||
sed -i.bak "s/#define OTA_VERSION .*/#define OTA_VERSION $OTA_VERSION/" include/config.h include/config.sample.h
|
||||
rm include/config.h.bak include/config.sample.h.bak
|
||||
|
||||
PLATFORMIO_BUILD_FLAGS='-DVERSION=\"$VERSION\"' pio run -e deploy -t buildprog || exit 1
|
||||
|
||||
cp .pio/build/deploy/firmware.bin bin/firmware.bin || exit 1
|
||||
|
||||
sed -i.bak "s/VERSION=.*/VERSION=$OTA_VERSION/" bin/update.manifest
|
||||
MD5=`md5sum --binary bin/firmware.bin | cut -d" " -f1`
|
||||
sed -i.bak "s/IMAGE_MD5=.*/IMAGE_MD5=$MD5/" bin/update.manifest
|
||||
rm bin/update.manifest.bak
|
||||
|
||||
echo; echo; echo; echo; echo
|
||||
echo "Please check the git diff, if everything looks okay:"
|
||||
git diff
|
||||
|
||||
read -p "Press ENTER to continue, Ctrl-C to abort. " foo
|
||||
|
||||
git add bin/firmware.bin bin/update.manifest
|
||||
git commit -m "Deploying version $VERSION."
|
||||
git tag -a -m "Deploying version $VERSION" $VERSION
|
||||
git push --follow-tags
|
@ -1,30 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "config.h"
|
||||
#include "player.h"
|
||||
#include <MFRC522.h>
|
||||
#include <MCP23S17/MCP23S17.h>
|
||||
#include <MFRC522v2.h>
|
||||
#include <MFRC522Debug.h>
|
||||
#include "esmp3.h"
|
||||
#include "playlist.h"
|
||||
|
||||
class Controller {
|
||||
private:
|
||||
MFRC522* _rfid;
|
||||
MCP* _mcp;
|
||||
bool _rfid_enabled = true;
|
||||
void _check_rfid();
|
||||
void _check_serial();
|
||||
void _check_buttons();
|
||||
uint32_t _get_rfid_card_uid();
|
||||
uint32_t _last_rfid_card_uid = 0;
|
||||
Player* _player;
|
||||
unsigned long _last_rfid_scan_at = 0;
|
||||
String _serial_buffer = String();
|
||||
void _execute_serial_command(String cmd);
|
||||
void _execute_command_ls(String path);
|
||||
void _execute_command_help();
|
||||
unsigned long _button_last_pressed_at[NUM_BUTTONS];
|
||||
bool _check_button(uint8_t btn);
|
||||
public:
|
||||
Controller(Player* p, MCP* m);
|
||||
void loop();
|
||||
};
|
||||
private:
|
||||
void handle_buttons();
|
||||
void handle_rfid();
|
||||
bool is_button_pressed(uint8_t pin);
|
||||
Playlist current_playlist;
|
||||
bool is_rfid_present = false;
|
||||
unsigned long last_rfid_check = 0;
|
||||
unsigned long last_button_check = 0;
|
||||
unsigned long last_position_save = 0;
|
||||
uint8_t button_pressed = 0;
|
||||
unsigned long button_pressed_since = 0;
|
||||
bool button_already_processed = false;
|
||||
String read_rfid_data();
|
||||
|
||||
public:
|
||||
void handle();
|
||||
void next_track();
|
||||
void prev_track();
|
||||
void play();
|
||||
void play(String rfid_id, bool shuffle=false);
|
||||
void stop();
|
||||
void eof_mp3(String info);
|
||||
};
|
26
include/esmp3.h
Normal file
26
include/esmp3.h
Normal file
@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "controller.h"
|
||||
#include "playlist_manager.h"
|
||||
#include <Audio.h>
|
||||
|
||||
#define PIN_CS_SD 22
|
||||
#define PIN_CS_RFID 21
|
||||
|
||||
#define PIN_BTN_VOL_UP 32
|
||||
#define PIN_BTN_VOL_DOWN 33
|
||||
#define PIN_BTN_TRACK_NEXT 17
|
||||
#define PIN_BTN_TRACK_PREV 16
|
||||
|
||||
#define I2S_DOUT 25
|
||||
#define I2S_BCLK 26
|
||||
#define I2S_LRC 27
|
||||
|
||||
class Controller;
|
||||
|
||||
extern Controller controller;
|
||||
extern Audio audio;
|
||||
extern PlaylistManager* pm;
|
||||
extern MFRC522* rfid;
|
||||
|
||||
void save_audio_current_time();
|
9
include/persisted_playlist.h
Normal file
9
include/persisted_playlist.h
Normal file
@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
|
||||
struct PersistedPlaylist {
|
||||
String dir;
|
||||
uint16_t file = 0;
|
||||
uint32_t position = 0;
|
||||
PersistedPlaylist(String s="") : dir(s) {}
|
||||
};
|
@ -1,92 +0,0 @@
|
||||
#pragma once
|
||||
#include "config.h"
|
||||
#include <SPI.h>
|
||||
#include <SD.h>
|
||||
#include <list>
|
||||
#include <map>
|
||||
#include <MCP23S17/MCP23S17.h>
|
||||
|
||||
#define SCI_MODE 0x00
|
||||
#define SCI_STATUS 0x01
|
||||
#define SCI_CLOCKF 0x03
|
||||
#define SCI_DECODE_TIME 0x04
|
||||
#define SCI_VOL 0x0B
|
||||
#define SCI_WRAMADDR 0x07
|
||||
#define SCI_WRAM 0x06
|
||||
|
||||
#define CMD_WRITE 0x02
|
||||
#define CMD_READ 0x03
|
||||
|
||||
#define ADDR_ENDBYTE 0x1E06
|
||||
|
||||
#define SM_CANCEL 0x0008
|
||||
#define SS_DO_NOT_JUMP 0x8000
|
||||
|
||||
#define XRESET PIN_VS1053_XRESET
|
||||
#define DREQ PIN_VS1053_DREQ
|
||||
#define XCS PIN_VS1053_XCS
|
||||
#define XDCS PIN_VS1053_XDCS
|
||||
|
||||
class Player {
|
||||
private:
|
||||
enum state { uninitialized, idle, playing, stopping,
|
||||
system_sound_while_playing, system_sound_while_stopped };
|
||||
struct album_state {
|
||||
uint8_t index;
|
||||
uint32_t position;
|
||||
};
|
||||
void _check_system_sound(String filename);
|
||||
void _reset();
|
||||
void _init();
|
||||
void _wait();
|
||||
uint16_t _read_control_register(uint8_t address);
|
||||
void _write_control_register(uint8_t address, uint16_t value);
|
||||
void _write_data(uint8_t* data);
|
||||
uint16_t _read_wram(uint16_t address);
|
||||
state _state = state::uninitialized;
|
||||
void _refill();
|
||||
bool _refill_needed();
|
||||
void _flush_and_cancel();
|
||||
int8_t _get_endbyte();
|
||||
void _flush(uint count, int8_t fill_byte);
|
||||
void _set_last_track(const char* album, uint8_t track, uint32_t position);
|
||||
std::map<String, album_state> _last_tracks;
|
||||
void _play_file(String filename, uint32_t offset);
|
||||
uint32_t _id3_tag_offset(File f);
|
||||
void _finish_playing();
|
||||
void _finish_stopping();
|
||||
void _mute();
|
||||
void _unmute();
|
||||
|
||||
SPISettings _spi_settings_slow = SPISettings(250000, MSBFIRST, SPI_MODE0);
|
||||
SPISettings _spi_settings_fast = SPISettings(4000000, MSBFIRST, SPI_MODE0);
|
||||
SPISettings* _spi_settings = &_spi_settings_slow;
|
||||
|
||||
std::list<String> _files_in_dir(String dir);
|
||||
String _find_album_dir(String album);
|
||||
File _file;
|
||||
uint8_t _buffer[32];
|
||||
String _playing_album;
|
||||
uint8_t _playing_index;
|
||||
uint8_t _playing_album_songs;
|
||||
uint32_t _current_play_position;
|
||||
uint _refills;
|
||||
uint8_t _volume;
|
||||
uint16_t _stop_delay;
|
||||
uint32_t _skip_to;
|
||||
MCP* _mcp;
|
||||
public:
|
||||
Player(MCP* m);
|
||||
void vol_up();
|
||||
void vol_down();
|
||||
void track_next();
|
||||
void track_prev();
|
||||
|
||||
bool play_album(String album);
|
||||
bool play_song(String album, uint8_t song_index, uint32_t offset=0);
|
||||
void play_system_sound(String filename);
|
||||
void stop();
|
||||
bool loop();
|
||||
void set_volume(uint8_t vol, bool save = true);
|
||||
std::list<String> ls(String path);
|
||||
};
|
30
include/playlist.h
Normal file
30
include/playlist.h
Normal file
@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <Arduino.h>
|
||||
#include "persisted_playlist.h"
|
||||
|
||||
class Playlist {
|
||||
private:
|
||||
std::vector<String> files;
|
||||
uint8_t current_file = 0;
|
||||
uint32_t current_time = 0;
|
||||
String rfid_id;
|
||||
PersistedPlaylist* pp;
|
||||
|
||||
public:
|
||||
Playlist();
|
||||
Playlist(String rfid_id, PersistedPlaylist* p);
|
||||
void add_file(String filename);
|
||||
void sort();
|
||||
String get_rfid_id();
|
||||
String get_current_file_name();
|
||||
bool next_track();
|
||||
bool prev_track();
|
||||
void restart();
|
||||
void set_current_time(uint32_t time);
|
||||
uint32_t get_current_time();
|
||||
void shuffle();
|
||||
void set_current_position(uint8_t file, uint32_t position=0);
|
||||
void save_current_position(uint32_t position=0);
|
||||
};
|
25
include/playlist_manager.h
Normal file
25
include/playlist_manager.h
Normal file
@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <Arduino.h>
|
||||
#include "playlist.h"
|
||||
#include "persisted_playlist.h"
|
||||
|
||||
class Playlist;
|
||||
|
||||
class PlaylistManager {
|
||||
private:
|
||||
Playlist get_playlist_for_tag_id(String id);
|
||||
String current_rfid_tag_id;
|
||||
uint32_t audio_current_time = 0;
|
||||
|
||||
public:
|
||||
PlaylistManager();
|
||||
std::map<String, PersistedPlaylist> map;
|
||||
Playlist get_playlist(String rfid_id);
|
||||
bool has_playlist(String rfid_id);
|
||||
Playlist current_playlist;
|
||||
void set_audio_current_time(uint32_t time);
|
||||
String pp_to_String();
|
||||
};
|
@ -1,35 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include "config.h"
|
||||
|
||||
class SPIMaster {
|
||||
public:
|
||||
static void init() {
|
||||
SPI.setHwCs(false);
|
||||
pinMode(PIN_SD_CS, OUTPUT);
|
||||
pinMode(PIN_VS1053_XCS, OUTPUT);
|
||||
pinMode(PIN_VS1053_XDCS, OUTPUT);
|
||||
pinMode(PIN_MCP, OUTPUT);
|
||||
}
|
||||
static void enable(uint8_t pin) {
|
||||
digitalWrite(PIN_SD_CS, pin==PIN_SD_CS ? LOW : HIGH);
|
||||
digitalWrite(PIN_VS1053_XCS, pin==PIN_VS1053_XCS ? LOW : HIGH);
|
||||
digitalWrite(PIN_VS1053_XDCS, pin==PIN_VS1053_XDCS ? LOW : HIGH);
|
||||
digitalWrite(PIN_MCP, pin==PIN_MCP ? LOW : HIGH);
|
||||
|
||||
}
|
||||
|
||||
static void printStatus() {
|
||||
Serial.printf("CS state: SD:%d, VS1053_XCS:%d, VS1053_XDCS:%d, MCP:%d\n",
|
||||
digitalRead(PIN_SD_CS),
|
||||
digitalRead(PIN_VS1053_XCS),
|
||||
digitalRead(PIN_VS1053_XDCS),
|
||||
digitalRead(PIN_MCP));
|
||||
}
|
||||
|
||||
static void disable() {
|
||||
enable(142);
|
||||
}
|
||||
public:
|
||||
static void enable_sd();
|
||||
static void enable_rfid();
|
||||
static void disable_all();
|
||||
static void initialize();
|
||||
};
|
||||
|
6
partitions.csv
Normal file
6
partitions.csv
Normal file
@ -0,0 +1,6 @@
|
||||
# Custom partition table without SPIFFS.
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x220000,
|
||||
app1, app, ota_1, 0x230000,0x220000,
|
|
@ -8,11 +8,35 @@
|
||||
; Please visit documentation for the other options and examples
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[env:esp12e]
|
||||
platform = espressif8266
|
||||
board = esp12e
|
||||
[platformio]
|
||||
default_envs = esp32
|
||||
|
||||
[extra]
|
||||
lib_deps =
|
||||
|
||||
[env:esp32]
|
||||
platform = espressif32
|
||||
board = esp-wrover-kit
|
||||
framework = arduino
|
||||
upload_speed = 512000
|
||||
lib_deps = 63
|
||||
https://github.com/n0mjs710/MCP23S17.git
|
||||
upload_port = /dev/cu.wchusbserial1420
|
||||
upload_speed = 921600
|
||||
build_flags = -DCORE_DEBUG_LEVEL=5 -DCONFIG_ARDUHAL_LOG_COLORS=1 ; !./build_version.sh
|
||||
lib_deps =
|
||||
${extra.lib_deps}
|
||||
esphome/ESP32-audioI2S@^2.1.0
|
||||
computer991/Arduino_MFRC522v2@^2.0.1
|
||||
https://github.com/dplasa/FTPClientServer
|
||||
;upload_port = 10.10.2.108
|
||||
monitor_speed = 115200
|
||||
monitor_port = /dev/cu.usbserial-0001
|
||||
monitor_filters = esp32_exception_decoder
|
||||
|
||||
[env:deploy]
|
||||
platform = espressif32
|
||||
board = esp-wrover-kit
|
||||
framework = arduino
|
||||
lib_deps =
|
||||
${extra.lib_deps}
|
||||
esphome/ESP32-audioI2S@^2.1.0
|
||||
computer991/Arduino_MFRC522v2@^2.0.1
|
||||
board_build.embed_txtfiles = src/index.html
|
||||
board_build.partitions = partitions.csv
|
||||
|
@ -1,168 +1,226 @@
|
||||
#include "controller.h"
|
||||
#include "spi_master.h"
|
||||
#include "esmp3.h"
|
||||
|
||||
Controller::Controller(Player* p, MCP* m) {
|
||||
_player = p;
|
||||
_mcp = m;
|
||||
_rfid = new MFRC522(PIN_RC522_CS, MFRC522::UNUSED_PIN);
|
||||
|
||||
SPIMaster::enable(PIN_MCP);
|
||||
_mcp->pinMode(1, INPUT); _mcp->pullupMode(1, HIGH);
|
||||
_mcp->pinMode(2, INPUT); _mcp->pullupMode(2, HIGH);
|
||||
_mcp->pinMode(3, INPUT); _mcp->pullupMode(3, HIGH);
|
||||
_mcp->pinMode(4, INPUT); _mcp->pullupMode(4, HIGH);
|
||||
|
||||
SPIMaster::enable(PIN_RC522_CS);
|
||||
DEBUG("Initializing RC522...");
|
||||
_rfid->PCD_Init();
|
||||
#ifdef SHOW_DEBUG
|
||||
_rfid->PCD_DumpVersionToSerial();
|
||||
#endif
|
||||
SPIMaster::disable();
|
||||
INFO("RC522 initialized.\n");
|
||||
|
||||
for (uint8_t i=0; i<NUM_BUTTONS; i++) _button_last_pressed_at[i]=0;
|
||||
}
|
||||
|
||||
void Controller::loop() {
|
||||
unsigned long now = millis();
|
||||
if ((_last_rfid_scan_at < now - RFID_SCAN_INTERVAL) || (now < _last_rfid_scan_at)) {
|
||||
_check_rfid();
|
||||
_last_rfid_scan_at = now;
|
||||
void Controller::handle() {
|
||||
if (last_rfid_check + 500 < millis() || last_rfid_check > millis()) {
|
||||
handle_rfid();
|
||||
last_rfid_check = millis();
|
||||
}
|
||||
_check_serial();
|
||||
_check_buttons();
|
||||
}
|
||||
|
||||
uint32_t Controller::_get_rfid_card_uid() {
|
||||
SPIMaster::enable(PIN_RC522_CS);
|
||||
if (!_rfid->PICC_ReadCardSerial()) {
|
||||
if (!_rfid->PICC_IsNewCardPresent()) {
|
||||
return 0;
|
||||
}
|
||||
if (!_rfid->PICC_ReadCardSerial()) {
|
||||
return 0;
|
||||
}
|
||||
if (last_button_check + 10 < millis() || last_button_check > millis()) {
|
||||
handle_buttons();
|
||||
last_button_check = millis();
|
||||
}
|
||||
if (last_position_save + 10000 < millis() || last_position_save > millis()) {
|
||||
current_playlist.save_current_position(audio.getFilePos());
|
||||
last_position_save = millis();
|
||||
//Serial.println(pm->pp_to_String().c_str());
|
||||
}
|
||||
uint32_t uid = _rfid->uid.uidByte[0]<<24 | _rfid->uid.uidByte[1]<<16 | _rfid->uid.uidByte[2]<<8 | _rfid->uid.uidByte[3];
|
||||
SPIMaster::disable();
|
||||
return uid;
|
||||
}
|
||||
|
||||
void Controller::_check_rfid() {
|
||||
uint32_t uid = _get_rfid_card_uid();
|
||||
if (uid != _last_rfid_card_uid) {
|
||||
if (uid > 0) {
|
||||
INFO("New RFID card uid: %08x\n", uid);
|
||||
String s_uid = String(uid, HEX);
|
||||
_player->play_album(s_uid);
|
||||
void Controller::handle_buttons() {
|
||||
if (is_button_pressed(PIN_BTN_VOL_UP)) {
|
||||
log_i("BTN_VOL_UP pressed");
|
||||
uint8_t vol = min(audio.getVolume()+2, 21);
|
||||
log_d("Setting new volume %d", vol);
|
||||
audio.setVolume(vol);
|
||||
} else if (is_button_pressed(PIN_BTN_VOL_DOWN)) {
|
||||
log_i("BTN_VOL_DOWN pressed");
|
||||
uint8_t vol;
|
||||
if ((vol = audio.getVolume()) >= 3) {
|
||||
vol -= 2;
|
||||
} else {
|
||||
INFO("No more RFID card.");
|
||||
_player->stop();
|
||||
vol = 1;
|
||||
}
|
||||
_last_rfid_card_uid = uid;
|
||||
log_d("Setting new volume %d", vol);
|
||||
audio.setVolume(vol);
|
||||
} else if (is_button_pressed(PIN_BTN_TRACK_NEXT)) {
|
||||
log_i("BTN_TRACK_NEXT pressed");
|
||||
next_track();
|
||||
} else if (is_button_pressed(PIN_BTN_TRACK_PREV)) {
|
||||
log_i("BTN_TRACK_PREV pressed");
|
||||
prev_track();
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::_check_serial() {
|
||||
if (Serial.available() > 0) {
|
||||
char c = Serial.read();
|
||||
Serial.printf("%c", c);
|
||||
if (c==10 || c==13) {
|
||||
if (_serial_buffer.length()>0) {
|
||||
_execute_serial_command(_serial_buffer);
|
||||
_serial_buffer = String();
|
||||
void Controller::handle_rfid() {
|
||||
if (is_rfid_present) {
|
||||
byte buffer[2];
|
||||
byte buffer_size = 2;
|
||||
MFRC522Constants::StatusCode status = rfid->PICC_WakeupA(buffer, &buffer_size);
|
||||
if (status == MFRC522Constants::STATUS_OK) {
|
||||
// Card is still present.
|
||||
rfid->PICC_HaltA();
|
||||
} else {
|
||||
Serial.printf("RFID status is %s\n", MFRC522Debug::GetStatusCodeName(status));
|
||||
is_rfid_present = false;
|
||||
Serial.println("No more RFID card.\n");
|
||||
stop();
|
||||
}
|
||||
} else {
|
||||
if (rfid->PICC_IsNewCardPresent()) {
|
||||
if (rfid->PICC_ReadCardSerial()) {
|
||||
uint32_t uid = rfid->uid.uidByte[0]<<24 | rfid->uid.uidByte[1]<<16 | rfid->uid.uidByte[2]<<8 | rfid->uid.uidByte[3];
|
||||
Serial.printf("Found new rfid card with uid %x\n", uid);
|
||||
is_rfid_present = true;
|
||||
if (uid > 0) {
|
||||
String temp = String(uid, HEX);
|
||||
String s_uid = "";
|
||||
for (int i=0; i<(8-temp.length()); i++) {
|
||||
s_uid.concat("0");
|
||||
}
|
||||
s_uid.concat(temp);
|
||||
|
||||
String data = read_rfid_data();
|
||||
|
||||
play(s_uid, data.indexOf("[random]")>=0);
|
||||
}
|
||||
rfid->PICC_HaltA();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String Controller::read_rfid_data() {
|
||||
log_v("read_rfid_data() running...");
|
||||
MFRC522::StatusCode status;
|
||||
MFRC522::PICC_Type type = rfid->PICC_GetType(rfid->uid.sak);
|
||||
uint16_t pageStart = 0;
|
||||
uint16_t pages = 4;
|
||||
uint16_t pageSize = 1;
|
||||
switch(type) {
|
||||
case MFRC522Constants::PICC_TYPE_MIFARE_MINI:
|
||||
case MFRC522Constants::PICC_TYPE_MIFARE_1K:
|
||||
case MFRC522Constants::PICC_TYPE_MIFARE_4K: {
|
||||
log_v("Trying to authenticate Mifare card.");
|
||||
MFRC522::MIFARE_Key key = {0xD3, 0xF7, 0xD3, 0xF7, 0xD3, 0xF7};
|
||||
status = rfid->PCD_Authenticate(MFRC522Constants::PICC_CMD_MF_AUTH_KEY_A, 4, &key, &rfid->uid);
|
||||
if (status == MFRC522Constants::STATUS_OK) {
|
||||
log_v("Authentication succeeded.");
|
||||
} else {
|
||||
log_v("Authentication failed. Trying to read anyway.");
|
||||
}
|
||||
pageStart = 4;
|
||||
break;
|
||||
}
|
||||
case MFRC522Constants::PICC_TYPE_MIFARE_UL:
|
||||
log_v("PICC type is Mifare Ultralight. No authentication necessary.");
|
||||
pages = 16;
|
||||
pageSize = 4;
|
||||
break;
|
||||
default:
|
||||
log_v("Unexpected rfid card type %s. Trying to read anyway.", MFRC522Debug::PICC_GetTypeName(type));
|
||||
}
|
||||
String data = "";
|
||||
for (uint8_t block=pageStart; block<pages+pageStart; block+=pageSize) {
|
||||
byte buffer[18];
|
||||
uint8_t byte_count = 18;
|
||||
status = rfid->MIFARE_Read(block, buffer, &byte_count);
|
||||
if (status != MFRC522Constants::STATUS_OK) {
|
||||
log_d("MIFARE_Read() failed: %s\n", String(MFRC522Debug::GetStatusCodeName(status)).c_str());
|
||||
continue;
|
||||
}
|
||||
for (int i=0; i<16; i++) {
|
||||
if (buffer[i]>=0x20 && buffer[i]<0x7F) data.concat((char)buffer[i]);
|
||||
}
|
||||
}
|
||||
rfid->PCD_StopCrypto1();
|
||||
log_v("Read rfid data: '%s'", data.c_str());
|
||||
return data;
|
||||
}
|
||||
|
||||
void Controller::play(String rfid_id, bool shuffle) {
|
||||
if (!rfid_id.equals(current_playlist.get_rfid_id())) {
|
||||
if (pm->has_playlist(rfid_id)) {
|
||||
current_playlist = pm->get_playlist(rfid_id);
|
||||
if (shuffle) {
|
||||
log_i("Shuffling the playlist.");
|
||||
current_playlist.shuffle();
|
||||
}
|
||||
play();
|
||||
} else {
|
||||
Serial.printf("There is no playlist for rfid_id %s\n", rfid_id.c_str());
|
||||
// This is working more or less, but downloading files is really, REALLY slow. (About 4 minutes for 10 MBytes).
|
||||
//download_album(rfid_id);
|
||||
audio.connecttoFS(SD, "/system/sys_unknown_card.mp3");
|
||||
}
|
||||
} else {
|
||||
if (!audio.isRunning()) {
|
||||
play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::play() {
|
||||
String file = current_playlist.get_current_file_name();
|
||||
|
||||
if (file.startsWith("/")) {
|
||||
log_i("Playing file %s via connecttoFS", file.c_str());
|
||||
audio.connecttoFS(SD, file.c_str(), current_playlist.get_current_time());
|
||||
} else if (file.startsWith("http")) {
|
||||
log_i("Playing URL %s via connecttohost", file.c_str());
|
||||
audio.connecttoFS(SD, "/system/sys_connecting.mp3");
|
||||
while (audio.isRunning()) {
|
||||
yield();
|
||||
audio.loop();
|
||||
}
|
||||
audio.connecttohost(file.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::next_track() {
|
||||
if (current_playlist.next_track()) {
|
||||
play();
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::prev_track() {
|
||||
uint32_t time = audio.getAudioCurrentTime();
|
||||
log_d("prev_track() called. getAudioCurrentTime() returns %d", time);
|
||||
if (time >= 5) {
|
||||
log_d("Restarting current track.");
|
||||
current_playlist.restart();
|
||||
play();
|
||||
} else {
|
||||
if (current_playlist.prev_track()) {
|
||||
play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::stop() {
|
||||
if (audio.isRunning()) {
|
||||
current_playlist.set_current_time(audio.stopSong());
|
||||
}
|
||||
}
|
||||
|
||||
bool Controller::is_button_pressed(uint8_t pin) {
|
||||
//log_d("Button %d state is %d", pin, digitalRead(pin));
|
||||
if (!digitalRead(pin)) {
|
||||
// Button is pressed - let's debounce it.
|
||||
if (button_pressed == pin) {
|
||||
if (button_pressed_since + 150 < millis() && !button_already_processed) {
|
||||
button_already_processed = true;
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
_serial_buffer.concat(c);
|
||||
button_pressed = pin;
|
||||
button_pressed_since = millis();
|
||||
button_already_processed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::_execute_serial_command(String cmd) {
|
||||
DEBUG("Executing command: %s", cmd.c_str());
|
||||
|
||||
if (cmd.equals("ls")) {
|
||||
_execute_command_ls("/");
|
||||
} else if (cmd.startsWith("ls ")) {
|
||||
_execute_command_ls(cmd.substring(3));
|
||||
} else if (cmd.startsWith("play ")) {
|
||||
_player->play_album(cmd.substring(5));
|
||||
} else if (cmd.startsWith("sys ")) {
|
||||
_player->play_system_sound(cmd.substring(4));
|
||||
} else if (cmd.equals("stop")) {
|
||||
_player->stop();
|
||||
} else if (cmd.equals("help")) {
|
||||
_execute_command_help();
|
||||
} else if (cmd.equals("-")) {
|
||||
_player->vol_down();
|
||||
} else if (cmd.equals("+")) {
|
||||
_player->vol_up();
|
||||
} else if (cmd.equals("p")) {
|
||||
_player->track_prev();
|
||||
} else if (cmd.equals("n")) {
|
||||
_player->track_next();
|
||||
} else {
|
||||
ERROR("Unknown command: %s\n", cmd.c_str());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void Controller::_execute_command_ls(String path) {
|
||||
INFO("Listing contents of %s:\n", path.c_str());
|
||||
std::list<String> files = _player->ls(path);
|
||||
for(std::list<String>::iterator it=files.begin(); it!=files.end(); it++) {
|
||||
INFO(" %s\n", (*it).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::_execute_command_help() {
|
||||
INFO("Valid commands are:");
|
||||
INFO(" help - Displays this help\n");
|
||||
INFO(" ls [dir] - Lists the contents of [dir] or, if not given, of /\n");
|
||||
INFO(" play [id] - Plays the album with the given id\n");
|
||||
INFO(" sys [file]- Plays the file as system sound\n");
|
||||
INFO(" stop - Stops playback\n");
|
||||
INFO(" - / + - Decrease or increase the volume\n");
|
||||
INFO(" p / n - Previous or next track\n");
|
||||
}
|
||||
|
||||
void Controller::_check_buttons() {
|
||||
SPIMaster::enable(PIN_MCP);
|
||||
SPI.beginTransaction(SPISettings(250000, MSBFIRST, SPI_MODE0));
|
||||
/*if (millis()%100==0) {
|
||||
Serial.printf("Buttons: %d %d %d %d\n", _mcp->digitalRead(1), _mcp->digitalRead(2), _mcp->digitalRead(3), _mcp->digitalRead(4));
|
||||
}*/
|
||||
if (_check_button(0)) {
|
||||
_player->track_prev();
|
||||
} else if (_check_button(1)) {
|
||||
_player->vol_up();
|
||||
} else if (_check_button(2)) {
|
||||
_player->vol_down();
|
||||
} else if (_check_button(3)) {
|
||||
_player->track_next();
|
||||
}
|
||||
SPI.endTransaction();
|
||||
SPIMaster::disable();
|
||||
}
|
||||
|
||||
bool Controller::_check_button(uint8_t index) {
|
||||
if (index >= NUM_BUTTONS) return false;
|
||||
bool ret = false;
|
||||
uint8_t sum = 0;
|
||||
while (1) {
|
||||
sum = 0;
|
||||
for (int i=0; i<8; i++) {
|
||||
sum += _mcp->digitalRead(index + 1) == HIGH ? 1 : 0;
|
||||
if (button_pressed == pin) {
|
||||
button_pressed = 0;
|
||||
}
|
||||
if (sum==0 || sum==8) break;
|
||||
}
|
||||
if (sum == 0) {
|
||||
if (_button_last_pressed_at[index] + DEBOUNCE_MILLIS < millis()) {
|
||||
DEBUG("Button %d pressed.\n", index);
|
||||
ret = true;
|
||||
}
|
||||
_button_last_pressed_at[index] = millis();
|
||||
}
|
||||
return ret;
|
||||
return false;
|
||||
}
|
||||
|
||||
void Controller::eof_mp3(String info) {
|
||||
log_d("Handling eof. Keep playing until the file is finished.");
|
||||
while(audio.isRunning()) { audio.loop(); yield; }
|
||||
if (info.startsWith("sys_")) {
|
||||
log_d("File ending was a system audio file. Not running next_track.");
|
||||
} else {
|
||||
next_track();
|
||||
}
|
||||
}
|
139
src/esmp3.cpp
Normal file
139
src/esmp3.cpp
Normal file
@ -0,0 +1,139 @@
|
||||
#include <WiFi.h>
|
||||
#include <ArduinoOTA.h>
|
||||
#include <SD.h>
|
||||
#include "spi_master.h"
|
||||
#include "playlist_manager.h"
|
||||
#include "controller.h"
|
||||
#include <Audio.h>
|
||||
#include "esmp3.h"
|
||||
#include <Ticker.h>
|
||||
#include <MFRC522v2.h>
|
||||
#include <MFRC522DriverSPI.h>
|
||||
#include <MFRC522DriverPinSimple.h>
|
||||
#include <MFRC522Debug.h>
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <FTPServer.h>
|
||||
|
||||
Controller controller;
|
||||
Audio audio;
|
||||
PlaylistManager* pm;
|
||||
MFRC522* rfid;
|
||||
FTPServer ftp(SD);
|
||||
|
||||
void setup() {
|
||||
pinMode(PIN_CS_SD, OUTPUT); digitalWrite(PIN_CS_SD, HIGH);
|
||||
pinMode(PIN_CS_RFID, OUTPUT); digitalWrite(PIN_CS_RFID, HIGH);
|
||||
|
||||
Serial.begin(115200);
|
||||
WiFi.begin("Schlenz", "1410WischlingenPanda");
|
||||
log_i("Connecting to WiFi...");
|
||||
uint8_t i=9;
|
||||
while(WiFi.status() != WL_CONNECTED) {
|
||||
Serial.print(i);
|
||||
Serial.print("... ");
|
||||
delay(1000);
|
||||
i--;
|
||||
if (i==0) {
|
||||
Serial.println("Could not connect to WiFi. Restarting in 1s.");
|
||||
delay(1000);
|
||||
ESP.restart();
|
||||
}
|
||||
}
|
||||
Serial.println();
|
||||
Serial.print("Connected to WiFi. IP address: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
ArduinoOTA.begin();
|
||||
log_i("Waiting for OTA...");
|
||||
for(int i=0; i<20; i++) {
|
||||
ArduinoOTA.handle();
|
||||
delay(100);
|
||||
}
|
||||
|
||||
Serial.println("Setting up audio...");
|
||||
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
|
||||
audio.setVolume(1);
|
||||
audio.forceMono(true);
|
||||
audio.setBufsize(30000, -1);
|
||||
audio.setConnectionTimeout(1000, 1000);
|
||||
|
||||
Serial.println("Initializing SPI...");
|
||||
SPI.begin();
|
||||
//SPI.setHwCs(false);
|
||||
//SPIMaster::initialize();
|
||||
|
||||
Serial.print("Initializing SD card...");
|
||||
//SPIMaster::enable_sd();
|
||||
while(!SD.begin(PIN_CS_SD, SPI, 25000000)) {
|
||||
for(int i=0; i<10; i++) {
|
||||
if(SPI.transfer(0xFF)==0xFF) break;
|
||||
delay(10);
|
||||
}
|
||||
Serial.print(".");
|
||||
delay(100);
|
||||
}
|
||||
Serial.println();
|
||||
|
||||
Serial.println("Initializing PlaylistManager...");
|
||||
pm = new PlaylistManager();
|
||||
|
||||
Serial.println("Setting up rfid reader...");
|
||||
pinMode(PIN_CS_RFID, OUTPUT);
|
||||
MFRC522DriverPin* pin = new MFRC522DriverPinSimple(PIN_CS_RFID);
|
||||
MFRC522Driver* spi = new MFRC522DriverSPI(*pin);
|
||||
rfid = new MFRC522(*spi);
|
||||
rfid->PCD_Init();
|
||||
MFRC522Debug::PCD_DumpVersionToSerial(*rfid, Serial);
|
||||
|
||||
Serial.println("Setting up buttons...");
|
||||
pinMode(PIN_BTN_VOL_UP, INPUT_PULLUP);
|
||||
pinMode(PIN_BTN_VOL_DOWN, INPUT_PULLUP);
|
||||
pinMode(PIN_BTN_TRACK_NEXT, INPUT_PULLUP);
|
||||
pinMode(PIN_BTN_TRACK_PREV, INPUT_PULLUP);
|
||||
|
||||
Serial.println("Setup finished.");
|
||||
|
||||
audio.setVolume(12);
|
||||
audio.connecttoFS(SD, "/system/sys_ready.mp3");
|
||||
|
||||
ftp.begin("", "");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
ArduinoOTA.handle();
|
||||
controller.handle();
|
||||
audio.loop();
|
||||
ftp.handleFTP();
|
||||
}
|
||||
|
||||
void audio_info(const char *info){
|
||||
Serial.print("info "); Serial.println(info);
|
||||
}
|
||||
void audio_id3data(const char *info){ //id3 metadata
|
||||
Serial.print("id3data ");Serial.println(info);
|
||||
}
|
||||
void audio_eof_mp3(const char *info){ //end of file
|
||||
Serial.print("eof_mp3 ");Serial.println(info);
|
||||
controller.eof_mp3(info);
|
||||
}
|
||||
void audio_showstation(const char *info){
|
||||
Serial.print("station ");Serial.println(info);
|
||||
}
|
||||
void audio_showstreamtitle(const char *info){
|
||||
Serial.print("streamtitle ");Serial.println(info);
|
||||
}
|
||||
void audio_bitrate(const char *info){
|
||||
Serial.print("bitrate ");Serial.println(info);
|
||||
}
|
||||
void audio_commercial(const char *info){ //duration in sec
|
||||
Serial.print("commercial ");Serial.println(info);
|
||||
}
|
||||
void audio_icyurl(const char *info){ //homepage
|
||||
Serial.print("icyurl ");Serial.println(info);
|
||||
}
|
||||
void audio_lasthost(const char *info){ //stream URL played
|
||||
Serial.print("lasthost ");Serial.println(info);
|
||||
}
|
||||
void audio_eof_speech(const char *info){
|
||||
Serial.print("eof_speech ");Serial.println(info);
|
||||
}
|
49
src/main.cpp
49
src/main.cpp
@ -1,49 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <SD.h>
|
||||
#include <MCP23S17/MCP23S17.h>
|
||||
#include "config.h"
|
||||
#include "controller.h"
|
||||
#include "player.h"
|
||||
#include "spi_master.h"
|
||||
|
||||
Controller* controller;
|
||||
Player* player;
|
||||
MCP* mcp;
|
||||
|
||||
void setup() {
|
||||
delay(500);
|
||||
Serial.begin(74880);
|
||||
INFO("Starting.\n");
|
||||
INFO("Initializing...\n");
|
||||
|
||||
DEBUG("Setting up SPI...\n");
|
||||
SPI.begin();
|
||||
SPIMaster::init();
|
||||
INFO("SPI initialized.\n");
|
||||
|
||||
DEBUG("Setting up MCP...\n");
|
||||
SPIMaster::enable(PIN_MCP);
|
||||
mcp = new MCP(0, PIN_MCP);
|
||||
INFO("MCP initialized.");
|
||||
|
||||
DEBUG("Setting up SD card...\n");
|
||||
SPIMaster::enable(PIN_SD_CS);
|
||||
if (SD.begin(PIN_SD_CS)) {
|
||||
INFO("SD card initialized.\n");
|
||||
} else {
|
||||
ERROR("Could not initialize SD card. Halting.\n");
|
||||
while(1);
|
||||
}
|
||||
player = new Player(mcp);
|
||||
controller = new Controller(player, mcp);
|
||||
|
||||
INFO("Initialization completed.\n");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
bool more_data_needed = player->loop();
|
||||
if (more_data_needed) return;
|
||||
|
||||
controller->loop();
|
||||
}
|
492
src/player.cpp
492
src/player.cpp
@ -1,492 +0,0 @@
|
||||
// Based on https://github.com/mpflaga/Arduino_Library-vs1053_for_SdFat/blob/master/src/vs1053_SdFat.cpp
|
||||
|
||||
#include "player.h"
|
||||
#include "spi_master.h"
|
||||
|
||||
//Player::_spi_settings
|
||||
|
||||
Player::Player(MCP* m) {
|
||||
_mcp = m;
|
||||
_mcp->pinMode(XRESET, OUTPUT);
|
||||
_mcp->digitalWrite(XRESET, HIGH);
|
||||
pinMode(DREQ, INPUT);
|
||||
|
||||
_init();
|
||||
}
|
||||
|
||||
void Player::_reset() {
|
||||
_mcp->digitalWrite(XRESET, LOW);
|
||||
delay(100);
|
||||
_mcp->digitalWrite(XRESET, HIGH);
|
||||
delay(100);
|
||||
_state = uninitialized;
|
||||
_spi_settings = &_spi_settings_slow; // After reset, communication has to be slow
|
||||
}
|
||||
|
||||
void Player::_init() {
|
||||
SPIMaster::disable();
|
||||
DEBUG("Resetting VS1053...\n");
|
||||
_reset();
|
||||
|
||||
uint16_t result = _read_control_register(SCI_MODE);
|
||||
DEBUG("SCI_MODE: 0x%04X\n", result);
|
||||
if (result != 0x4800) {
|
||||
ERROR("SCI_MODE was 0x%04X, expected was 0x4800.\n", result);
|
||||
return;
|
||||
}
|
||||
result = _read_control_register(SCI_STATUS);
|
||||
DEBUG("SCI_STATUS: 0x%04X\n", result);
|
||||
if (result != 0x0040 && result != 0x0048) {
|
||||
ERROR("SCI_STATUS was 0x%04X, expected was 0x0040 or 0x0048.\n", result);
|
||||
return;
|
||||
}
|
||||
result = _read_control_register(SCI_CLOCKF);
|
||||
DEBUG("SCI_CLOCKF: 0x%04X\n", result);
|
||||
|
||||
DEBUG("VS1053 Init looking good.\n");
|
||||
DEBUG("Upping VS1053 multiplier...\n");
|
||||
|
||||
_write_control_register(SCI_CLOCKF, 0x6000);
|
||||
delay(10);
|
||||
|
||||
_spi_settings = &_spi_settings_fast;
|
||||
|
||||
result = _read_control_register(SCI_CLOCKF);
|
||||
DEBUG("SCI_CLOCKF: 0x%04X\n", result);
|
||||
if (result != 0x6000) {
|
||||
ERROR("Error: SCI_CLOCKF was 0x%04X, expected was 0x6000.\n", result);
|
||||
return;
|
||||
}
|
||||
|
||||
set_volume(VOLUME_DEFAULT);
|
||||
|
||||
INFO("VS1053 initialization completed.\n");
|
||||
|
||||
INFO("Checking system sounds...\n");
|
||||
SPIMaster::enable(PIN_SD_CS);
|
||||
_check_system_sound("no_prev_song.mp3");
|
||||
_check_system_sound("no_next_song.mp3");
|
||||
_check_system_sound("volume_max.mp3");
|
||||
_check_system_sound("volume_min.mp3");
|
||||
|
||||
_state = idle;
|
||||
}
|
||||
|
||||
void Player::_check_system_sound(String filename) {
|
||||
String path = String("/system/") + filename;
|
||||
if (!SD.exists(path)) {
|
||||
ERROR("System sound %s is missing on the sd card!\n", path.c_str());
|
||||
} else {
|
||||
DEBUG("%s found.\n", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
inline void Player::_wait() {
|
||||
while(!digitalRead(DREQ));
|
||||
}
|
||||
|
||||
uint16_t Player::_read_control_register(uint8_t address) {
|
||||
_wait();
|
||||
SPIMaster::enable(XCS);
|
||||
SPI.beginTransaction(*_spi_settings);
|
||||
SPI.transfer(CMD_READ);
|
||||
SPI.transfer(address);
|
||||
uint8_t b1 = SPI.transfer(0xFF);
|
||||
_wait();
|
||||
uint8_t b2 = SPI.transfer(0xFF);
|
||||
_wait();
|
||||
SPI.endTransaction();
|
||||
SPIMaster::disable();
|
||||
|
||||
return (b1 << 8) | b2;
|
||||
}
|
||||
|
||||
void Player::_write_control_register(uint8_t address, uint16_t value) {
|
||||
uint8_t b1 = value >> 8;
|
||||
uint8_t b2 = value & 0xFF;
|
||||
_wait();
|
||||
SPIMaster::enable(XCS);
|
||||
SPI.beginTransaction(*_spi_settings);
|
||||
SPI.transfer(CMD_WRITE);
|
||||
SPI.transfer(address);
|
||||
SPI.transfer(b1);
|
||||
SPI.transfer(b2);
|
||||
_wait();
|
||||
SPI.endTransaction();
|
||||
SPIMaster::disable();
|
||||
}
|
||||
|
||||
void Player::_write_data(uint8_t* buffer) {
|
||||
SPIMaster::enable(XDCS);
|
||||
SPI.beginTransaction(*_spi_settings);
|
||||
for (uint i=0; i<sizeof(_buffer); i++) {
|
||||
SPI.transfer(_buffer[i]);
|
||||
}
|
||||
SPI.endTransaction();
|
||||
SPIMaster::disable();
|
||||
}
|
||||
|
||||
uint16_t Player::_read_wram(uint16_t address) {
|
||||
DEBUG("Reading WRAM address 0x%04X...\n", address);
|
||||
_write_control_register(SCI_WRAMADDR, address);
|
||||
uint16_t r1 = _read_control_register(SCI_WRAM);
|
||||
_write_control_register(SCI_WRAMADDR, address);
|
||||
uint16_t r2 = _read_control_register(SCI_WRAM);
|
||||
if (r1 == r2) return r1;
|
||||
DEBUG("Reading WRAM resulted in different values: 0x%04X and 0x%04X.\n", r1, r2);
|
||||
_write_control_register(SCI_WRAMADDR, address);
|
||||
r1 = _read_control_register(SCI_WRAM);
|
||||
if (r1 == r2) return r1;
|
||||
DEBUG("Reading WRAM resulted in different values: 0x%04X and 0x%04X.\n", r1, r2);
|
||||
_write_control_register(SCI_WRAMADDR, address);
|
||||
r2 = _read_control_register(SCI_WRAM);
|
||||
if (r1 == r2) return r1;
|
||||
DEBUG("Reading WRAM resulted in different values: 0x%04X and 0x%04X.\n", r1, r2);
|
||||
DEBUG("Returning last value (0x%04X)...\n", r2);
|
||||
return r2;
|
||||
}
|
||||
|
||||
int8_t Player::_get_endbyte() {
|
||||
int8_t endbyte = _read_wram(ADDR_ENDBYTE) & 0xFF;
|
||||
DEBUG("Endbyte is 0x%02X.\n", endbyte);
|
||||
return endbyte;
|
||||
}
|
||||
|
||||
|
||||
void Player::set_volume(uint8_t vol, bool save) {
|
||||
if (save) {
|
||||
_volume = vol;
|
||||
}
|
||||
INFO("Setting volume to %d\n", vol);
|
||||
vol = 0xFF - vol;
|
||||
if (vol==0xFF) vol=0xFE;
|
||||
uint16_t value = (vol<<8)|vol;
|
||||
DEBUG("Setting volume register to 0x%04X\n", value);
|
||||
_write_control_register(SCI_VOL, value);
|
||||
}
|
||||
|
||||
void Player::vol_up() {
|
||||
if (_volume == VOLUME_MAX) play_system_sound("volume_max.mp3");
|
||||
else if (_volume + VOLUME_STEP > VOLUME_MAX) set_volume(VOLUME_MAX);
|
||||
else set_volume(_volume + VOLUME_STEP);
|
||||
}
|
||||
|
||||
void Player::vol_down() {
|
||||
if (_volume >= VOLUME_MIN + VOLUME_STEP) set_volume(_volume - VOLUME_STEP);
|
||||
else if (_volume == VOLUME_MIN) play_system_sound("volume_min.mp3");
|
||||
else set_volume(VOLUME_MIN);
|
||||
}
|
||||
|
||||
void Player::_mute() {
|
||||
INFO("Muting.");
|
||||
set_volume(0, false);
|
||||
}
|
||||
|
||||
void Player::_unmute() {
|
||||
INFO("Unmuting.");
|
||||
set_volume(_volume, false);
|
||||
}
|
||||
|
||||
void Player::track_next() {
|
||||
if (_state != playing) return;
|
||||
if (_playing_index + 1 >= _playing_album_songs) {
|
||||
play_system_sound("no_next_song.mp3");
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
play_song(_playing_album, _playing_index + 1);
|
||||
}
|
||||
|
||||
void Player::track_prev() {
|
||||
if (_state != playing) return;
|
||||
if (_current_play_position > 100000) {
|
||||
stop();
|
||||
play_song(_playing_album, _playing_index);
|
||||
} else {
|
||||
if (_playing_index == 0) {
|
||||
play_system_sound("no_prev_song.mp3");
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
play_song(_playing_album, _playing_index - 1);
|
||||
}
|
||||
}
|
||||
|
||||
std::list<String> Player::ls(String path) {
|
||||
SPIMaster::enable(PIN_SD_CS);
|
||||
std::list<String> result;
|
||||
if (!SD.exists(path)) return result;
|
||||
File dir = SD.open(path);
|
||||
File entry;
|
||||
while (entry = dir.openNextFile()) {
|
||||
String filename = entry.name();
|
||||
if (entry.isDirectory()) filename.concat("/");
|
||||
result.push_back(filename);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String Player::_find_album_dir(String id) {
|
||||
String id_with_divider = id + " - ";
|
||||
File root = SD.open("/");
|
||||
File entry;
|
||||
String result = String("");
|
||||
while ((result.length()==0) && (entry = root.openNextFile())) {
|
||||
String name = entry.name();
|
||||
if (entry.isDirectory() && (name.startsWith(id_with_divider) || name.equals(id))) {
|
||||
result = name;
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
root.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
std::list<String> Player::_files_in_dir(String path) {
|
||||
DEBUG("Examining folder %s...\n", path.c_str());
|
||||
if (!path.startsWith("/")) path = String("/") + path;
|
||||
if (!path.endsWith("/")) path.concat("/");
|
||||
std::list<String> result;
|
||||
if (!SD.exists(path)) return result;
|
||||
File dir = SD.open(path);
|
||||
File entry;
|
||||
while (entry = dir.openNextFile()) {
|
||||
String filename = entry.name();
|
||||
if (!entry.isDirectory() &&
|
||||
!filename.startsWith(".") &&
|
||||
( filename.endsWith(".mp3") ||
|
||||
filename.endsWith(".ogg") ||
|
||||
filename.endsWith(".wma") ||
|
||||
filename.endsWith(".mp4") ||
|
||||
filename.endsWith(".mpa"))) {
|
||||
DEBUG(" Adding entry %s\n", filename.c_str());
|
||||
result.push_back(path + filename);
|
||||
} else {
|
||||
DEBUG(" Ignoring entry %s\n", filename.c_str());
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
result.sort();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Player::play_album(String album) {
|
||||
//if (_state==playing) stop();
|
||||
album_state s = _last_tracks[album.c_str()];
|
||||
DEBUG("Last index for album %s was %d,%d\n", album.c_str(), s.index, s.position);
|
||||
return play_song(album, s.index, s.position);
|
||||
}
|
||||
|
||||
bool Player::play_song(String album, uint8_t index, uint32_t skip_to) {
|
||||
if (_state != idle) return false;
|
||||
DEBUG("Trying to play song at index %d, offset %d of album %s\n", index, skip_to, album.c_str());
|
||||
String path = _find_album_dir(album);
|
||||
if (path.length()==0) {
|
||||
ERROR("Could not find album.\n");
|
||||
return false;
|
||||
} else {
|
||||
INFO("Found album in dir '%s'.\n", path.c_str());
|
||||
}
|
||||
std::list<String> files = _files_in_dir(path);
|
||||
_playing_album_songs = files.size();
|
||||
DEBUG("Found %d songs in album\n", files.size());
|
||||
if (index >= files.size()) {
|
||||
ERROR("No matching file found - not playing.\n");
|
||||
return false;
|
||||
}
|
||||
//std::list<String>::iterator it = files.begin();
|
||||
//std::advance(it, index);
|
||||
String file = *(std::next(files.begin(), index));
|
||||
_state = playing;
|
||||
_playing_album = album;
|
||||
_playing_index = index;
|
||||
_set_last_track(album.c_str(), index, skip_to);
|
||||
_play_file(file, skip_to);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Player::play_system_sound(String filename) {
|
||||
//String file = String("/system/") + filename;
|
||||
String file = filename;
|
||||
if (!SD.exists(file)) {
|
||||
ERROR("File %s does not exist!\n", file.c_str());
|
||||
return;
|
||||
}
|
||||
if (_state == playing) {
|
||||
stop();
|
||||
_state = system_sound_while_playing;
|
||||
} else {
|
||||
_state = system_sound_while_stopped;
|
||||
}
|
||||
_play_file(file, 0);
|
||||
}
|
||||
|
||||
void Player::_play_file(String file, uint32_t file_offset) {
|
||||
INFO("play_file('%s', %d)\n", file.c_str(), file_offset);
|
||||
_file = SD.open(file);
|
||||
if (!_file) return;
|
||||
|
||||
DEBUG("Resetting SCI_DECODE_TIME...\n");
|
||||
_write_control_register(SCI_DECODE_TIME, 0);
|
||||
DEBUG("Resetting SS_DO_NOT_JUMP...\n");
|
||||
_write_control_register(SCI_STATUS, _read_control_register(SCI_STATUS) & ~SS_DO_NOT_JUMP);
|
||||
delay(100);
|
||||
|
||||
if (file_offset == 0) {
|
||||
_file.seek(_id3_tag_offset(_file));
|
||||
}
|
||||
_refills = 0;
|
||||
_current_play_position = _file.position();
|
||||
_skip_to = file_offset;
|
||||
if (_skip_to>0) _mute();
|
||||
INFO("Now playing.\n");
|
||||
}
|
||||
|
||||
uint32_t Player::_id3_tag_offset(File f) {
|
||||
uint32_t original_position = f.position();
|
||||
uint32_t offset = 0;
|
||||
if (f.read()=='I' && f.read()=='D' && f.read()=='3') {
|
||||
DEBUG("ID3 tag found\n");
|
||||
// Skip ID3 tag version
|
||||
f.read(); f.read();
|
||||
byte tags = f.read();
|
||||
bool footer_present = tags & 0x10;
|
||||
DEBUG("ID3 footer found: %d\n", footer_present);
|
||||
for (byte i=0; i<4; i++) {
|
||||
offset <<= 7;
|
||||
offset |= (0x7F & f.read());
|
||||
}
|
||||
offset += 10;
|
||||
if (footer_present) offset += 10;
|
||||
DEBUG("ID3 tag length is %d bytes.\n", offset);
|
||||
} else {
|
||||
DEBUG("No ID3 tag found\n");
|
||||
}
|
||||
f.seek(original_position);
|
||||
return offset;
|
||||
}
|
||||
|
||||
void Player::_flush(uint count, int8_t byte) {
|
||||
SPIMaster::enable(XDCS);
|
||||
SPI.beginTransaction(*_spi_settings);
|
||||
for(uint i=0; i<count; i++) {
|
||||
_wait();
|
||||
SPI.transfer(byte);
|
||||
}
|
||||
SPI.endTransaction();
|
||||
}
|
||||
|
||||
void Player::_finish_playing() {
|
||||
uint8_t endbyte = _get_endbyte();
|
||||
_flush(2052, endbyte);
|
||||
_write_control_register(SCI_MODE, _read_control_register(SCI_MODE) | SM_CANCEL);
|
||||
for (int i=0; i<64; i++) {
|
||||
_flush(32, endbyte);
|
||||
uint16_t mode = _read_control_register(SCI_MODE);
|
||||
if ((mode & SM_CANCEL) == 0) return;
|
||||
}
|
||||
// If we reached this, the Chip didn't stop. That should not happen.
|
||||
// (That's written in the manual.)
|
||||
// Reset the chip.
|
||||
_init();
|
||||
}
|
||||
|
||||
void Player::stop() {
|
||||
if (_state != playing /* && _state != system_sound_while_playing && _state != system_sound_while_stopped*/) return;
|
||||
INFO("Stopping...\n");
|
||||
if (_state == playing) {
|
||||
_set_last_track(_playing_album.c_str(), _playing_index, (uint32_t)_file.position());
|
||||
}
|
||||
_state = stopping;
|
||||
_stop_delay = 0;
|
||||
_write_control_register(SCI_MODE, _read_control_register(SCI_MODE) | SM_CANCEL);
|
||||
uint8_t endbyte = _get_endbyte();
|
||||
while (true) {
|
||||
_refill();
|
||||
uint16_t mode = _read_control_register(SCI_MODE);
|
||||
if ((mode & SM_CANCEL) == 0) {
|
||||
_flush(2052, endbyte);
|
||||
_finish_stopping();
|
||||
break;
|
||||
} else if (_stop_delay > 2048) {
|
||||
init();
|
||||
break;
|
||||
}
|
||||
_stop_delay++;
|
||||
}
|
||||
}
|
||||
|
||||
void Player::_finish_stopping() {
|
||||
_state = idle;
|
||||
if (_file) {
|
||||
_file.close();
|
||||
}
|
||||
INFO("Stopped.\n");
|
||||
}
|
||||
|
||||
void Player::_refill() {
|
||||
SPIMaster::enable(PIN_SD_CS);
|
||||
_refills++;
|
||||
if (_refills % 1000 == 0) DEBUG(".");
|
||||
uint8_t result = _file.read(_buffer, sizeof(_buffer));
|
||||
if (result == 0) {
|
||||
// File is over.
|
||||
DEBUG("EOF reached.\n");
|
||||
_finish_playing();
|
||||
if (_state == system_sound_while_playing) {
|
||||
_finish_stopping();
|
||||
play_album(_playing_album);
|
||||
return;
|
||||
} else if (_state == system_sound_while_stopped) {
|
||||
_finish_stopping();
|
||||
return;
|
||||
}
|
||||
|
||||
_finish_stopping();
|
||||
bool result = play_song(_playing_album, _playing_index + 1);
|
||||
if (!result) {
|
||||
_set_last_track(_playing_album.c_str(), 0, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_current_play_position+=result;
|
||||
_write_data(_buffer);
|
||||
|
||||
if (_skip_to > 0) {
|
||||
if (_skip_to > _file.position()) {
|
||||
uint16_t status = _read_control_register(SCI_STATUS);
|
||||
if ((status & SS_DO_NOT_JUMP) == 0) {
|
||||
DEBUG("Skipping to %d.\n", _skip_to);
|
||||
_flush(2048, _get_endbyte());
|
||||
_file.seek(_skip_to);
|
||||
_skip_to = 0;
|
||||
_unmute();
|
||||
}
|
||||
} else {
|
||||
_skip_to = 0;
|
||||
_unmute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Player::_refill_needed() {
|
||||
return _state==playing ||
|
||||
_state==stopping ||
|
||||
_state==system_sound_while_playing ||
|
||||
_state==system_sound_while_stopped;
|
||||
}
|
||||
|
||||
bool Player::loop() {
|
||||
if (digitalRead(DREQ) && _refill_needed()) {
|
||||
_refill();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Player::_set_last_track(const char* album, uint8_t index, uint32_t position) {
|
||||
DEBUG("Setting _last_track[%s]=%d,%d.\n", album, index, position);
|
||||
_last_tracks[album] = {index, position};
|
||||
}
|
82
src/playlist.cpp
Normal file
82
src/playlist.cpp
Normal file
@ -0,0 +1,82 @@
|
||||
#include "playlist.h"
|
||||
|
||||
Playlist::Playlist() {}
|
||||
|
||||
Playlist::Playlist(String id, PersistedPlaylist* p) {
|
||||
rfid_id = id;
|
||||
pp = p;
|
||||
}
|
||||
|
||||
String Playlist::get_rfid_id() {
|
||||
return rfid_id;
|
||||
}
|
||||
|
||||
void Playlist::add_file(String filename) {
|
||||
files.push_back(filename);
|
||||
}
|
||||
|
||||
void Playlist::sort() {
|
||||
std::sort(files.begin(), files.end());
|
||||
}
|
||||
|
||||
void Playlist::set_current_position(uint8_t file, uint32_t bytes) {
|
||||
log_d("Setting position: File %d, bytes %d.", file, bytes);
|
||||
current_file = file;
|
||||
current_time = bytes;
|
||||
save_current_position();
|
||||
}
|
||||
|
||||
void Playlist::save_current_position(uint32_t position) {
|
||||
if (position==0) {
|
||||
position = current_time;
|
||||
}
|
||||
log_d("Saving current position: File %d, bytes %d.", current_file, position);
|
||||
if (pp != NULL) {
|
||||
pp->file = current_file;
|
||||
pp->position = position;
|
||||
}
|
||||
}
|
||||
|
||||
String Playlist::get_current_file_name() {
|
||||
if (current_file >= files.size()) {
|
||||
Serial.printf("Requested a file number %d, which is not available in this playlist. Starting over.\n", current_file);
|
||||
set_current_position(0);
|
||||
}
|
||||
return files[current_file];
|
||||
}
|
||||
|
||||
bool Playlist::next_track() {
|
||||
if (files.size() <= current_file + 1) {
|
||||
Serial.println("next_track does not exist. Resetting playlist and returning false.");
|
||||
set_current_position(0, 0);
|
||||
return false;
|
||||
}
|
||||
set_current_position(current_file + 1, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Playlist::prev_track() {
|
||||
log_d("Playlist::prev_track called. current_file is %d", current_file);
|
||||
if (current_file == 0) {
|
||||
set_current_position(0, 0);
|
||||
} else {
|
||||
set_current_position(current_file - 1, 0);
|
||||
}
|
||||
return files.size()>0;
|
||||
}
|
||||
|
||||
void Playlist::restart() {
|
||||
current_time = 0;
|
||||
}
|
||||
|
||||
void Playlist::set_current_time(uint32_t pos) {
|
||||
set_current_position(current_file, pos);
|
||||
}
|
||||
|
||||
uint32_t Playlist::get_current_time() {
|
||||
return current_time;
|
||||
}
|
||||
|
||||
void Playlist::shuffle() {
|
||||
std::random_shuffle(files.begin(), files.end());
|
||||
}
|
85
src/playlist_manager.cpp
Normal file
85
src/playlist_manager.cpp
Normal file
@ -0,0 +1,85 @@
|
||||
#include "playlist_manager.h"
|
||||
#include "spi_master.h"
|
||||
#include <SD.h>
|
||||
|
||||
PlaylistManager::PlaylistManager() {
|
||||
SPIMaster::enable_sd();
|
||||
current_rfid_tag_id = String("");
|
||||
|
||||
if (!SD.exists("/_mapping.txt")) {
|
||||
Serial.println("WARNING: /_mapping.txt not found!");
|
||||
} else {
|
||||
map.clear();
|
||||
File f = SD.open("/_mapping.txt");
|
||||
Serial.println(" Reading /_mapping.txt...");
|
||||
while (f.available()) {
|
||||
char buffer[512];
|
||||
size_t pos = f.readBytesUntil('\n', buffer, 511);
|
||||
buffer[pos] = '\0';
|
||||
|
||||
String data = buffer;
|
||||
uint8_t eq = data.indexOf('=');
|
||||
if (eq>0 && eq<data.length()-1) {
|
||||
String rfid_id = data.substring(0, eq);
|
||||
String folder = data.substring(eq + 1);
|
||||
Serial.printf(" Adding mapping: %s=>%s\n", rfid_id.c_str(), folder.c_str());
|
||||
map[rfid_id] = PersistedPlaylist(folder);
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
Playlist PlaylistManager::get_playlist(String rfid_id) {
|
||||
if (rfid_id.equals(current_rfid_tag_id)) {
|
||||
return current_playlist;
|
||||
} else {
|
||||
if (map.count(rfid_id)==0) {
|
||||
Serial.printf("No known playlist for id %s.\n", rfid_id);
|
||||
return current_playlist;
|
||||
} else {
|
||||
PersistedPlaylist* ap = &(map[rfid_id]);
|
||||
log_d("PP status is: File %d, bytes %d.", ap->file, ap->position);
|
||||
current_playlist = Playlist(rfid_id, ap);
|
||||
String path = ap->dir;
|
||||
if (path.startsWith("/")) {
|
||||
File dir = SD.open(path);
|
||||
while(File entry = dir.openNextFile()) {
|
||||
String filename = entry.name();
|
||||
String ext = filename.substring(filename.length()-4);
|
||||
if (!entry.isDirectory() &&
|
||||
!filename.startsWith(".") &&
|
||||
ext.equals(".mp3")) {
|
||||
Serial.printf("Adding %s to the list of files\n", (path + "/" + filename).c_str());
|
||||
current_playlist.add_file(path + "/" + filename);
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
current_playlist.set_current_position(ap->file, ap->position);
|
||||
} else if (path.startsWith("http")) {
|
||||
Serial.printf("Adding URL %s to the list of files\n", path.c_str());
|
||||
current_playlist.add_file(path);
|
||||
}
|
||||
current_playlist.sort();
|
||||
current_rfid_tag_id = rfid_id;
|
||||
return current_playlist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void PlaylistManager::set_audio_current_time(uint32_t time) {
|
||||
audio_current_time = time;
|
||||
}
|
||||
|
||||
bool PlaylistManager::has_playlist(String rfid_id) {
|
||||
return map.count(rfid_id) == 1;
|
||||
}
|
||||
|
||||
String PlaylistManager::pp_to_String() {
|
||||
String s = "";
|
||||
for(const auto& kv : map) {
|
||||
s += kv.first + "=" + kv.second.file + "," + kv.second.position + '\n';
|
||||
}
|
||||
return s;
|
||||
}
|
26
src/spi_master.cpp
Normal file
26
src/spi_master.cpp
Normal file
@ -0,0 +1,26 @@
|
||||
#include "spi_master.h"
|
||||
#include <Arduino.h>
|
||||
#include "esmp3.h"
|
||||
|
||||
void SPIMaster::initialize() {
|
||||
pinMode(PIN_CS_SD, OUTPUT);
|
||||
pinMode(PIN_CS_RFID, OUTPUT);
|
||||
disable_all();
|
||||
}
|
||||
|
||||
void SPIMaster::disable_all() {
|
||||
digitalWrite(PIN_CS_SD, HIGH);
|
||||
digitalWrite(PIN_CS_RFID, HIGH);
|
||||
}
|
||||
|
||||
void SPIMaster::enable_rfid() {
|
||||
disable_all();
|
||||
digitalWrite(PIN_CS_RFID, LOW);
|
||||
delay(5);
|
||||
}
|
||||
|
||||
void SPIMaster::enable_sd() {
|
||||
disable_all();
|
||||
digitalWrite(PIN_CS_SD, LOW);
|
||||
delay(5);
|
||||
}
|
Reference in New Issue
Block a user