Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docker/MQTTManager/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ find_package(CURL REQUIRED)
find_package(nlohmann_json REQUIRED)
find_package(ixwebsocket REQUIRED)
find_package(tz REQUIRED)
find_package(date REQUIRED)
find_package(protobuf REQUIRED)
find_package(SqliteOrm REQUIRED)
find_package(stb REQUIRED)
Expand Down Expand Up @@ -117,7 +118,7 @@ target_link_libraries(MQTTManager_Thermostat MQTTManager_HomeAssistantManager MQ
add_library(MQTTManager_MediaPlayer STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/media_player/media_player.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/media_player/media_player.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/media_player/home_assistant_media_player.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/media_player/home_assistant_media_player.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/media_player/home_assistant_source_volume.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/media_player/home_assistant_source_volume.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/media_player/home_assistant_source_player.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/media_player/home_assistant_source_player.cpp)
set_target_properties(MQTTManager_MediaPlayer PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/media_player/media_player.hpp)
target_include_directories(MQTTManager_MediaPlayer PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY})
target_link_libraries(MQTTManager_MediaPlayer MQTTManager_HomeAssistantManager MQTTManager_WebHelper spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager gtest::gtest)
target_link_libraries(MQTTManager_MediaPlayer MQTTManager_HomeAssistantManager MQTTManager_WebHelper spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager date::date Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager gtest::gtest)

add_library(MQTTManager_Switch STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/home_assistant_switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/home_assistant_switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/openhab_switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/openhab_switch.hpp)
set_target_properties(MQTTManager_Switch PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/home_assistant_switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/openhab_switch.hpp)
Expand Down
2 changes: 2 additions & 0 deletions docker/MQTTManager/conanfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def requirements(self):
self.requires("nlohmann_json/3.12.0")
self.requires("ixwebsocket/11.4.5")
self.requires("tz/2023c")
self.requires("date/3.0.5") # Header-only, for parsing Home Assistant timestamps
# self.requires("inja/3.4.0")
self.requires("boost/1.84.0")
self.requires("protobuf/5.27.0")
Expand All @@ -27,6 +28,7 @@ def configure(self):
self.options["boost"].without_stacktrace = False
self.options["boost"].with_stacktrace_backtrace = True
self.options["sqlite_orm"].SQLITE_ORM_ENABLE_CXX_17 = True
self.options["date"].header_only = True

def layout(self):
cmake_layout(self)
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@
#include <algorithm>
#include <boost/algorithm/string/predicate.hpp>
#include <boost/bind.hpp>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <date/date.h>
#include <home_assistant_manager/home_assistant_manager.hpp>
#include <list>
#include <mqtt_manager_config/mqtt_manager_config.hpp>
#include <mutex>
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
#include <sstream>
#include <string>
#include <web_helper/WebHelper.hpp>

Expand Down Expand Up @@ -218,6 +221,7 @@ void HomeAssistantMediaPlayer::home_assistant_event_callback(nlohmann::json data
this->_can_mute = supported_features & HomeAssistantMediaPlayer::feature_volume_mute;
this->_set_player_sources(HomeAssistantSourcePlayer::sources_from_attributes(attributes));
}
this->_update_media_position(attributes);

