Compare commits

13 Commits

Author SHA1 Message Date
8f19b990ff PlaylistManager: Only search for folders, don't try it with webstreams. 2019-11-28 06:29:51 +01:00
519ac0e3bd Playlist: Album title now gets handled better. 2019-11-28 06:20:57 +01:00
651843fb06 Webinterface: Fixed usage of filenames as titles. 2019-11-28 06:20:07 +01:00
fcbbdce118 Playlist: Initialization of PlaylistEntries now uses designators. 2019-11-28 06:19:11 +01:00
6f8683ba9d WIP: Lots of streaming stuff 2019-11-27 06:51:20 +01:00
710b8a2cdc Add UserAgent, remove superfluous form of location mapping. 2019-11-20 06:17:18 +01:00
b989784fb9 You can now also play MP3s streamed from the internet. (Very rough & wonky code. More or less proof-of-concept right now.) 2019-11-20 06:13:15 +01:00
94489618ca Moved reading of SD card data into a dedicated class DataSource. 2019-11-20 05:04:27 +01:00
82d8f07eea Player: Only change volume and report a position if we are actually playing something. 2019-11-19 20:48:43 +01:00
20041dd483 Extended http_server to provide new endpoints:
/_mapping.txt, /player.json, /playlist_manager.json, /controller.json and /position.json to get the matching data as well as /cmd to send commands to.
2019-11-19 20:48:11 +01:00
4f9174d362 PlaylistManager: Extracted create_mapping_txt from _save_mapping. 2019-11-19 20:46:54 +01:00
68ecc05712 Made player and playlist_manager pubilc members of Controller. 2019-11-19 20:46:04 +01:00
5fad39ee0e Added File System Upload step to installation instructions. 2019-11-19 20:44:03 +01:00
17 changed files with 753 additions and 116 deletions

View File

@ -37,7 +37,9 @@ and dashes an alike are okay. Put the SD card into the SD card slot.
Copy `include/config.sample.h` to `include/config.h`. Modify it to at
least contain the correct login details for your WiFi.
The code then should compile in PlatformIO without errors.
The code then should compile in PlatformIO without errors. Upload it
to your ESP32. After that, upload static files using PlatformIO's task
"Upload file system image".
The serial console in PlatformIO should give you more or less useful
messages about what's going on. There will also be a line saying
@ -142,4 +144,4 @@ will only play in December. On December 1st, only track 1
will play. On December 2nd, track 2 followed by track 1. On
December 3rd, tracks 3, 1 and 2. From December 24th on, track
24 followed by tracks 1-23. So your kid will get the "daily track"
first, followed by all previous tags in the right order.
first, followed by all previous tags in the right order.

View File

