diff --git a/docker/MQTTManager/CMakeLists.txt b/docker/MQTTManager/CMakeLists.txt index cb18fb02..888e0e91 100644 --- a/docker/MQTTManager/CMakeLists.txt +++ b/docker/MQTTManager/CMakeLists.txt @@ -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) @@ -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) diff --git a/docker/MQTTManager/conanfile.py b/docker/MQTTManager/conanfile.py index d4b5a168..a84b10b8 100644 --- a/docker/MQTTManager/conanfile.py +++ b/docker/MQTTManager/conanfile.py @@ -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") @@ -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) diff --git a/docker/MQTTManager/include/media_player/home_assistant_media_player.cpp b/docker/MQTTManager/include/media_player/home_assistant_media_player.cpp index d0fdd1a2..df5647dc 100644 --- a/docker/MQTTManager/include/media_player/home_assistant_media_player.cpp +++ b/docker/MQTTManager/include/media_player/home_assistant_media_player.cpp @@ -8,14 +8,17 @@ #include #include #include +#include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -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 strategy; { @@ -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 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()); + if (!updated_at.has_value()) { + SPDLOG_WARN("Could not parse media_position_updated_at '{}' for HA media player {}::{}.", attributes["media_position_updated_at"].get(), 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(), 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(), 0.0)); + } else { + this->_media_duration = 0; + } +} + +std::optional HomeAssistantMediaPlayer::parse_timestamp(const std::string ×tamp) { + // ISO 8601 as Home Assistant writes it: "YYYY-MM-DDTHH:MM:SS", optional fraction, then "+HH:MM"/"-HH:MM". + date::sys_time time_point; + std::istringstream stream(timestamp); + stream >> date::parse("%FT%T%Ez", time_point); + if (stream.fail() || stream.peek() != std::char_traits::eof()) { + return std::nullopt; + } + return std::chrono::floor(time_point).time_since_epoch().count(); +} + void HomeAssistantMediaPlayer::play() { this->_call_service("media_play"); } diff --git a/docker/MQTTManager/include/media_player/home_assistant_media_player.hpp b/docker/MQTTManager/include/media_player/home_assistant_media_player.hpp index 0b0c3da7..33485461 100644 --- a/docker/MQTTManager/include/media_player/home_assistant_media_player.hpp +++ b/docker/MQTTManager/include/media_player/home_assistant_media_player.hpp @@ -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 parse_timestamp(const std::string ×tamp); + protected: /** * Download album art from Home Assistant. The source is the "entity_picture" attribute from Home Assistant, @@ -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. diff --git a/docker/MQTTManager/include/media_player/media_player.cpp b/docker/MQTTManager/include/media_player/media_player.cpp index 315693b8..9b5b61d6 100644 --- a/docker/MQTTManager/include/media_player/media_player.cpp +++ b/docker/MQTTManager/include/media_player/media_player.cpp @@ -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 lock_guard(this->_sources_mutex); MediaPlayerEntity::_fill_source_selection(media_player_state->mutable_player_sources(), this->_player_sources); diff --git a/docker/MQTTManager/include/media_player/media_player.hpp b/docker/MQTTManager/include/media_player/media_player.hpp index d278857d..1f2d30ea 100644 --- a/docker/MQTTManager/include/media_player/media_player.hpp +++ b/docker/MQTTManager/include/media_player/media_player.hpp @@ -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; diff --git a/docker/MQTTManager/tests/media_player_recorded_states_test.cpp b/docker/MQTTManager/tests/media_player_recorded_states_test.cpp index 1a811dc9..b5921806 100644 --- a/docker/MQTTManager/tests/media_player_recorded_states_test.cpp +++ b/docker/MQTTManager/tests/media_player_recorded_states_test.cpp @@ -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")); @@ -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()); diff --git a/docker/MQTTManager/tests/media_player_test.cpp b/docker/MQTTManager/tests/media_player_test.cpp index 381f46b0..db7886da 100644 --- a/docker/MQTTManager/tests/media_player_test.cpp +++ b/docker/MQTTManager/tests/media_player_test.cpp @@ -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 cases[] = { {NSPanelMQTTManagerCommand_MediaPlayerCommand_PlaybackAction_PLAY, "media_play"},