std::shared_ptr<HomeAssistantSourceVolumeStrategy> strategy;
{
Expand Down Expand Up @@ -251,6 +255,44 @@ void HomeAssistantMediaPlayer::_call_service(const std::string &service, nlohman
HomeAssistantManager::send_json(command);
}

void HomeAssistantMediaPlayer::_update_media_position(const nlohmann::json &attributes) {
// Home Assistant only has a position for media that is playing or paused. The position is
// only meaningful together with the time it was taken at, so both are needed.
std::optional<int64_t> updated_at;
if (attributes.contains("media_position_updated_at") && attributes["media_position_updated_at"].is_string()) {
updated_at = HomeAssistantMediaPlayer::parse_timestamp(attributes["media_position_updated_at"].get<std::string>());
if (!updated_at.has_value()) {
SPDLOG_WARN("Could not parse media_position_updated_at '{}' for HA media player {}::{}.", attributes["media_position_updated_at"].get<std::string>(), this->_id, this->_name);
}
}
if (updated_at.has_value() && attributes.contains("media_position") && attributes["media_position"].is_number()) {
this->_has_media_position = true;
this->_media_position = std::lround(std::max(attributes["media_position"].get<double>(), 0.0));
this->_media_position_updated_at = updated_at.value();
} else {
this->_has_media_position = false;
this->_media_position = 0;
this->_media_position_updated_at = 0;
}

if (attributes.contains("media_duration") && attributes["media_duration"].is_number()) {
this->_media_duration = std::lround(std::max(attributes["media_duration"].get<double>(), 0.0));
} else {
this->_media_duration = 0;
}
}

std::optional<int64_t> HomeAssistantMediaPlayer::parse_timestamp(const std::string &timestamp) {
// ISO 8601 as Home Assistant writes it: "YYYY-MM-DDTHH:MM:SS", optional fraction, then "+HH:MM"/"-HH:MM".
date::sys_time<std::chrono::microseconds> time_point;
std::istringstream stream(timestamp);
stream >> date::parse("%FT%T%Ez", time_point);
if (stream.fail() || stream.peek() != std::char_traits<char>::eof()) {
return std::nullopt;
}
return std::chrono::floor<std::chrono::seconds>(time_point).time_since_epoch().count();
}

void HomeAssistantMediaPlayer::play() {
this->_call_service("media_play");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class HomeAssistantMediaPlayer : public MediaPlayerEntity {
void select_player_source(const std::string &source);
void select_source_player_source(const std::string &source);

/**
* Parse a timestamp from Home Assistant, for example "2026-10-17T01:59:45.666287+00:00".
* @return Unix time in seconds, or nothing if the timestamp could not be parsed.
*/
static std::optional<int64_t> parse_timestamp(const std::string &timestamp);

protected:
/**
* Download album art from Home Assistant. The source is the "entity_picture" attribute from Home Assistant,
Expand Down Expand Up @@ -61,6 +67,11 @@ class HomeAssistantMediaPlayer : public MediaPlayerEntity {
*/
void _call_service(const std::string &service, nlohmann::json service_data = nlohmann::json::object());

/**
* Update the playback position and duration from the attributes of the media player in Home Assistant.
*/
void _update_media_position(const nlohmann::json &attributes);

std::string _home_assistant_name;

// Guards the source volume strategy, the settings it was created from and the last player attributes it was given.
Expand Down
6 changes: 6 additions & 0 deletions docker/MQTTManager/include/media_player/media_player.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ void MediaPlayerEntity::send_state_update_to_nspanel() {
media_player_state->set_can_mute(this->_can_mute);
media_player_state->set_album_art_url(this->_get_album_art_url());
media_player_state->set_volume_step(this->_volume_step);
media_player_state->set_has_media_position(this->_has_media_position);
if (this->_has_media_position) {
media_player_state->set_media_position(this->_media_position);
media_player_state->set_media_position_updated_at(this->_media_position_updated_at);
}
media_player_state->set_media_duration(this->_media_duration);
{
std::lock_guard<std::mutex> lock_guard(this->_sources_mutex);
MediaPlayerEntity::_fill_source_selection(media_player_state->mutable_player_sources(), this->_player_sources);
Expand Down
4 changes: 4 additions & 0 deletions docker/MQTTManager/include/media_player/media_player.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ class MediaPlayerEntity : public MqttManagerEntity {
bool _is_muted = false;
bool _has_source_volume = false;
uint8_t _source_volume = 0;
bool _has_media_position = false;
int32_t _media_position = 0; // In seconds, at _media_position_updated_at
int64_t _media_position_updated_at = 0; // Unix time in seconds
int32_t _media_duration = 0; // In seconds, 0 if unknown

bool _can_play = false;
bool _can_pause = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ TEST_F(RecordedMediaPlayerTest, matrix_zone_publishes_its_inputs_and_the_source_
EXPECT_TRUE(state->is_muted());
ASSERT_TRUE(state->has_source_volume());
EXPECT_EQ(state->source_volume(), 28);
ASSERT_TRUE(state->has_media_position());
EXPECT_EQ(state->media_position(), 0);
EXPECT_EQ(state->media_position_updated_at(), 1792202385);
EXPECT_EQ(state->media_duration(), 314);

// The zone's inputs are the matrix inputs, one of which is the source player.
EXPECT_EQ(sources_of(state->player_sources()), recorded_sources("matrix_zone"));
Expand Down Expand Up @@ -202,6 +206,8 @@ TEST_F(RecordedMediaPlayerTest, receiver_that_is_off_offers_its_inputs_without_a
auto state = last_published_state();
ASSERT_TRUE(state.has_value());
EXPECT_EQ(state->state(), NSPanelEntityState_MediaPlayer_PlaybackState_OFF);
EXPECT_FALSE(state->has_media_position());
EXPECT_EQ(state->media_duration(), 0);
EXPECT_EQ(sources_of(state->player_sources()), recorded_sources("av_receiver_off"));
EXPECT_EQ(state->player_sources().current_source(), "");
EXPECT_TRUE(state->player_sources().can_select());
Expand Down
69 changes: 69 additions & 0 deletions docker/MQTTManager/tests/media_player_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,75 @@ TEST_F(HomeAssistantMediaPlayerTest, removed_entity_becomes_unknown) {
EXPECT_EQ(state->state(), NSPanelEntityState_MediaPlayer_PlaybackState_UNKNOWN);
}

TEST_F(HomeAssistantMediaPlayerTest, publishes_media_position_and_duration) {
receive_state("playing", {{"media_duration", 314.5}, {"media_position", 42.4}, {"media_position_updated_at", "2026-10-17T01:59:45.666287+00:00"}});

auto state = last_published_state();
ASSERT_TRUE(state.has_value());
ASSERT_TRUE(state->has_media_position());
EXPECT_EQ(state->media_position(), 42);
EXPECT_EQ(state->media_position_updated_at(), 1792202385);
EXPECT_EQ(state->media_duration(), 315);
}

TEST_F(HomeAssistantMediaPlayerTest, live_stream_has_a_position_but_no_duration) {
receive_state("playing", {{"media_position", 7}, {"media_position_updated_at", "2026-10-17T01:59:45+00:00"}});

auto state = last_published_state();
ASSERT_TRUE(state.has_value());
EXPECT_TRUE(state->has_media_position());
EXPECT_EQ(state->media_position(), 7);
EXPECT_EQ(state->media_duration(), 0);
}

TEST_F(HomeAssistantMediaPlayerTest, media_position_without_a_time_is_not_published) {
receive_state("playing", {{"media_duration", 200}, {"media_position", 10}});

auto state = last_published_state();
ASSERT_TRUE(state.has_value());
EXPECT_FALSE(state->has_media_position());
EXPECT_EQ(state->media_position(), 0);
EXPECT_EQ(state->media_position_updated_at(), 0);
EXPECT_EQ(state->media_duration(), 200);
}

TEST_F(HomeAssistantMediaPlayerTest, media_position_is_cleared_when_playback_stops) {
receive_state("playing", {{"media_duration", 200}, {"media_position", 10}, {"media_position_updated_at", "2026-10-17T01:59:45+00:00"}});
receive_state("idle", nlohmann::json::object());

auto state = last_published_state();
ASSERT_TRUE(state.has_value());
EXPECT_FALSE(state->has_media_position());
EXPECT_EQ(state->media_duration(), 0);
}

TEST_F(HomeAssistantMediaPlayerTest, seeking_publishes_the_new_position) {
receive_state("playing", {{"media_duration", 200}, {"media_position", 10}, {"media_position_updated_at", "2026-10-17T01:59:45+00:00"}});
last_published_state();

receive_state("playing", {{"media_duration", 200}, {"media_position", 120}, {"media_position_updated_at", "2026-10-17T02:00:05+00:00"}});

auto state = last_published_state();
ASSERT_TRUE(state.has_value());
EXPECT_EQ(state->media_position(), 120);
EXPECT_EQ(state->media_position_updated_at(), 1792202405);
}

TEST(HomeAssistantTimestamp, parses_home_assistant_timestamps) {
EXPECT_EQ(HomeAssistantMediaPlayer::parse_timestamp("2026-10-17T01:59:45.666287+00:00"), 1792202385);
EXPECT_EQ(HomeAssistantMediaPlayer::parse_timestamp("2026-10-17T01:59:45+00:00"), 1792202385);
EXPECT_EQ(HomeAssistantMediaPlayer::parse_timestamp("2026-10-17T01:59:45.999999+00:00"), 1792202385);
EXPECT_EQ(HomeAssistantMediaPlayer::parse_timestamp("2026-10-16T18:59:45-07:00"), 1792202385);
EXPECT_EQ(HomeAssistantMediaPlayer::parse_timestamp("2026-10-17T07:29:45+05:30"), 1792202385);
}

TEST(HomeAssistantTimestamp, rejects_invalid_timestamps) {
for (const char *timestamp : {"", "unknown", "2026-10-17", "2026-10-17T01:59:45", "2026-13-17T01:59:45+00:00", "2026-10-17T01:59:45+0000", "2026-10-17T01:59:45Z trailing"}) {
SCOPED_TRACE(timestamp);
EXPECT_FALSE(HomeAssistantMediaPlayer::parse_timestamp(timestamp).has_value());
}
}

TEST_F(HomeAssistantMediaPlayerTest, panel_playback_commands_call_home_assistant) {
const std::pair<NSPanelMQTTManagerCommand_MediaPlayerCommand_PlaybackAction, std::string> cases[] = {
{NSPanelMQTTManagerCommand_MediaPlayerCommand_PlaybackAction_PLAY, "media_play"},
Expand Down
Loading