@ -93,6 +93,20 @@
<span>&times;</span>
</button>
</div>
<h6>Open URL</h6>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="fa fa-link"></i>
</span>
</div>
<input type="text" class="form-control" id="input_url" />
<div class="input-group-append">
<button class="btn btn-primary" id="button_url_open">Go</button>
<button class="btn btn-danger" id="button_url_add_mapping" style="display: none;"><i class="fa fa-arrows-alt-h"></i></button>
</div>
</div>
<div class="modal-body">
<div id="albums_without_id_area">
@ -173,7 +187,7 @@ update_playlist = function(data) {
tr = $('<tr>').data('track', i);
tr.append($('<td>').html(i + 1));
tr.append($('<td>').html(data.current_track==i ? '<i class="fa fa-play"></i>' : ''));
tr.append($('<td>').html(data.files[i].substr(data.files[i].lastIndexOf('/')+1)));
tr.append($('<td>').html(data.files[i].substr(data.files[i].title)));
$('#track_list').append(tr);
}
@ -189,12 +203,10 @@ update_playlist = function(data) {
$('#button_track_prev').removeClass('btn-primary').addClass('btn-secondary', 'btn-disabled');
}
$('#album').html(data.title);
var file = data.files[data.current_track];
if (file) {
file = file.substr(1);
$('#album').html(file.substr(0, file.indexOf('/')));
file = file.substr(file.indexOf('/')+1);
$('#track').html(file.substr(0, file.lastIndexOf('.')));
$('#track').html(file.title);
}
}
@ -249,6 +261,7 @@ process_ws_message = function(event) {
for (var i=0; i<data.length; i++) {
var json = JSON.parse(data[i]);
console.log(json);
if (json === null) continue;
switch(json["_type"]) {
case "position": update_position(json); break;
case "player": update_player(json); break;
@ -275,13 +288,35 @@ $(function() {
$('#button_settings').click(function(e) { $('#settingsModal').modal('show'); });
$('#button_reset_vs1053').click(function(e) { ws.send("reset_vs1053"); $('#settingsModal').modal('hide'); });
$('#button_reboot').click(function(e) { ws.send("reboot"); $('#settingsModal').modal('hide'); });
$('#button_url_open').click(function(e) { ws.send("play " + $('#input_url').val()); $('#openModal').modal('hide');});
$('#button_add_mapping').click(function(e) {
$('#settingsModal').modal('hide');
$('#openModal').modal('show');
$('.add_mapping_button').show();
$('#button_url_open').hide();
$('#button_url_add_mapping').show();
play_on_click = false;
});
$('#openModal').on('click', '.add_mapping_button', function(e) {ws.send("add_mapping=" + $('#last_rfid_id').html() + "=" + $(e.target).parents('tr').data('folder')); $('#openModal').modal('hide'); $('.add_mapping_button').hide(); e.stopPropagation(); play_on_click=true; return false;});
$('#openModal').on('click', '.add_mapping_button', function(e) {
ws.send("add_mapping=" + $('#last_rfid_id').html() + "=" + $(e.target).parents('tr').data('folder'));
$('#openModal').modal('hide');
$('.add_mapping_button').hide();
$('#button_url_open').hide();
$('#button_url_add_mapping').show();
e.stopPropagation();
play_on_click=true;
return false;
});
$('#button_url_add_mapping').click(function(e) {
ws.send("add_mapping=" + $('#last_rfid_id').html() + "=" + $('#input_url').val());
$('#openModal').modal('hide');
$('.add_mapping_button').hide();
$('#button_url_open').hide();
$('#button_url_add_mapping').show();
e.stopPropagation();
play_on_click=true;
return false;
});
});
</script>
</html>

View File

@ -18,7 +18,6 @@ class Controller {
private:
MFRC522* _rfid;
HTTPServer* _http_server;
PlaylistManager* _pm;
ControllerState _state = NORMAL;
bool _rfid_enabled = true;
void _check_rfid();
@ -30,7 +29,7 @@ private:
bool _rfid_present = false;
String _last_rfid_uid = "";
String _last_rfid_data = "";
Player* _player;
unsigned long _last_rfid_scan_at = 0;
unsigned long _last_position_info_at = 0;
String _serial_buffer = String();
@ -42,6 +41,8 @@ private:
bool _check_button(uint8_t btn);
public:
Controller(Player* p, PlaylistManager* pm);
PlaylistManager* pm;
Player* player;
void register_http_server(HTTPServer* h);
void loop();
void send_controller_status();

54
include/data_sources.h Normal file
View File

@ -0,0 +1,54 @@
#pragma once
#include <Arduino.h>
#include <SD.h>
#include "config.h"
#include "http_client_wrapper.h"
class DataSource {
private:
public:
DataSource() {};
virtual ~DataSource() {};
virtual size_t read(uint8_t* buf, size_t len) = 0;
virtual int read() = 0;
virtual size_t position() = 0;
virtual void seek(size_t position) = 0;
virtual size_t size() = 0;
virtual void close() = 0;
virtual void skip_id3_tag() {};
virtual bool usable() = 0;
};
class SDDataSource : public DataSource {
private:
File _file;
public:
SDDataSource(String file);
~SDDataSource();
size_t read(uint8_t* buf, size_t len);
int read();
size_t position();
void seek(size_t position);
size_t size();
void close();
void skip_id3_tag();
bool usable();
};
class HTTPSDataSource : public DataSource {
private:
WiFiClient* _stream = NULL;
HTTPClientWrapper* _http = NULL;
uint32_t _position;
public:
HTTPSDataSource(String url, uint32_t offset=0);
~HTTPSDataSource();
size_t read(uint8_t* buf, size_t len);
int read();
size_t position();
void seek(size_t position);
size_t size();
void close();
bool usable();
};

View File

@ -0,0 +1,37 @@
#pragma once
#include <HTTPClient.h>
#include "config.h"
class HTTPClientWrapper {
private:
HTTPClient* _http;
uint8_t* _buffer;
uint16_t _buffer_size;
uint16_t _buffer_length;
uint16_t _buffer_position;
uint32_t _chunk_length;
bool _connected = false;
String _content_type;
uint32_t _length;
bool _request(String method, String url, uint32_t offset=0, uint8_t redirection_count=0);
WiFiClient* _stream;
bool _is_chunked;
void _read_next_chunk_header(bool first);
uint16_t _fill_buffer();
public:
HTTPClientWrapper();
~HTTPClientWrapper();
bool get(String url, uint32_t offset=0, uint8_t redirection_count=0);
bool head(String url, uint32_t offset=0, uint8_t redirection_count=0);
String getContentType();
String getString();
int read();
uint32_t read(uint8_t* dst, uint32_t len);
void close();
uint32_t getSize();
String readUntil(String sep);
String readLine();
};

View File

@ -4,6 +4,7 @@
#include <SD.h>
#include "spi_master.h"
#include "playlist.h"
#include "data_sources.h"
class Player;
@ -55,7 +56,6 @@ private:
void _flush_and_cancel();
int8_t _get_endbyte();
void _flush(uint count, int8_t fill_byte);
uint32_t _id3_tag_offset(File f);
void _play_file(String filename, uint32_t offset);
void _finish_playing();
void _finish_stopping(bool turn_speaker_off);
@ -72,7 +72,7 @@ private:
SPISettings _spi_settings_fast = SPISettings(4000000, MSBFIRST, SPI_MODE0);
SPISettings* _spi_settings = &_spi_settings_slow;
File _file;
DataSource* _file;
uint32_t _file_size = 0;
uint8_t _buffer[32];
uint32_t _current_play_position = 0;

View File

@ -2,6 +2,14 @@
#include <Arduino.h>
#include <vector>
#include <ArduinoJson.h>
#include "http_client_wrapper.h"
struct PlaylistEntry {
String filename;
String title;
bool operator<(PlaylistEntry p) { return title < p.title; }
};
class Playlist {
private:
@ -9,10 +17,17 @@ private:
uint32_t _current_track = 0;
bool _started = false;
bool _shuffled = false;
std::vector<String> _files;
std::vector<PlaylistEntry> _files;
String _title = "";
void _add_path(String path);
void _examine_http_url(String url);
void _parse_rss(HTTPClientWrapper* http);
void _parse_m3u(HTTPClientWrapper* http);
void _parse_pls(HTTPClientWrapper* http);
public:
Playlist(String path);
void start();
uint16_t get_file_count();
bool has_track_next();
bool has_track_prev();
bool track_next();

View File

@ -19,4 +19,5 @@ public:
void scan_files();
String json();
bool add_mapping(String id, String folder);
String create_mapping_txt();
};

View File

@ -17,6 +17,7 @@ build_flags=!./build_version.sh
lib_deps = MFRC522
https://github.com/me-no-dev/ESPAsyncWebServer.git
ArduinoJSON
6691 ; TinyXML
upload_port = /dev/cu.SLAB_USBtoUART
monitor_speed = 74480
;monitor_port = /dev/cu.wchusbserial1420
;monitor_port = /dev/cu.wchusbserial1420

View File

@ -5,12 +5,12 @@
#include "http_server.h"
#include <ArduinoJson.h>
Controller::Controller(Player* p, PlaylistManager* pm) {
_player = p;
_pm = pm;
Controller::Controller(Player* p, PlaylistManager* playlist_manager) {
player = p;
pm = playlist_manager;
_rfid = new MFRC522(17, MFRC522::UNUSED_PIN);
_player->register_controller(this);
player->register_controller(this);
BTN_NEXT_SETUP();
BTN_PREV_SETUP();
@ -87,7 +87,7 @@ void Controller::_check_rfid() {
_rfid_present = false;
INFO("No more RFID card.\n");
if (_state != LOCKED) {
_player->stop();
player->stop();
}
send_controller_status();
} else {
@ -106,7 +106,7 @@ void Controller::_check_rfid() {
String data = _read_rfid_data();
_last_rfid_data = data;
Playlist* pl = _pm->get_playlist_for_id(s_uid);
Playlist* pl = pm->get_playlist_for_id(s_uid);
if (data.indexOf("[lock]") != -1) {
if (_state == LOCKED) {
_state = NORMAL;
@ -152,7 +152,7 @@ void Controller::_check_rfid() {
DEBUG("ControllerState is now LOCKED.\n");
}
_player->play(pl);
player->play(pl);
//send_playlist_manager_status();
send_controller_status();
}
@ -248,38 +248,38 @@ bool Controller::process_message(String cmd) {
DEBUG("Executing command: %s\n", cmd.c_str());
if (cmd.startsWith("play ")) {
Playlist* p = _pm->get_playlist_for_folder(cmd.substring(5));
_player->play(p);
Playlist* p = pm->get_playlist_for_folder(cmd.substring(5));
player->play(p);
//} else if (cmd.equals("ls")) {
// _execute_command_ls("/");
//} else if (cmd.startsWith("ls ")) {
// _execute_command_ls(cmd.substring(3));
} else if (cmd.equals("play")) {
_player->play();
player->play();
} else if (cmd.equals("stop")) {
_player->stop();
player->stop();
} else if (cmd.equals("help")) {
_execute_command_help();
} else if (cmd.equals("-")) {
_player->vol_down();
player->vol_down();
} else if (cmd.equals("+")) {
_player->vol_up();
player->vol_up();
} else if (cmd.startsWith("volume=")) {
uint8_t vol = cmd.substring(7).toInt();
_player->set_volume(vol);
player->set_volume(vol);
} else if (cmd.equals("track_prev")) {
_player->track_prev();
player->track_prev();
} else if (cmd.equals("track_next")) {
_player->track_next();
player->track_next();
} else if (cmd.startsWith("track=")) {
uint8_t track = cmd.substring(6).toInt();
_player->set_track(track);
player->set_track(track);
} else if (cmd.equals("ids")) {
_pm->dump_ids();
pm->dump_ids();
} else if (cmd.equals("reset_vs1053")) {
_player->stop();
_player->init();
player->stop();
player->init();
} else if (cmd.equals("reboot")) {
ESP.restart();
} else if (cmd.startsWith("add_mapping=")) {
@ -287,7 +287,7 @@ bool Controller::process_message(String cmd) {
uint8_t idx = rest.indexOf('=');
String id = rest.substring(0, idx);
String folder = rest.substring(idx + 1);
_pm->add_mapping(id, folder);
pm->add_mapping(id, folder);
send_playlist_manager_status();
} else {
ERROR("Unknown command: %s\n", cmd.c_str());
@ -299,7 +299,7 @@ bool Controller::process_message(String cmd) {
void Controller::_execute_command_ls(String path) {
INFO("Listing contents of %s:\n", path.c_str());
// TODO
//std::list<String> files = _player->ls(path);
//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());
//}
@ -321,17 +321,17 @@ void Controller::_check_buttons() {
if (BTN_PREV() && _debounce_button(0)) {
if (_state == NORMAL) {
_player->track_prev();
player->track_prev();
} else {
DEBUG("Ignoring btn_prev because state is LOCKED.\n");
}
} else if (BTN_VOL_UP() && _debounce_button(1)) {
_player->vol_up();
player->vol_up();
} else if (BTN_VOL_DOWN() && _debounce_button(2)) {
_player->vol_down();
player->vol_down();
} else if (BTN_NEXT() && _debounce_button(3)) {
if (_state == NORMAL) {
_player->track_next();
player->track_next();
} else {
DEBUG("Ignoring btn_next because state is LOCKED.\n");
}
@ -360,6 +360,8 @@ String Controller::json() {
JsonObject rfid = json.createNestedObject("last_rfid");
rfid["uid"] = _last_rfid_uid;
rfid["data"] = _last_rfid_data;
json["uptime"] = millis() / 1000;
json["free_heap"] = ESP.getFreeHeap();
return json.as<String>();
}
@ -367,22 +369,22 @@ void Controller::send_player_status() {
TRACE("In send_player_status()...\n");
if (_http_server->ws->count() > 0) {
_http_server->ws->textAll(_player->json());
_http_server->ws->textAll(_player->position_json());
_http_server->ws->textAll(player->json());
_http_server->ws->textAll(player->position_json());
}
}
void Controller::send_playlist_manager_status() {
TRACE("In send_playlist_manager_status()...\n");
if (_http_server->ws->count() > 0) {
_http_server->ws->textAll(_pm->json());
_http_server->ws->textAll(pm->json());
}
}
void Controller::send_position() {
TRACE("In send_position()...\n");
if (_http_server->ws->count() > 0 && _player->is_playing()) {
_http_server->ws->textAll(_player->position_json());
if (_http_server->ws->count() > 0) {
_http_server->ws->textAll(player->position_json());
}
_last_position_info_at = millis();
}
@ -396,11 +398,11 @@ void Controller::send_controller_status() {
void Controller::inform_new_client(AsyncWebSocketClient* client) {
String s;
s += _pm->json();
s += pm->json();
s += '\n';
s += _player->json();
s += player->json();
s += '\n';
s += _player->position_json();
s += player->position_json();
s += '\n';
s += json();
client->text(s);
@ -412,6 +414,6 @@ void Controller::queue_command(String s) {
}
void Controller::update_playlist_manager() {
_pm->scan_files();
pm->scan_files();
send_playlist_manager_status();
}
}

57
src/data_sources.cpp Normal file
View File

@ -0,0 +1,57 @@
#include "data_sources.h"
////////////// SDDataSource //////////////
SDDataSource::SDDataSource(String file) { _file = SD.open(file, "r"); }
SDDataSource::~SDDataSource() { if (_file) _file.close(); }
size_t SDDataSource::read(uint8_t* buf, size_t len) { return _file.read(buf, len); }
int SDDataSource::read() { return _file.read(); }
size_t SDDataSource::position() { return _file.position(); }
void SDDataSource::seek(size_t position) { _file.seek(position); }
size_t SDDataSource::size() { return _file.size(); }
void SDDataSource::close() { _file.close(); }
bool SDDataSource::usable() { return _file; }
void SDDataSource::skip_id3_tag() {
uint32_t original_position = _file.position();
uint32_t offset = 0;
if (_file.read()=='I' && _file.read()=='D' && _file.read()=='3') {
DEBUG("ID3 tag found\n");
// Skip ID3 tag version
_file.read(); _file.read();
byte tags = _file.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 & _file.read());
}
offset += 10;
if (footer_present) offset += 10;
DEBUG("ID3 tag length is %d bytes.\n", offset);
_file.seek(offset);
} else {
DEBUG("No ID3 tag found\n");
_file.seek(original_position);
}
}
////////////// HTTPSDataSource //////////////
HTTPSDataSource::HTTPSDataSource(String url, uint32_t offset) {
_http = new HTTPClientWrapper();
if (!_http->get(url, offset)) return;
_position = 0;
}
HTTPSDataSource::~HTTPSDataSource() {
_http->close();
delete _http;
}
bool HTTPSDataSource::usable() { return _http; }
size_t HTTPSDataSource::read(uint8_t* buf, size_t len) { size_t result = _http->read(buf, len); _position += result; return result; }
int HTTPSDataSource::read() { int b = _http->read(); if (b>=0) _position++; return b; }
size_t HTTPSDataSource::position() { return _position; }
void HTTPSDataSource::seek(size_t position) { return; /* TODO */ }
size_t HTTPSDataSource::size() { return _http->getSize(); }
void HTTPSDataSource::close() { _http->close(); }

210
src/http_client_wrapper.cpp Normal file
View File

@ -0,0 +1,210 @@
#include "http_client_wrapper.h"
#include <WiFiClientSecure.h>
HTTPClientWrapper::HTTPClientWrapper() {
_buffer = new uint8_t[512];
_buffer_size = 512;
}
HTTPClientWrapper::~HTTPClientWrapper() {
if (_http) {
_http->end();
delete _http;
}
delete _buffer;
}
void HTTPClientWrapper::close() {
_http->end();
_connected = false;
}
bool HTTPClientWrapper::get(String url, uint32_t offset, uint8_t redirection_count) { return _request("GET", url, offset, redirection_count); }
bool HTTPClientWrapper::head(String url, uint32_t offset, uint8_t redirection_count) { return _request("HEAD", url, offset, redirection_count); }
bool HTTPClientWrapper::_request(String method, String url, uint32_t offset, uint8_t redirection_count) {
if (redirection_count>=5) return false;
//if (_http) delete _http;
DEBUG("%s %s\n", method.c_str(), url.c_str());
_http = new HTTPClient();
_http->setUserAgent("PodBox/0.1");
if (offset) {
String s = "bytes=";
s += offset;
s += "-";
_http->addHeader("Range: ", s);
}
const char* headers[] = {"Location", "Content-Type", "Transfer-Encoding"};
_http->collectHeaders(headers, 3);
bool result;
/*if (url.startsWith("https:")) {
BearSSL::WiFiClientSecure* client = new BearSSL::WiFiClientSecure();
client->setInsecure();
result = _http->begin(*client, url);
} else {
result = _http->begin(url);
}*/
result = _http->begin(url);
TRACE("HTTP->begin result: %d\n", result);
if (!result) return false;
int status = _http->sendRequest(method.c_str());
TRACE("HTTP Status code: %d\n", status);
if (status == HTTP_CODE_FOUND || status==HTTP_CODE_MOVED_PERMANENTLY || status==HTTP_CODE_TEMPORARY_REDIRECT) {
if (_http->hasHeader("Location")) {
url = _http->header("Location");
_http->end();
delete _http;
_http = NULL;
return _request(method, url, offset, redirection_count+1);
} else {
ERROR("Got redirection HTTP code, but no Location header.\n");
delete _http;
_http = NULL;
return false;
}
} else if (status != HTTP_CODE_OK) {
DEBUG("Unexpected HTTP return code %d. Cancelling.\n", status);
return false;
}
_connected = true;
_length = _http->getSize() + offset;
if (_http->hasHeader("Content-Type")) {
_content_type = _http->header("Content-Type");
} else {
_content_type = "";
}
_is_chunked = (_http->hasHeader("Transfer-Encoding")) && (_http->header("Transfer-Encoding").indexOf("chunked")!=-1);
_stream = _http->getStreamPtr();
if (_is_chunked) {
_read_next_chunk_header(true);
}
return true;
}
void HTTPClientWrapper::_read_next_chunk_header(bool first) {
if (!_connected) {
_chunk_length = 0;
return;
}
if (!first) {
// read() returns an error if no bytes is available right at this moment.
// So we wait until 2 bytes are available or the connection times out.
while (_stream->connected() && !_stream->available()) { delay(1); }
int c1 = _stream->read();
while (_stream->connected() && !_stream->available()) { delay(1); }
int c2 = _stream->read();
if (c1==-1 || c2==-1) {
ERROR("Connection timeout.\n");
DEBUG("_stream.connected() returns %d\n", _stream->connected());
_chunk_length = 0;
_connected = false;
return;
} else if (c1!='\r' || c2!='\n') {
ERROR("Invalid chunk border found. Found: 0x%02X 0x%02X\n", c1, c2);
_chunk_length = 0;
_connected = false;
return;
}
}
String chunk_header = _stream->readStringUntil('\n');
chunk_header.trim();
_chunk_length = strtol(chunk_header.c_str(), NULL, 16);
if (_chunk_length == 0) {
_connected = false;
TRACE("Empty chunk found -> EOF reached.\n");
} else {
TRACE("Chunk found. Length: %d\n", _chunk_length);
}
}
uint16_t HTTPClientWrapper::_fill_buffer() {
if (!_connected) {
_buffer_position = 0;
_buffer_length = 0;
return 0;
}
uint16_t bytes_to_fill = _buffer_size;
uint16_t bytes_filled = 0;
while (bytes_to_fill > 0) {
uint16_t bytes_to_request = bytes_to_fill;
if (_is_chunked && _chunk_length < bytes_to_fill) bytes_to_request = _chunk_length;
TRACE("fill_buffer loop. _is_chunked: %d, _chunk_length: %d, _buffer_size: %d, bytes_filled: %d, bytes_to_fill: %d, bytes_to_request: %d", _is_chunked, _chunk_length, _buffer_size, bytes_filled, bytes_to_fill, bytes_to_request);
uint16_t result = _stream->readBytes(_buffer + bytes_filled, bytes_to_request);
TRACE(", result: %d\n", result);
bytes_filled += result;
bytes_to_fill -= result;
if (_is_chunked) {
_chunk_length -= result;
if (_chunk_length == 0) _read_next_chunk_header(false);
}
if (result == 0) {
_connected = false;
break;
}
}
_buffer_position = 0;
_buffer_length = bytes_filled;
TRACE("Buffer filled. _buffer_length: %d\n", _buffer_length);
return bytes_filled;
}
String HTTPClientWrapper::getContentType() {
return _content_type;
}
int HTTPClientWrapper::read() {
if (_buffer_position >= _buffer_length) _fill_buffer();
if (_buffer_position >= _buffer_length) return -1;
return _buffer[_buffer_position++];
}
uint32_t HTTPClientWrapper::read(uint8_t* dst, uint32_t len) {
TRACE("Reading %d bytes...\n", len);
uint32_t bytes_filled = 0;
while (1) {
if (_buffer_position >= _buffer_length) _fill_buffer();
if (_buffer_position >= _buffer_length) break;
uint32_t bytes_to_fill = len;
if (bytes_to_fill > _buffer_length - _buffer_position) bytes_to_fill = _buffer_length - _buffer_position;
TRACE("read_loop: _buffer_length=%d, _buffer_position=%d, len=%d, bytes_to_fill=%d\n", _buffer_length, _buffer_position, len, bytes_to_fill);
memcpy(dst + bytes_filled, _buffer + _buffer_position, bytes_to_fill);
_buffer_position += bytes_to_fill;
bytes_filled += bytes_to_fill;
len -= bytes_to_fill;
if (bytes_to_fill==0 || len==0) break;
}
return bytes_filled;
}
uint32_t HTTPClientWrapper::getSize() {return _length; }
String HTTPClientWrapper::readUntil(String sep) {
String result = "";
while(true) {
int i = read();
if (i==-1) break;
char c = i;
if (sep.indexOf(c)!=-1) {
// separator
if (result.length()>0) break;
} else {
result.concat(c);
}
}
return result;
}
String HTTPClientWrapper::readLine() {
return readUntil("\n\r");
}

View File

@ -10,8 +10,15 @@ HTTPServer::HTTPServer(Player* p, Controller* c) {
ws = new AsyncWebSocket("/ws");
_server->addHandler(ws);
ws->onEvent([&](AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len){this->_onEvent(server, client, type, arg, data, len);});
_server->on("/", [&](AsyncWebServerRequest* req) {req->send(SPIFFS, "/index.html", "text/html");});
_server->on("/", HTTP_GET, [&](AsyncWebServerRequest* req) {req->send(SPIFFS, "/index.html", "text/html");});
_server->on("/upload", HTTP_POST, [](AsyncWebServerRequest* req) {req->send(200); }, ([&](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final){this->_handle_upload(request, filename, index, data, len, final);}));
_server->on("/_mapping.txt", HTTP_GET, [&](AsyncWebServerRequest* req) {req->send(200, "text/plain", _controller->pm->create_mapping_txt());});
_server->on("/player.json", HTTP_GET, [&](AsyncWebServerRequest* req) {req->send(200, "application/json", _controller->player->json());});
_server->on("/playlist_manager.json", HTTP_GET, [&](AsyncWebServerRequest* req) {req->send(200, "application/json", _controller->pm->json());});
_server->on("/controller.json", HTTP_GET, [&](AsyncWebServerRequest* req) {req->send(200, "application/json", _controller->json());});
_server->on("/position.json", HTTP_GET, [&](AsyncWebServerRequest* req) {req->send(200, "application/json", _controller->player->position_json());});
_server->on("/cmd", HTTP_POST, [&](AsyncWebServerRequest *req) {req->send(200); }, NULL, [&](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {_controller->queue_command((char*)data);});
_server->begin();
MDNS.addService("http", "tcp", 80);
}
@ -129,8 +136,9 @@ void HTTPServer::_onEvent(AsyncWebSocket * server, AsyncWebSocketClient * client
} else if (type==WS_EVT_DATA) {
AwsFrameInfo* info = (AwsFrameInfo*) arg;
if (info->final && info->index==0 && info->len==len && info->opcode==WS_TEXT) {
data[len]='\0';
DEBUG("Received ws message: %s\n", (char*)data);
_controller->queue_command((char*)data);
}
}
}
}

View File

@ -18,6 +18,19 @@ HTTPServer* http_server;
uint8_t SPIMaster::state = 0;
bool connect_to_wifi(String ssid, String pass) {
TRACE("Connecting to wifi \"%s\"...\n", ssid.c_str());
WiFi.mode(WIFI_AP_STA);
WiFi.begin(ssid.c_str(), pass.c_str());
if (WiFi.waitForConnectResult() != WL_CONNECTED) {
DEBUG("Could not connect to wifi \"%s\".\n", ssid.c_str());
return false;
} else {
INFO("Connected to \"%s\". IP address: %s\n", ssid.c_str(), WiFi.localIP().toString().c_str());
}
return true;
}
void setup() {
delay(500);
Serial.begin(74880);
@ -58,15 +71,35 @@ void setup() {
controller = new Controller(player, pm);
INFO("Player and controller initialized.\n");
DEBUG("Connecting to wifi \"%s\"...\n", WIFI_SSID);
WiFi.mode(WIFI_AP_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
if (WiFi.waitForConnectResult() != WL_CONNECTED) {
ERROR("Could not connect to Wifi. Rebooting.");
delay(1000);
ESP.restart();
bool connected = false;
INFO("Connecting to WiFi...\n");
SPIMaster::select_sd();
if (SD.exists("/_wifis.txt")) {
DEBUG("Reading /_wifis.txt\n");
File f = SD.open("/_wifis.txt", "r");
while (String line = f.readStringUntil('\n')) {
if (line.length()==0 || line.startsWith("#") || line.indexOf('=')==-1) {
continue;
}
String ssid = line.substring(0, line.indexOf('='));
String pass = line.substring(line.indexOf('=')+1);
connected = connect_to_wifi(ssid, pass);
if (connected) break;
}
f.close();
} else {
File f = SD.open("/_wifis.txt", "w");
f.print("# WiFi definitions. Syntax: <SSID>=<PASS>. Lines starting with # are ignored. Example:\n# My WiFi=VerySecretPassword\n");
f.close();
}
SPIMaster::select_sd(false);
if (!connected) {
DEBUG("Trying hardcoded WiFi data...\n");
connected = connect_to_wifi(WIFI_SSID, WIFI_PASS);
}
if (!connected) {
INFO("No WiFi connection!\n");
}
INFO("WiFi connected. IP address: %s\n", WiFi.localIP().toString().c_str());
MDNS.begin("esmp3");

View File

@ -463,12 +463,14 @@ void Player::set_volume(uint8_t vol, bool save) {
}
void Player::vol_up() {
if (!is_playing()) return;
uint8_t vol = _volume + VOLUME_STEP;
if (vol > VOLUME_MAX) vol=VOLUME_MAX;
set_volume(vol);
}
void Player::vol_down() {
if (!is_playing()) return;
uint8_t vol = _volume - VOLUME_STEP;
if (vol < VOLUME_MIN) vol=VOLUME_MIN;
set_volume(vol);
@ -531,6 +533,7 @@ bool Player::play() {
if (_state == sleeping || _state == recording) _wakeup();
if (_state != idle) return false;
if (_current_playlist == NULL) return false;
if (_current_playlist->get_file_count()==0) return false;
_current_playlist->start();
String file = _current_playlist->get_current_file();
uint32_t position = _current_playlist->get_position();
@ -543,10 +546,16 @@ bool Player::play() {
void Player::_play_file(String file, uint32_t file_offset) {
INFO("play_file('%s', %d)\n", file.c_str(), file_offset);
_spi->select_sd();
_file = SD.open(file);
_file_size = _file.size();
if (file.startsWith("/")) {
_file = new SDDataSource(file);
} else if (file.startsWith("http")) {
_file = new HTTPSDataSource(file);
} else {
return;
}
_file_size = _file->size();
_spi->select_sd(false);
if (!_file) {
if (!_file || !_file->usable()) {
DEBUG("Could not open file %s", file.c_str());
return;
}
@ -559,10 +568,10 @@ void Player::_play_file(String file, uint32_t file_offset) {
_spi->select_sd();
if (file_offset == 0) {
_file.seek(_id3_tag_offset(_file));
_file->skip_id3_tag();
}
_refills = 0;
_current_play_position = _file.position();
_current_play_position = _file->position();
_spi->select_sd(false);
_skip_to = file_offset;
if (_skip_to>0) _mute();
@ -571,30 +580,6 @@ void Player::_play_file(String file, uint32_t file_offset) {
_controller->send_player_status();
}
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) {
_spi->select_vs1053_xdcs();
SPI.beginTransaction(*_spi_settings);
@ -650,7 +635,8 @@ void Player::_finish_stopping(bool turn_speaker_off) {
_state = idle;
_stopped_at = millis();
if (_file) {
_file.close();
_file->close();
delete _file;
}
_current_play_position = 0;
_file_size = 0;
@ -662,7 +648,7 @@ void Player::_refill() {
_spi->select_sd();
_refills++;
if (_refills % 1000 == 0) DEBUG(".");
uint8_t result = _file.read(_buffer, sizeof(_buffer));
uint8_t result = _file->read(_buffer, sizeof(_buffer));
_spi->select_sd(false);
if (result == 0) {
// File is over.
@ -684,13 +670,13 @@ void Player::_refill() {
_write_data(_buffer);
if (_skip_to > 0) {
if (_skip_to > _file.position()) {
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());
_spi->select_sd();
_file.seek(_skip_to);
_file->seek(_skip_to);
_spi->select_sd(false);
_skip_to = 0;
_unmute();
@ -755,6 +741,7 @@ String Player::json() {
}
String Player::position_json() {
if (!is_playing()) return "null";
DynamicJsonDocument json(200);
json["_type"] = "position";
json["position"] = _current_play_position;

View File

@ -4,9 +4,18 @@
#include <SD.h>
#include <algorithm>
#include <ArduinoJson.h>
#include <TinyXML.h>
Playlist::Playlist(String path) {
// Add files to _files
if (path.startsWith("/")) {
_add_path(path);
} else if (path.startsWith("http")) {
_examine_http_url(path);
}
if (_title.length()==0) _title=path;
}
void Playlist::_add_path(String path) {
SPIMaster::select_sd();
TRACE("Examining folder %s...\n", path.c_str());
if (!path.startsWith("/")) path = String("/") + path;
@ -15,6 +24,11 @@ Playlist::Playlist(String path) {
SPIMaster::select_sd(false);
return;
}
_title = path.substring(1);
int idx = _title.indexOf('/');
if (idx>0) {
_title.remove(idx);
}
File dir = SD.open(path);
File entry;
while (entry = dir.openNextFile()) {
@ -29,7 +43,8 @@ Playlist::Playlist(String path) {
ext.equals(".mp4") ||
ext.equals(".mpa"))) {
TRACE(" Adding entry %s\n", entry.name());
_files.push_back(entry.name());
String title = filename.substring(0, filename.length() - 4);
_files.push_back({.filename=entry.name(), .title=title});
bool non_ascii_chars = false;
for(int i=0; i<filename.length(); i++) {
char c = filename.charAt(i);
@ -51,6 +66,175 @@ Playlist::Playlist(String path) {
std::sort(_files.begin(), _files.end());
}
void Playlist::_examine_http_url(String url) {
HTTPClientWrapper* http = new HTTPClientWrapper();
if (!http->get(url)) {
DEBUG("Could not GET %s.\n", url.c_str());
return;
}
String ct = http->getContentType();
DEBUG("Content-Type is %s.\n", ct.c_str());
if (ct.startsWith("audio/x-mpegurl")) {
_parse_m3u(http);
} else if (ct.startsWith("audio/")) {
_files.push_back({.filename=url, .title=url});
} else if (ct.startsWith("application/rss+xml")) {
_parse_rss(http);
} else if (ct.startsWith("application/pls+xml")) {
_parse_pls(http);
} else {
ERROR("Unknown content type %s.\n", ct.c_str());
}
http->close();
delete http;
}
std::vector<PlaylistEntry>* xml_files_ptr = NULL;
String xml_last_tag = "";
String xml_title = "";
String xml_album_title = "";
String xml_url = "";
String xml_enclosure_url = "";
bool xml_enclosure_is_audio = false;
void xmlcb(uint8_t status, char* tagName, uint16_t tagLen, char* data, uint16_t dataLen) {
String tag(tagName);
if (status & STATUS_START_TAG) xml_last_tag = tag;
if (tag.equals("/rss/channel/title") && (status & STATUS_TAG_TEXT)) {
xml_album_title = data;
} else if (tag.endsWith("/item") && (status & STATUS_START_TAG)) {
xml_title = "";
xml_url = "";
} else if (tag.endsWith("/item/title") && (status & STATUS_TAG_TEXT)) {
xml_title = String(data);
//} else if (xml_last_tag.endsWith("/item/enclosure") && (status & STATUS_ATTR_TEXT)) {
// DEBUG("tag: %s, data: %s\n", tag.c_str(), data);
} else if (xml_last_tag.endsWith("/item/enclosure") && tag.equals("type") && (status & STATUS_ATTR_TEXT) && String(data).indexOf("audio/")>=0) {
DEBUG("enclosure is audio\n");
xml_enclosure_is_audio = true;
} else if (xml_last_tag.endsWith("/item/enclosure") && tag.equals("url") && (status & STATUS_ATTR_TEXT)) {
DEBUG("found url\n");
xml_enclosure_url = String(data);
} else if (tag.endsWith("/item/enclosure") && (status & STATUS_END_TAG)) {
DEBUG("end of enclosure. xml_enclosure_is_audio: %d, xml_enclosure_url: %s\n", xml_enclosure_is_audio, xml_enclosure_url.c_str());
if (xml_enclosure_is_audio && xml_enclosure_url.length()>0) {
xml_url = xml_enclosure_url;
}
xml_enclosure_is_audio = false;
xml_enclosure_url = "";
} else if (tag.endsWith("/item") && (status & STATUS_END_TAG)) {
if (xml_title.length()>0 && xml_url.length()>0) {
DEBUG("Adding playlist entry: '%s' => '%s'\n", xml_title.c_str(), xml_url.c_str());
xml_files_ptr->push_back({xml_url, xml_title});
}
}
}
void Playlist::_parse_rss(HTTPClientWrapper* http) {
DEBUG("RSS parser running.\n");
// http is already initialized
int i;
TinyXML xml;
uint8_t* buffer = new uint8_t[150];
xml.init(buffer, 150, &xmlcb);
xml_files_ptr = &_files;
xml_title = "";
xml_album_title = "";
xml_url = "";
xml_enclosure_is_audio = false;
xml_enclosure_url = "";
while ((i = http->read()) >= 0) {
xml.processChar(i);
}
xml_files_ptr = NULL;
if (xml_album_title.length()>0) {
_title = xml_album_title;
}
xml_album_title = "";
delete buffer;
// don't close http at the end
DEBUG("RSS parser finished.\n");
}
void Playlist::_parse_m3u(HTTPClientWrapper* http) {
// http is already initialized
String line = "";
String title = "";
int i;
do {
i = http->read();
char c = i;
if (i>=-1 && c!='\r' && c!='\n') {
line += c;
} else {
if (line.equals("#EXTM3U")) {
// Do nothing
} else if (line.startsWith("#EXTINF")) {
int idx = line.indexOf(",");
if (idx>4) {
// Get the title
title = line.substring(idx+1);
if (_title.length()==0) _title=title;
}
} else if (line.startsWith("http")) {
if (title.length()==0) title = line;
_files.push_back({.filename=line, .title=title});
title = "";
}
line = "";
}
} while (i>=0);
// don't close http at the end
}
void Playlist::_parse_pls(HTTPClientWrapper* http) {
// http is already initialized
String line;
String title = "";
String url = "";
int last_index = -1;
int index;
while(true) {
line = http->readLine();
if (line.startsWith("Title")) {
uint8_t eq_idx = line.indexOf('=');
if (eq_idx==-1) continue;
index = line.substring(5, eq_idx-4).toInt();
title = line.substring(eq_idx+1);
if (index != last_index) {
url = "";
last_index = index;
}
} else if (line.startsWith("File")) {
uint8_t eq_idx = line.indexOf('=');
if (eq_idx==-1) continue;
index = line.substring(5, eq_idx-4).toInt();
url = line.substring(eq_idx+1);
if (index != last_index) {
title = "";
last_index = index;
}
}
if (title.length()>0 && url.length()>0) {
_files.push_back({.filename=url, .title=title});
last_index = -1;
title = "";
url = "";
}
}
// don't close http at the end
}
uint16_t Playlist::get_file_count() {
return _files.size();
}
void Playlist::start() {
_started = true;
}
@ -100,7 +284,7 @@ void Playlist::shuffle(uint8_t random_offset) {
int j = random(random_offset, _files.size()-1);
if (i!=j) {
TRACE(" Swapping elements %d and %d.\n", i, j);
String temp = _files[i];
PlaylistEntry temp = _files[i];
_files[i] = _files[j];
_files[j] = temp;
}
@ -127,7 +311,7 @@ void Playlist::reset() {
}
String Playlist::get_current_file() {
return _files[_current_track];
return _files[_current_track].filename;
}
uint32_t Playlist::get_position() {
@ -144,15 +328,18 @@ bool Playlist::is_fresh() {
void Playlist::dump() {
for (int i=0; i<_files.size(); i++) {
DEBUG(" %02d %2s %s\n", i+1, (i==_current_track) ? "->" : "", _files[i].c_str());
DEBUG(" %02d %2s %s\n", i+1, (i==_current_track) ? "->" : "", _files[i].filename.c_str());
}
}
void Playlist::json(JsonObject json) {
json["_type"] = "playlist";
json["title"] = _title;
JsonArray files = json.createNestedArray("files");
for (String file: _files) {
files.add(file);
for (PlaylistEntry entry: _files) {
JsonObject o = files.createNestedObject();
o["filename"] = entry.filename;
o["title"] = entry.title;
}
json["current_track"] = _current_track;
json["has_track_next"] = has_track_next();

View File

@ -39,16 +39,18 @@ void PlaylistManager::scan_files() {
String folder = data.substring(eq + 1);
TRACE(" Adding mapping: %s=>%s\n", rfid_id.c_str(), folder.c_str());
_map[rfid_id] = folder;
bool found=false;
for (String f: folders) {
if (f.equals(folder)) {
found = true;
break;
if (folder.charAt(0)=='/') {
bool found=false;
for (String f: folders) {
if (f.equals(folder)) {
found = true;
break;
}
}
if (!found) {
INFO("WARNING: Found mapping for RFID id %s which maps to non-existing folder %s!\n", rfid_id.c_str(), folder.c_str());
}
}
if (!found) {
INFO("WARNING: Found mapping for RFID id %s which maps to non-existing folder %s!\n", rfid_id.c_str(), folder.c_str());
}
}
}
@ -143,6 +145,16 @@ bool PlaylistManager::add_mapping(String id, String folder) {
void PlaylistManager::_save_mapping() {
SPIMaster::select_sd();
File f = SD.open("/_mapping.txt", "w");
String s = create_mapping_txt();
unsigned char* buf = new unsigned char[s.length()];
s.getBytes(buf, s.length());
f.write(buf, s.length()-1);
f.close();
SPIMaster::select_sd(false);
delete buf;
}
String PlaylistManager::create_mapping_txt() {
String s;
for(std::map<String, String>::iterator it = _map.begin(); it != _map.end(); it++) {
s += it->first;
@ -150,10 +162,5 @@ void PlaylistManager::_save_mapping() {
s += it->second;
s += '\n';
}
unsigned char* buf = new unsigned char[s.length()];
s.getBytes(buf, s.length());
f.write(buf, s.length()-1);
f.close();
SPIMaster::select_sd(false);
delete buf;
}
return s;
}