From fa36b39e81ace5002055c1be7e0bde7ac5b818bc Mon Sep 17 00:00:00 2001 From: Jacob Date: Wed, 7 Jan 2026 02:10:03 -0500 Subject: [PATCH] chore+feat(home-assistant): update to pyo3 0.27 and update extraction errors, switch out `SmolStr` for `Arc`, tighten up light service calls and implement some for notify, start implementing units of measurement like for power --- home-assistant/Cargo.toml | 5 +- home-assistant/src/entity_id.rs | 38 ++- home-assistant/src/event/context/context.rs | 2 +- home-assistant/src/event/context/id.rs | 34 ++- home-assistant/src/event/event_origin.rs | 47 +++- .../src/event/specific/state_changed.rs | 34 ++- home-assistant/src/home_assistant.rs | 47 +++- home-assistant/src/light/mod.rs | 20 +- home-assistant/src/light/protocol.rs | 25 +- home-assistant/src/light/service/turn_off.rs | 8 +- home-assistant/src/light/service/turn_on.rs | 9 +- home-assistant/src/light/state.rs | 40 +++- home-assistant/src/logger.rs | 10 +- home-assistant/src/notify/mod.rs | 1 + .../notify/service/mobile_app/command/mod.rs | 3 + .../command/request_location_update.rs | 33 +++ .../src/notify/service/mobile_app/mod.rs | 223 ++++++++++++++++++ .../src/notify/service/mobile_app/standard.rs | 60 +++++ .../service/mobile_app/text_to_speech.rs | 45 ++++ home-assistant/src/notify/service/mod.rs | 1 + .../src/sensor/device_classes/mod.rs | 1 + .../src/sensor/device_classes/power.rs | 125 ++++++++++ home-assistant/src/sensor/mod.rs | 2 + .../src/sensor/state_classes/measurement.rs | 48 ++++ .../src/sensor/state_classes/mod.rs | 1 + home-assistant/src/service_registry.rs | 61 ++++- home-assistant/src/slug.rs | 6 +- home-assistant/src/state.rs | 71 ------ home-assistant/src/state/error_state.rs | 55 +++++ home-assistant/src/state/mod.rs | 64 +++++ home-assistant/src/state/unexpected_state.rs | 35 +++ home-assistant/src/state_machine.rs | 46 +++- home-assistant/src/state_object.rs | 211 +++++++++-------- home-assistant/src/unit_of_measurement/mod.rs | 1 + .../src/unit_of_measurement/power.rs | 102 ++++++++ 35 files changed, 1255 insertions(+), 259 deletions(-) create mode 100644 home-assistant/src/notify/mod.rs create mode 100644 home-assistant/src/notify/service/mobile_app/command/mod.rs create mode 100644 home-assistant/src/notify/service/mobile_app/command/request_location_update.rs create mode 100644 home-assistant/src/notify/service/mobile_app/mod.rs create mode 100644 home-assistant/src/notify/service/mobile_app/standard.rs create mode 100644 home-assistant/src/notify/service/mobile_app/text_to_speech.rs create mode 100644 home-assistant/src/notify/service/mod.rs create mode 100644 home-assistant/src/sensor/device_classes/mod.rs create mode 100644 home-assistant/src/sensor/device_classes/power.rs create mode 100644 home-assistant/src/sensor/mod.rs create mode 100644 home-assistant/src/sensor/state_classes/measurement.rs create mode 100644 home-assistant/src/sensor/state_classes/mod.rs delete mode 100644 home-assistant/src/state.rs create mode 100644 home-assistant/src/state/error_state.rs create mode 100644 home-assistant/src/state/mod.rs create mode 100644 home-assistant/src/state/unexpected_state.rs create mode 100644 home-assistant/src/unit_of_measurement/mod.rs create mode 100644 home-assistant/src/unit_of_measurement/power.rs diff --git a/home-assistant/Cargo.toml b/home-assistant/Cargo.toml index c0d790d..5205286 100644 --- a/home-assistant/Cargo.toml +++ b/home-assistant/Cargo.toml @@ -19,14 +19,17 @@ derive_more = { workspace = true, features = [ "try_into", ] } emitter-and-signal = { path = "../emitter-and-signal" } +mitsein = { workspace = true } once_cell = "1.21.3" protocol = { path = "../protocol" } pyo3 = { workspace = true } pyo3-async-runtimes = { workspace = true, features = ["tokio-runtime"] } python-utils = { path = "../python-utils" } -smol_str = "0.3.2" snafu = { workspace = true } strum = { workspace = true, features = ["derive"] } tokio = { workspace = true } tracing = { optional = true, workspace = true } +typed-builder = { workspace = true } ulid = "1.2.0" +uom = "0.37.0" +url = { workspace = true } diff --git a/home-assistant/src/entity_id.rs b/home-assistant/src/entity_id.rs index 282329f..a3bf661 100644 --- a/home-assistant/src/entity_id.rs +++ b/home-assistant/src/entity_id.rs @@ -1,6 +1,10 @@ use std::{convert::Infallible, fmt::Display, str::FromStr}; -use pyo3::{exceptions::PyValueError, prelude::*, types::PyString}; +use pyo3::{ + exceptions::{PyException, PyValueError}, + prelude::*, + types::PyString, +}; use snafu::{ResultExt, Snafu}; use super::{ @@ -44,21 +48,39 @@ impl Display for EntityId { } } -impl From for PyErr { - fn from(error: EntityIdParsingError) -> Self { - PyValueError::new_err(error.to_string()) +#[derive(Debug, Snafu)] +pub enum ExtractEntityIdError { + /// couldn't extract the object as a string + ExtractStringError { source: PyErr }, + + /// couldn't parse the string as an [`EntityId`] + ParseError { source: EntityIdParsingError }, +} + +impl From for PyErr { + fn from(error: ExtractEntityIdError) -> Self { + match &error { + ExtractEntityIdError::ExtractStringError { .. } => { + PyException::new_err(error.to_string()) + } + ExtractEntityIdError::ParseError { .. } => PyValueError::new_err(error.to_string()), + } } } -impl<'py> FromPyObject<'py> for EntityId { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { - let s = ob.extract()?; - let entity_id = EntityId::from_str(s)?; +// TODO: replace with a derive(PyFromStr) (analogous to serde_with::DeserializeFromStr) once I make one +impl<'a, 'py> FromPyObject<'a, 'py> for EntityId { + type Error = ExtractEntityIdError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { + let s = ob.extract().context(ExtractStringSnafu)?; + let entity_id = EntityId::from_str(s).context(ParseSnafu)?; Ok(entity_id) } } +// TODO: replace with a derive(DisplayToPy) (analogous to serde_with::SerializeDisplay) once I make one impl<'py> IntoPyObject<'py> for EntityId { type Target = PyString; type Output = Bound<'py, Self::Target>; diff --git a/home-assistant/src/event/context/context.rs b/home-assistant/src/event/context/context.rs index e073a8e..178092d 100644 --- a/home-assistant/src/event/context/context.rs +++ b/home-assistant/src/event/context/context.rs @@ -28,7 +28,7 @@ impl<'py, Event: IntoPyObject<'py>> IntoPyObject<'py> for Context { .bind(py); let context_class = homeassistant_core.getattr("Context")?; - let context_class = context_class.downcast_into::()?; + let context_class = context_class.cast_into::()?; let context_instance = context_class.call1((self.user_id, self.parent_id, self.id))?; diff --git a/home-assistant/src/event/context/id.rs b/home-assistant/src/event/context/id.rs index 69e715c..b23ab9a 100644 --- a/home-assistant/src/event/context/id.rs +++ b/home-assistant/src/event/context/id.rs @@ -1,18 +1,35 @@ -use std::convert::Infallible; +use std::{convert::Infallible, sync::Arc}; -use pyo3::{prelude::*, types::PyString}; -use smol_str::SmolStr; +use pyo3::{exceptions::PyTypeError, prelude::*, types::PyString}; +use snafu::{ResultExt, Snafu}; use ulid::Ulid; #[derive(Debug, Clone)] pub enum Id { Ulid(Ulid), - Other(SmolStr), + Other(Arc), } -impl<'py> FromPyObject<'py> for Id { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { - let s = ob.extract::()?; +#[derive(Debug, Snafu)] +pub enum ExtractIdError { + /// couldn't extract the given object as a string + ExtractStringError { source: PyErr }, +} + +impl From for PyErr { + fn from(error: ExtractIdError) -> Self { + match &error { + ExtractIdError::ExtractStringError { .. } => PyTypeError::new_err(error.to_string()), + } + } +} + +// TODO: replace with a derive(PyFromStr) (analogous to serde_with::DeserializeFromStr) once I make one +impl<'a, 'py> FromPyObject<'a, 'py> for Id { + type Error = ExtractIdError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { + let s = ob.extract::<&str>().context(ExtractStringSnafu)?; if let Ok(ulid) = s.parse() { Ok(Id::Ulid(ulid)) @@ -22,6 +39,7 @@ impl<'py> FromPyObject<'py> for Id { } } +// TODO: replace with a derive(DisplayToPy) (analogous to serde_with::SerializeDisplay) once I make one impl<'py> IntoPyObject<'py> for Id { type Target = PyString; @@ -32,7 +50,7 @@ impl<'py> IntoPyObject<'py> for Id { fn into_pyobject(self, py: Python<'py>) -> Result { match self { Id::Ulid(ulid) => ulid.to_string().into_pyobject(py), - Id::Other(id) => id.as_str().into_pyobject(py), + Id::Other(id) => id.into_pyobject(py), } } } diff --git a/home-assistant/src/event/event_origin.rs b/home-assistant/src/event/event_origin.rs index 2c1fddf..3379903 100644 --- a/home-assistant/src/event/event_origin.rs +++ b/home-assistant/src/event/event_origin.rs @@ -1,6 +1,10 @@ use std::str::FromStr; -use pyo3::{exceptions::PyValueError, prelude::*}; +use pyo3::{ + exceptions::{PyException, PyTypeError, PyValueError}, + prelude::*, +}; +use snafu::{ResultExt, Snafu}; #[derive(Debug, Clone, strum::EnumString, strum::Display)] #[strum(serialize_all = "UPPERCASE")] @@ -9,12 +13,41 @@ pub enum EventOrigin { Remote, } -impl<'py> FromPyObject<'py> for EventOrigin { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { - let s = ob.str()?; - let s = s.extract()?; - let event_origin = - EventOrigin::from_str(s).map_err(|err| PyValueError::new_err(err.to_string()))?; +#[derive(Debug, Snafu)] +pub enum ExtractEventOriginError { + /// couldn't turn the object into a string with `str` (Python function) + ToStrError { source: PyErr }, + + /// after calling `str` on the object, it's somehow not extractable as a string?! + NotString { source: PyErr }, + + /// this is not an expected value for [`EventOrigin`] + UnexpectedValue { + source: ::Err, + }, +} + +impl From for PyErr { + fn from(error: ExtractEventOriginError) -> Self { + match &error { + ExtractEventOriginError::ToStrError { .. } => PyException::new_err(error.to_string()), + ExtractEventOriginError::NotString { .. } => PyTypeError::new_err(error.to_string()), + ExtractEventOriginError::UnexpectedValue { .. } => { + PyValueError::new_err(error.to_string()) + } + } + } +} + +impl<'a, 'py> FromPyObject<'a, 'py> for EventOrigin { + type Error = ExtractEventOriginError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { + let s = ob.str().context(ToStrSnafu)?; + // TODO: could I go straight to trying to extract an &str without calling .str() first? if so then I could + // TODO: replace with a derive(PyFromStr) (analogous to serde_with::DeserializeFromStr) once I make one + let s = s.extract().context(NotStringSnafu)?; + let event_origin = EventOrigin::from_str(s).context(UnexpectedValueSnafu)?; Ok(event_origin) } diff --git a/home-assistant/src/event/specific/state_changed.rs b/home-assistant/src/event/specific/state_changed.rs index 3978f95..f694cfc 100644 --- a/home-assistant/src/event/specific/state_changed.rs +++ b/home-assistant/src/event/specific/state_changed.rs @@ -1,21 +1,41 @@ -use pyo3::exceptions::PyValueError; +use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::prelude::*; +use snafu::{ResultExt, Snafu}; use crate::{entity_id::EntityId, state_object::StateObject}; #[derive(Debug, Clone)] pub struct Type; -impl<'py> FromPyObject<'py> for Type { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { - let s = ob.extract::<&str>()?; +#[derive(Debug, Snafu)] +pub enum ExtractTypeError { + /// couldn't extract this object as a string + ExtractStringError { source: PyErr }, + + /// expected a string of value "state_changed", but got {actual} + UnexpectedValue { actual: String }, +} + +impl From for PyErr { + fn from(error: ExtractTypeError) -> Self { + match &error { + ExtractTypeError::ExtractStringError { .. } => PyTypeError::new_err(error.to_string()), + ExtractTypeError::UnexpectedValue { .. } => PyValueError::new_err(error.to_string()), + } + } +} + +// TODO: replace with a derive(PyFromStrLiteral) / #[str = "state_changed"] once I learn how to make something like that and see about serde or strum integration or inspiration +impl<'a, 'py> FromPyObject<'a, 'py> for Type { + type Error = ExtractTypeError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { + let s = ob.extract::<&str>().context(ExtractStringSnafu)?; if s == "state_changed" { Ok(Type) } else { - Err(PyValueError::new_err(format!( - "expected a string of value 'state_changed', but got {s}" - ))) + Err(ExtractTypeError::UnexpectedValue { actual: s.into() }) } } } diff --git a/home-assistant/src/home_assistant.rs b/home-assistant/src/home_assistant.rs index a108240..9c1bbd9 100644 --- a/home-assistant/src/home_assistant.rs +++ b/home-assistant/src/home_assistant.rs @@ -2,17 +2,20 @@ use std::convert::Infallible; use pyo3::prelude::*; -use python_utils::{detach, validate_type_by_name}; +use python_utils::{detach, validate_type_by_name, TypeByNameValidationError}; +use snafu::{ResultExt, Snafu}; use super::{service_registry::ServiceRegistry, state_machine::StateMachine}; #[derive(Debug)] pub struct HomeAssistant(Py); -impl<'source> FromPyObject<'source> for HomeAssistant { - fn extract_bound(ob: &Bound<'source, PyAny>) -> PyResult { +impl<'a, 'py> FromPyObject<'a, 'py> for HomeAssistant { + type Error = TypeByNameValidationError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { // region: Validation - validate_type_by_name(ob, "HomeAssistant")?; + validate_type_by_name(&ob, "HomeAssistant")?; // endregion: Validation Ok(Self(detach(ob))) @@ -29,6 +32,24 @@ impl<'py> IntoPyObject<'py> for &HomeAssistant { } } +#[derive(Debug, Snafu)] +pub enum GetStatesError { + /// couldn't get the `states` attribute on the Home Assistant object + GetStatesAttributeError { source: PyErr }, + + /// couldn't extract the `states` as a [`StateMachine`] + ExtractStateMachineError { source: TypeByNameValidationError }, +} + +#[derive(Debug, Snafu)] +pub enum GetServicesError { + /// couldn't get the `services` attribute on the Home Assistant object + GetServicesAttributeError { source: PyErr }, + + /// couldn't extract the `states` as a [`ServiceRegistry`] + ExtractServiceRegistryError { source: TypeByNameValidationError }, +} + impl HomeAssistant { /// Return the representation pub fn repr(&self, py: Python<'_>) -> Result { @@ -48,13 +69,19 @@ impl HomeAssistant { is_stopping.extract(py) } - pub fn states(&self, py: Python<'_>) -> Result { - let states = self.0.getattr(py, "states")?; - states.extract(py) + pub fn states(&self, py: Python<'_>) -> Result { + let states = self + .0 + .getattr(py, "states") + .context(GetStatesAttributeSnafu)?; + states.extract(py).context(ExtractStateMachineSnafu) } - pub fn services(&self, py: Python<'_>) -> Result { - let services = self.0.getattr(py, "services")?; - services.extract(py) + pub fn services(&self, py: Python<'_>) -> Result { + let services = self + .0 + .getattr(py, "services") + .context(GetServicesAttributeSnafu)?; + services.extract(py).context(ExtractServiceRegistrySnafu) } } diff --git a/home-assistant/src/light/mod.rs b/home-assistant/src/light/mod.rs index 59bb024..c883795 100644 --- a/home-assistant/src/light/mod.rs +++ b/home-assistant/src/light/mod.rs @@ -3,7 +3,9 @@ use pyo3::prelude::*; use snafu::{ResultExt, Snafu}; use state::LightState; -use crate::state::HomeAssistantState; +use crate::{ + home_assistant::GetStatesError, state::HomeAssistantState, state_machine::GetStateError, +}; use super::{ domain::Domain, entity_id::EntityId, home_assistant::HomeAssistant, object_id::ObjectId, @@ -29,7 +31,15 @@ impl HomeAssistantLight { #[derive(Debug, Snafu)] pub enum GetStateObjectError { - PythonError { source: PyErr }, + /// couldn't get the state machine registry + GetStatesError { source: GetStatesError }, + + /// this state object exists in the state machine registry, but it couldn't be extracted as a light state object + GetStateError { source: GetStateError< + , LightAttributes, Py> as FromPyObject<'static, 'static>>::Error + > }, + + /// this entity does not have a state object in the registry EntityMissing, } @@ -40,12 +50,12 @@ impl HomeAssistantLight { StateObject, LightAttributes, Py>, GetStateObjectError, > { - Python::with_gil(|py| { - let states = self.home_assistant.states(py).context(PythonSnafu)?; + Python::attach(|py| { + let states = self.home_assistant.states(py).context(GetStatesSnafu)?; let entity_id = self.entity_id(); let state_object = states .get(py, entity_id) - .context(PythonSnafu)? + .context(GetStateSnafu)? .ok_or(GetStateObjectError::EntityMissing)?; Ok(state_object) diff --git a/home-assistant/src/light/protocol.rs b/home-assistant/src/light/protocol.rs index 4b79e19..204e6a4 100644 --- a/home-assistant/src/light/protocol.rs +++ b/home-assistant/src/light/protocol.rs @@ -1,5 +1,7 @@ use super::service::{turn_off::TurnOff, turn_on::TurnOn}; -use super::{state::LightState, GetStateObjectError, HomeAssistantLight}; +use super::{GetStateObjectError, HomeAssistantLight}; +use crate::home_assistant::GetServicesError; +use crate::service_registry::CallServiceError; use crate::{ event::context::context::Context, state::{ErrorState, HomeAssistantState, UnexpectedState}, @@ -35,21 +37,31 @@ impl GetState for HomeAssistantLight { } } +#[derive(Debug, Snafu)] +pub enum SetStateError { + /// couldn't get the service registry + GetServicesError { source: GetServicesError }, + + /// couldn't call the service + CallServiceError { source: CallServiceError }, +} + impl SetState for HomeAssistantLight { - type Error = PyErr; + type Error = SetStateError; async fn set_state(&mut self, state: protocol::light::State) -> Result<(), Self::Error> { let context: Option> = None; let target: Option<()> = None; - let services = Python::with_gil(|py| self.home_assistant.services(py))?; + let services = + Python::attach(|py| self.home_assistant.services(py)).context(GetServicesSnafu)?; let _: IsNone = match state { protocol::light::State::Off => { services .call_service( TurnOff { - entity_id: self.entity_id(), + object_id: self.object_id.clone(), }, context, target, @@ -61,7 +73,7 @@ impl SetState for HomeAssistantLight { services .call_service( TurnOn { - entity_id: self.entity_id(), + object_id: self.object_id.clone(), }, context, target, @@ -69,7 +81,8 @@ impl SetState for HomeAssistantLight { ) .await } - }?; + } + .context(CallServiceSnafu)?; Ok(()) } diff --git a/home-assistant/src/light/service/turn_off.rs b/home-assistant/src/light/service/turn_off.rs index 3b623f1..9215afc 100644 --- a/home-assistant/src/light/service/turn_off.rs +++ b/home-assistant/src/light/service/turn_off.rs @@ -3,13 +3,12 @@ use std::str::FromStr; use pyo3::IntoPyObject; use crate::{ - entity_id::EntityId, - service::{service_domain::ServiceDomain, service_id::ServiceId, IntoServiceCall}, + domain::Domain, entity_id::EntityId, object_id::ObjectId, service::{IntoServiceCall, service_domain::ServiceDomain, service_id::ServiceId} }; #[derive(Debug, Clone)] pub struct TurnOff { - pub entity_id: EntityId, + pub object_id: ObjectId, } #[derive(Debug, Clone, IntoPyObject)] @@ -24,7 +23,8 @@ impl IntoServiceCall for TurnOff { let service_domain = ServiceDomain::from_str("light").expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); let service_id = ServiceId::from_str("turn_off").expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); - let Self { entity_id } = self; + let Self { object_id } = self; + let entity_id = EntityId(Domain::Light, object_id); let service_data = TurnOffServiceData { entity_id }; diff --git a/home-assistant/src/light/service/turn_on.rs b/home-assistant/src/light/service/turn_on.rs index 83d0c6d..0b5df9c 100644 --- a/home-assistant/src/light/service/turn_on.rs +++ b/home-assistant/src/light/service/turn_on.rs @@ -3,13 +3,12 @@ use std::str::FromStr; use pyo3::IntoPyObject; use crate::{ - entity_id::EntityId, - service::{service_domain::ServiceDomain, service_id::ServiceId, IntoServiceCall}, + domain::Domain, entity_id::EntityId, object_id::ObjectId, service::{IntoServiceCall, service_domain::ServiceDomain, service_id::ServiceId} }; #[derive(Debug, Clone)] pub struct TurnOn { - pub entity_id: EntityId, + pub object_id: ObjectId, } #[derive(Debug, Clone, IntoPyObject)] @@ -24,7 +23,9 @@ impl IntoServiceCall for TurnOn { let service_domain = ServiceDomain::from_str("light").expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); let service_id = ServiceId::from_str("turn_on").expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); - let Self { entity_id } = self; + let Self { object_id } = self; + let entity_id = EntityId(Domain::Light, object_id); + let service_data = TurnOnServiceData { entity_id }; (service_domain, service_id, service_data) diff --git a/home-assistant/src/light/state.rs b/home-assistant/src/light/state.rs index 13d8511..bdcccc6 100644 --- a/home-assistant/src/light/state.rs +++ b/home-assistant/src/light/state.rs @@ -1,6 +1,10 @@ use std::str::FromStr; -use pyo3::{exceptions::PyValueError, prelude::*}; +use pyo3::{ + exceptions::{PyException, PyValueError}, + prelude::*, +}; +use snafu::{ResultExt, Snafu}; use strum::EnumString; #[derive(Debug, Clone, EnumString, strum::Display)] @@ -10,12 +14,36 @@ pub enum LightState { Off, } -impl<'py> FromPyObject<'py> for LightState { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { - let s = ob.extract::()?; +#[derive(Debug, Snafu)] +pub enum ExtractLightStateError { + /// couldn't extract the object as a string + ExtractStringError { source: PyErr }, - let state = - LightState::from_str(&s).map_err(|err| PyValueError::new_err(err.to_string()))?; + /// couldn't parse the string as a [`LightState`] + ParseError { + source: ::Err, + }, +} + +impl From for PyErr { + fn from(error: ExtractLightStateError) -> Self { + match &error { + ExtractLightStateError::ExtractStringError { .. } => { + PyException::new_err(error.to_string()) + } + ExtractLightStateError::ParseError { .. } => PyValueError::new_err(error.to_string()), + } + } +} + +// TODO: replace with a derive(PyFromStr) (analogous to serde_with::DeserializeFromStr) once I make one +impl<'a, 'py> FromPyObject<'a, 'py> for LightState { + type Error = ExtractLightStateError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { + let s = ob.extract::<&str>().context(ExtractStringSnafu)?; + + let state = LightState::from_str(&s).context(ParseSnafu)?; Ok(state) } diff --git a/home-assistant/src/logger.rs b/home-assistant/src/logger.rs index d80d39b..9df6913 100644 --- a/home-assistant/src/logger.rs +++ b/home-assistant/src/logger.rs @@ -1,15 +1,17 @@ use arbitrary_value::{arbitrary::Arbitrary, map::Map}; use once_cell::sync::OnceCell; use pyo3::{prelude::*, types::PyTuple}; -use python_utils::{detach, validate_type_by_name}; +use python_utils::{detach, validate_type_by_name, TypeByNameValidationError}; #[derive(Debug)] pub struct HassLogger(Py); -impl<'source> FromPyObject<'source> for HassLogger { - fn extract_bound(ob: &Bound<'source, PyAny>) -> PyResult { +impl<'a, 'py> FromPyObject<'a, 'py> for HassLogger { + type Error = TypeByNameValidationError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { // region: Validation - validate_type_by_name(ob, "HassLogger")?; + validate_type_by_name(&ob, "HassLogger")?; // endregion: Validation Ok(Self(detach(ob))) diff --git a/home-assistant/src/notify/mod.rs b/home-assistant/src/notify/mod.rs new file mode 100644 index 0000000..1f278a4 --- /dev/null +++ b/home-assistant/src/notify/mod.rs @@ -0,0 +1 @@ +pub mod service; diff --git a/home-assistant/src/notify/service/mobile_app/command/mod.rs b/home-assistant/src/notify/service/mobile_app/command/mod.rs new file mode 100644 index 0000000..d65fe8a --- /dev/null +++ b/home-assistant/src/notify/service/mobile_app/command/mod.rs @@ -0,0 +1,3 @@ +mod request_location_update; + +pub use request_location_update::RequestLocationUpdate; diff --git a/home-assistant/src/notify/service/mobile_app/command/request_location_update.rs b/home-assistant/src/notify/service/mobile_app/command/request_location_update.rs new file mode 100644 index 0000000..0da1a4d --- /dev/null +++ b/home-assistant/src/notify/service/mobile_app/command/request_location_update.rs @@ -0,0 +1,33 @@ +use std::str::FromStr; + +use crate::{ + notify::service::mobile_app::SpecialMessage, + object_id::ObjectId, + service::{service_domain::ServiceDomain, service_id::ServiceId, IntoServiceCall}, +}; + +use super::super::NotifyMobileAppServiceData; + +#[derive(Debug, Clone, typed_builder::TypedBuilder)] +pub struct RequestLocationUpdate { + pub object_id: ObjectId, +} + +impl IntoServiceCall for RequestLocationUpdate { + type ServiceData = NotifyMobileAppServiceData; + + fn into_service_call(self) -> (ServiceDomain, ServiceId, Self::ServiceData) { + let RequestLocationUpdate { object_id } = self; + + let service_domain = ServiceDomain::from_str("notify").expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); + + let service_id = ServiceId::from_str(&format!("mobile_app_{object_id}")).expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); + + let service_data = NotifyMobileAppServiceData::builder() + .message(SpecialMessage::RequestLocationUpdate.to_string()) + // .data(NotifyMobileAppServiceDataData::builder().build()) // TODO + .build(); + + (service_domain, service_id, service_data) + } +} diff --git a/home-assistant/src/notify/service/mobile_app/mod.rs b/home-assistant/src/notify/service/mobile_app/mod.rs new file mode 100644 index 0000000..ff442af --- /dev/null +++ b/home-assistant/src/notify/service/mobile_app/mod.rs @@ -0,0 +1,223 @@ +use std::{convert::Infallible, str::FromStr}; + +use pyo3::{types::PyString, Bound, IntoPyObject, Python}; +use python_utils::IntoPyObjectViaDisplay; +use snafu::Snafu; +use strum::EnumString; +use url::Url; + +pub mod command; +pub mod standard; +pub mod text_to_speech; + +pub use command::*; +pub use standard::StandardNotification; +pub use text_to_speech::TextToSpeech; + +#[derive(Debug, Clone, derive_more::Display)] +pub struct NonSpecialMessage(String); + +#[derive(Debug, Clone, EnumString, strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum SpecialMessage { + ClearBadge, + ClearNotification, + CommandActivity, + CommandAppLock, + CommandAutoScreenBrightness, + CommandBluetooth, + CommandBleTransmitter, + CommandBeaconMonitor, + CommandBroadcastIntent, + CommandDnd, + CommandFlashlight, + CommandHighAccuracyMode, + CommandLaunchApp, + CommandMedia, + CommandRingerMode, + CommandScreenBrightnessLevel, + CommandScreenOffTimeout, + CommandScreenOn, + CommandStopTts, + CommandPersistentConnection, + CommandUpdateSensors, + CommandVolumeLevel, + CommandWebivew, + RemoveChannel, + RequestLocationUpdate, + // #[strum(serialize = "TTS")] TODO: WIP: TESTING + Tts, + UpdateComplications, + UpdateWidgets, +} + +/// wasn't supposed to get a specially-behaving message here, but got {got} +#[derive(Debug, Clone, Snafu)] +pub struct WasSpecialMessage { + pub got: SpecialMessage, +} + +impl FromStr for NonSpecialMessage { + type Err = WasSpecialMessage; + + fn from_str(s: &str) -> Result { + match SpecialMessage::from_str(s) { + Ok(special_message) => Err(WasSpecialMessage { + got: special_message, + }), + Err(_e) => Ok(NonSpecialMessage(s.into())), + } + } +} + +/// How much of a notification is visible on the lock screen +#[derive(Debug, Clone, Default, EnumString, strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum Visibility { + /// always show all notification content + Public, + /// visibility depends on your setting in the system Settings app > Notifications; + /// if the option to show sensitive notifications when locked is enabled all notification content will be shown, + /// otherwise only basic information such as the icon and app name are visible + #[default] + Private, + /// always hide notification from lock screen + Secret, +} + +// TODO: replace with a derive(DisplayToPy) (analogous to serde_with::SerializeDisplay) once I make one +impl<'py> IntoPyObject<'py> for Visibility { + type Target = PyString; + type Output = Bound<'py, Self::Target>; + type Error = Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + let s = self.to_string(); + s.into_pyobject(py) + } +} + +#[derive(Debug, Clone, EnumString, strum::Display)] +pub enum Behavior { + /// prompt for text to return with the event + #[strum(serialize = "textInput")] + TextInput, +} + +// TODO: replace with a derive(DisplayToPy) (analogous to serde_with::SerializeDisplay) once I make one +impl<'py> IntoPyObject<'py> for Behavior { + type Target = PyString; + type Output = Bound<'py, Self::Target>; + type Error = Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + let s = self.to_string(); + s.into_pyobject(py) + } +} + +#[derive(Debug, Clone, Default, EnumString, strum::Display)] +#[strum(serialize_all = "camelCase")] +pub enum ActivationMode { + /// launch the app when tapped + Foreground, + /// just fires the event + #[default] + Background, +} + +// TODO: replace with a derive(DisplayToPy) (analogous to serde_with::SerializeDisplay) once I make one +impl<'py> IntoPyObject<'py> for ActivationMode { + type Target = PyString; + type Output = Bound<'py, Self::Target>; + type Error = Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + let s = self.to_string(); + s.into_pyobject(py) + } +} + +// TODO: better typed versions like `CallNumber` or `OpenWebpage` where Action: From and Action: From +#[derive(Debug, Clone, IntoPyObject, typed_builder::TypedBuilder)] +#[builder(field_defaults(default, setter(strip_option(fallback_suffix = "_option"))))] +pub struct Action { + // TODO: proper type + // TODO: I wish I could call this identifier or something instead + #[builder(!default, setter(!strip_option))] + pub action: String, + #[builder(!default, setter(!strip_option))] + pub title: String, + + pub uri: Option>, + pub behavior: Option, + + // TODO: make this written as activationMode + /// (iOS only) decide whether to open the app (to the foreground) when tapped + /// or merely fire an event (in the background) + pub activation_mode: Option, + + // TODO: make this written as authenticationRequired + /// (iOS only) require entering a passcode to use the action + pub authentication_required: Option, + + /// (iOS only) color the action's title red, indicating a destructive action + pub destructive: Option, + + // TODO: make this written as textInputButtonTitle + /// (iOS only) Title to use for text input for actions that prompt + pub text_input_button_title: Option, + + // TODO: make this written as textInputPlaceholder + /// (iOS only) Placeholder to use for text input for actions that prompt + pub text_input_placeholder: Option, + + // TODO: proper type + /// (iOS only) icon to use for the notification + pub icon: Option, +} + +#[derive(Debug, Clone, Default, EnumString, strum::Display)] +#[strum(serialize_all = "snake_case", suffix = "_stream")] +pub enum MediaStream { + Alarm, + Call, + Dtmf, + #[default] + Music, + Notification, + Ring, + System, +} + +// TODO: replace with a derive(DisplayToPy) (analogous to serde_with::SerializeDisplay) once I make one +impl<'py> IntoPyObject<'py> for MediaStream { + type Target = PyString; + type Output = Bound<'py, Self::Target>; + type Error = Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + let s = self.to_string(); + s.into_pyobject(py) + } +} + +#[derive(Debug, Default, Clone, IntoPyObject, typed_builder::TypedBuilder)] +#[builder(field_defaults(default, setter(strip_option(fallback_suffix = "_option"))))] +pub struct NotifyMobileAppServiceDataData { + actions: Option>, + media_stream: Option, + tts_text: Option, + visibility: Option, +} + +#[derive(Debug, Clone, IntoPyObject, typed_builder::TypedBuilder)] +#[builder(field_defaults(default, setter(strip_option(fallback_suffix = "_option"))))] +pub struct NotifyMobileAppServiceData { + #[builder(!default, setter(!strip_option))] + message: String, + + title: Option, + #[builder(setter(!strip_option))] + data: NotifyMobileAppServiceDataData, +} diff --git a/home-assistant/src/notify/service/mobile_app/standard.rs b/home-assistant/src/notify/service/mobile_app/standard.rs new file mode 100644 index 0000000..c2c7a89 --- /dev/null +++ b/home-assistant/src/notify/service/mobile_app/standard.rs @@ -0,0 +1,60 @@ +use std::str::FromStr; + +use mitsein::vec1::Vec1; +use pyo3::{types::PyAnyMethods, IntoPyObject, Python}; + +use crate::{ + object_id::ObjectId, + service::{service_domain::ServiceDomain, service_id::ServiceId, IntoServiceCall}, +}; + +use super::{ + Action, NonSpecialMessage, NotifyMobileAppServiceData, NotifyMobileAppServiceDataData, + Visibility, +}; + +#[derive(Debug, Clone, typed_builder::TypedBuilder)] +pub struct StandardNotification { + pub object_id: ObjectId, + + #[builder(default, setter(strip_option))] + pub title: Option, + pub message: NonSpecialMessage, + + #[builder(default, setter(strip_option))] + pub actions: Option>, + + #[builder(default, setter(strip_option))] + pub visibility: Option, +} + +impl IntoServiceCall for StandardNotification { + type ServiceData = NotifyMobileAppServiceData; + + fn into_service_call(self) -> (ServiceDomain, ServiceId, Self::ServiceData) { + let StandardNotification { + object_id, + title, + message, + actions, + visibility, + } = self; + + let service_domain = ServiceDomain::from_str("notify").expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); + + let service_id = ServiceId::from_str(&format!("mobile_app_{object_id}")).expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); + + let service_data = NotifyMobileAppServiceData::builder() + .title_option(title) + .message(message.to_string()) + .data( + NotifyMobileAppServiceDataData::builder() + .actions_option(actions.map(Into::into)) + .visibility_option(visibility) + .build(), + ) + .build(); + + (service_domain, service_id, service_data) + } +} diff --git a/home-assistant/src/notify/service/mobile_app/text_to_speech.rs b/home-assistant/src/notify/service/mobile_app/text_to_speech.rs new file mode 100644 index 0000000..b3f4dd2 --- /dev/null +++ b/home-assistant/src/notify/service/mobile_app/text_to_speech.rs @@ -0,0 +1,45 @@ +use std::str::FromStr; + +use crate::{ + notify::service::mobile_app::SpecialMessage, + object_id::ObjectId, + service::{service_domain::ServiceDomain, service_id::ServiceId, IntoServiceCall}, +}; + +use super::{MediaStream, NotifyMobileAppServiceData, NotifyMobileAppServiceDataData}; + +#[derive(Debug, Clone, typed_builder::TypedBuilder)] +pub struct TextToSpeech { + pub object_id: ObjectId, + + pub message: String, + pub media_stream: Option, +} + +impl IntoServiceCall for TextToSpeech { + type ServiceData = NotifyMobileAppServiceData; + + fn into_service_call(self) -> (ServiceDomain, ServiceId, Self::ServiceData) { + let TextToSpeech { + object_id, + message, + media_stream, + } = self; + + let service_domain = ServiceDomain::from_str("notify").expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); + + let service_id = ServiceId::from_str(&format!("mobile_app_{object_id}")).expect("statically written and known to be a valid slug; hoping to get compiler checks instead in the future"); + + let service_data = NotifyMobileAppServiceData::builder() + .message(SpecialMessage::Tts.to_string()) + .data( + NotifyMobileAppServiceDataData::builder() + .tts_text(message) + .media_stream_option(media_stream) + .build(), + ) + .build(); + + (service_domain, service_id, service_data) + } +} diff --git a/home-assistant/src/notify/service/mod.rs b/home-assistant/src/notify/service/mod.rs new file mode 100644 index 0000000..e120913 --- /dev/null +++ b/home-assistant/src/notify/service/mod.rs @@ -0,0 +1 @@ +pub mod mobile_app; diff --git a/home-assistant/src/sensor/device_classes/mod.rs b/home-assistant/src/sensor/device_classes/mod.rs new file mode 100644 index 0000000..5f148b2 --- /dev/null +++ b/home-assistant/src/sensor/device_classes/mod.rs @@ -0,0 +1 @@ +mod power; diff --git a/home-assistant/src/sensor/device_classes/power.rs b/home-assistant/src/sensor/device_classes/power.rs new file mode 100644 index 0000000..581b097 --- /dev/null +++ b/home-assistant/src/sensor/device_classes/power.rs @@ -0,0 +1,125 @@ +use std::{future::Future, str::FromStr, sync::Arc}; + +use emitter_and_signal::{Signal, SignalExt}; +use pyo3::{ + exceptions::{PyException, PyValueError}, + prelude::*, +}; +use python_utils::FromPyObjectViaParse; +use snafu::{ensure, ResultExt, Snafu}; + +use super::super::state_classes::measurement::Measurement; +use crate::{ + domain::Domain, + entity_id::EntityId, + home_assistant::HomeAssistant, + object_id::ObjectId, + state_object::{self, StateObject, StateObjectSignalError}, + unit_of_measurement::power::UnitOfMeasurement, +}; + +#[derive(Debug)] +struct Power; + +#[derive(Debug, Snafu)] +pub enum ExtractPowerError { + /// couldn't extract the object as a string + ExtractStringError { source: PyErr }, + + /// the string {actual:?} is not "power" like it's supposed to be + NotPower { actual: String }, +} + +impl From for PyErr { + fn from(error: ExtractPowerError) -> Self { + match &error { + ExtractPowerError::ExtractStringError { .. } => PyException::new_err(error.to_string()), + ExtractPowerError::NotPower { .. } => PyValueError::new_err(error.to_string()), + } + } +} + +// TODO: replace with a derive(PyFromStrLiteral) / #[literal = "state_changed"] once I learn how to make something like that and see about serde or strum integration or inspiration +impl<'a, 'py> FromPyObject<'a, 'py> for Power { + type Error = ExtractPowerError; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> Result { + let string: &str = obj.extract().context(ExtractStringSnafu)?; + + ensure!( + string == "power", + NotPowerSnafu { + actual: string.to_owned() + } + ); + + Ok(Self) + } +} + +#[derive(Debug, FromPyObject)] +#[pyo3(from_item_all)] +pub struct PowerSensorAttributes { + state_class: Measurement, + device_class: Power, + unit_of_measurement: UnitOfMeasurement, +} + +#[derive(Debug, Snafu)] +pub enum CreateSignalError { + /// couldn't get the underlying state object signal + StateObjectSignalError { + source: state_object::CreateSignalError, + }, + + /// couldn't map the state object to a power value + MappedSignalError { + source: emitter_and_signal::signal_ext::ProducerAlreadyExited, + }, +} + +pub fn signal<'py>( + py: Python<'py>, + home_assistant: &'py HomeAssistant, + object_id: ObjectId, +) -> Result< + ( + Signal>>>>, + impl Future>, + ), + CreateSignalError, +> { + let entity_id = EntityId(Domain::Light, object_id); + + let (signal, task1) = + StateObject::, PowerSensorAttributes, Py>::signal( + py, + home_assistant, + entity_id, + ) + .context(StateObjectSignalSnafu)?; + + let (signal, task2) = signal + .map(|state_object_result_option| { + state_object_result_option.map(|state_object_result| { + Arc::new( + Result::as_ref(&state_object_result) + .map(|state_object| { + let amount = state_object.state.0; + let unit_of_measurement = state_object.attributes.unit_of_measurement; + + let power = unit_of_measurement.into_uom(amount); + + power + }) + .map_err(|e| todo!()), + ) + }) + }) + .context(MappedSignalSnafu)?; + + Ok(( + signal, + async move { tokio::try_join!(task1, task2).map(|_| ()) }, + )) +} diff --git a/home-assistant/src/sensor/mod.rs b/home-assistant/src/sensor/mod.rs new file mode 100644 index 0000000..38ef112 --- /dev/null +++ b/home-assistant/src/sensor/mod.rs @@ -0,0 +1,2 @@ +pub mod device_classes; +pub mod state_classes; diff --git a/home-assistant/src/sensor/state_classes/measurement.rs b/home-assistant/src/sensor/state_classes/measurement.rs new file mode 100644 index 0000000..8998a8b --- /dev/null +++ b/home-assistant/src/sensor/state_classes/measurement.rs @@ -0,0 +1,48 @@ +use pyo3::{ + exceptions::{PyException, PyValueError}, + prelude::*, +}; +use snafu::{ensure, ResultExt, Snafu}; + +#[derive(Debug)] +pub struct Measurement; + +#[derive(Debug, Snafu)] +pub enum ExtractMeasurementError { + /// couldn't extract the object as a string + ExtractStringError { source: PyErr }, + + /// the string {actual:?} is not "measurement" like it's supposed to be + NotMeasurement { actual: String }, +} + +impl From for PyErr { + fn from(error: ExtractMeasurementError) -> Self { + match &error { + ExtractMeasurementError::ExtractStringError { .. } => { + PyException::new_err(error.to_string()) + } + ExtractMeasurementError::NotMeasurement { .. } => { + PyValueError::new_err(error.to_string()) + } + } + } +} + +// TODO: replace with a derive(PyFromStrLiteral) / #[literal = "state_changed"] once I learn how to make something like that and see about serde or strum integration or inspiration +impl<'a, 'py> FromPyObject<'a, 'py> for Measurement { + type Error = ExtractMeasurementError; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> Result { + let string: &str = obj.extract().context(ExtractStringSnafu)?; + + ensure!( + string == "measurement", + NotMeasurementSnafu { + actual: string.to_owned() + } + ); + + Ok(Self) + } +} diff --git a/home-assistant/src/sensor/state_classes/mod.rs b/home-assistant/src/sensor/state_classes/mod.rs new file mode 100644 index 0000000..5b5505d --- /dev/null +++ b/home-assistant/src/sensor/state_classes/mod.rs @@ -0,0 +1 @@ +pub mod measurement; diff --git a/home-assistant/src/service_registry.rs b/home-assistant/src/service_registry.rs index eb934a7..c4dd6e3 100644 --- a/home-assistant/src/service_registry.rs +++ b/home-assistant/src/service_registry.rs @@ -1,33 +1,64 @@ use super::{event::context::context::Context, service::IntoServiceCall}; -use pyo3::prelude::*; -use python_utils::{detach, validate_type_by_name}; +use pyo3::{ + exceptions::{PyException, PyTypeError}, + prelude::*, +}; +use python_utils::{detach, validate_type_by_name, TypeByNameValidationError}; +use snafu::{ResultExt, Snafu}; #[derive(Debug)] pub struct ServiceRegistry(Py); -impl<'py> FromPyObject<'py> for ServiceRegistry { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { +impl<'a, 'py> FromPyObject<'a, 'py> for ServiceRegistry { + type Error = TypeByNameValidationError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { // region: Validation - validate_type_by_name(ob, "ServiceRegistry")?; + validate_type_by_name(&ob, "ServiceRegistry")?; // endregion: Validation Ok(Self(detach(ob))) } } +#[derive(Debug, Snafu)] +pub enum CallServiceError { + /// couldn't successfully call `async_call` and turn it into a `Future` + CallIntoFutureError { source: PyErr }, + + /// couldn't await the `Future` from the `async_call` + AwaitFutureError { source: PyErr }, + + /// couldn't extract the service response as the requested type + ExtractServiceResponseError { source: PyErr }, +} + +impl From for PyErr { + fn from(error: CallServiceError) -> Self { + match &error { + CallServiceError::CallIntoFutureError { .. } => PyException::new_err(error.to_string()), + CallServiceError::AwaitFutureError { .. } => PyException::new_err(error.to_string()), + CallServiceError::ExtractServiceResponseError { .. } => { + PyTypeError::new_err(error.to_string()) + } + } + } +} + impl ServiceRegistry { pub async fn call_service< + 'a, ServiceData: for<'py> IntoPyObject<'py>, Target: for<'py> IntoPyObject<'py>, Event: for<'py> IntoPyObject<'py>, - ServiceResponse: for<'py> FromPyObject<'py>, + ServiceResponse: 'static + for<'py> FromPyObjectOwned<'py>, >( - &self, + &'a self, service_call: impl IntoServiceCall, context: Option>, target: Option, return_response: bool, - ) -> PyResult { + ) -> Result { let (domain, service, service_data) = service_call.into_service_call(); let blocking = true; @@ -42,13 +73,21 @@ impl ServiceRegistry { return_response, ); - let future = Python::with_gil::<_, PyResult<_>>(|py| { + let future = Python::attach::<_, PyResult<_>>(|py| { let service_registry = self.0.bind(py); let awaitable = service_registry.call_method("async_call", args, None)?; pyo3_async_runtimes::tokio::into_future(awaitable) + }) + .context(CallIntoFutureSnafu)?; + + let service_response = future.await.context(AwaitFutureSnafu)?; + let service_response = Python::attach(move |py| { + service_response + .extract(py) + .map_err(Into::into) + .context(ExtractServiceResponseSnafu) })?; - let service_response = future.await?; - Python::with_gil(|py| service_response.extract(py)) + Ok(service_response) } } diff --git a/home-assistant/src/slug.rs b/home-assistant/src/slug.rs index 729daf3..2b668f4 100644 --- a/home-assistant/src/slug.rs +++ b/home-assistant/src/slug.rs @@ -1,11 +1,11 @@ -use std::str::FromStr; +use std::{str::FromStr, sync::Arc}; use pyo3::{exceptions::PyValueError, PyErr}; -use smol_str::SmolStr; use snafu::Snafu; +// TODO: derive(PyFromStr) (analogous to serde_with::DeserializeFromStr) once I make one #[derive(Debug, Clone, derive_more::Display)] -pub struct Slug(SmolStr); +pub struct Slug(Arc); #[derive(Debug, Clone, Snafu)] #[snafu(display("expected a lowercase ASCII alphabetical character (i.e. a through z) or a digit (i.e. 0 through 9) or an underscore (i.e. _) but encountered {encountered}"))] diff --git a/home-assistant/src/state.rs b/home-assistant/src/state.rs deleted file mode 100644 index a6b48b7..0000000 --- a/home-assistant/src/state.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::{convert::Infallible, str::FromStr}; - -use pyo3::{exceptions::PyValueError, prelude::*}; -use smol_str::SmolStr; -use strum::EnumString; - -/// A state in Home Assistant that is known to represent an error of some kind: -/// * `unavailable` (the device is likely offline or unreachable from the Home Assistant instance) -/// * `unknown` (I don't know how to explain this one) -#[derive(Debug, Clone, EnumString, strum::Display)] -#[strum(serialize_all = "snake_case")] -pub enum ErrorState { - Unavailable, - Unknown, -} - -impl<'py> FromPyObject<'py> for ErrorState { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { - let s = ob.extract::()?; - - let state = - ErrorState::from_str(&s).map_err(|err| PyValueError::new_err(err.to_string()))?; - - Ok(state) - } -} - -#[derive(Debug, Clone, derive_more::Display, derive_more::FromStr)] -pub struct UnexpectedState(pub SmolStr); - -impl<'py> FromPyObject<'py> for UnexpectedState { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { - let s = ob.extract::()?; - let s = SmolStr::new(s); - - Ok(UnexpectedState(s)) - } -} - -#[derive(Debug, Clone, derive_more::Display)] -pub enum HomeAssistantState { - Ok(State), - Err(ErrorState), - UnexpectedErr(UnexpectedState), -} - -impl FromStr for HomeAssistantState { - type Err = Infallible; - - fn from_str(s: &str) -> Result::Err> { - if let Ok(ok) = State::from_str(s) { - return Ok(HomeAssistantState::Ok(ok)); - } - - if let Ok(error) = ErrorState::from_str(s) { - return Ok(HomeAssistantState::Err(error)); - } - - Ok(HomeAssistantState::UnexpectedErr(UnexpectedState(s.into()))) - } -} - -impl<'py, State: FromStr + FromPyObject<'py>> FromPyObject<'py> for HomeAssistantState { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { - let s = ob.extract::()?; - - let Ok(state) = s.parse(); - - Ok(state) - } -} diff --git a/home-assistant/src/state/error_state.rs b/home-assistant/src/state/error_state.rs new file mode 100644 index 0000000..2c50931 --- /dev/null +++ b/home-assistant/src/state/error_state.rs @@ -0,0 +1,55 @@ +use std::str::FromStr; + +use pyo3::{ + exceptions::{PyException, PyValueError}, + prelude::*, +}; +use snafu::{ResultExt, Snafu}; +use strum::EnumString; + +/// A state in Home Assistant that is known to represent an error of some kind: +/// * `unavailable` (the device is likely offline or unreachable from the Home Assistant instance) +/// * `unknown` (I don't know how to explain this one) +#[derive(Debug, Clone, EnumString, strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum ErrorState { + Unavailable, + Unknown, +} + +#[derive(Debug, Snafu)] +pub enum ExtractErrorStateError { + /// couldn't extract the object as a string + ExtractStringError { source: PyErr }, + + /// the string had an unexpected value + UnexpectedValue { + source: ::Err, + }, +} + +impl From for PyErr { + fn from(error: ExtractErrorStateError) -> Self { + match &error { + ExtractErrorStateError::ExtractStringError { .. } => { + PyException::new_err(error.to_string()) + } + ExtractErrorStateError::UnexpectedValue { .. } => { + PyValueError::new_err(error.to_string()) + } + } + } +} + +// TODO: replace with a derive(PyFromStr) (analogous to serde_with::DeserializeFromStr) once I make one +impl<'a, 'py> FromPyObject<'a, 'py> for ErrorState { + type Error = ExtractErrorStateError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { + let s = ob.extract::().context(ExtractStringSnafu)?; + + let state = ErrorState::from_str(&s).context(UnexpectedValueSnafu)?; + + Ok(state) + } +} diff --git a/home-assistant/src/state/mod.rs b/home-assistant/src/state/mod.rs new file mode 100644 index 0000000..185eab0 --- /dev/null +++ b/home-assistant/src/state/mod.rs @@ -0,0 +1,64 @@ +use std::{convert::Infallible, str::FromStr}; + +use pyo3::{exceptions::PyException, prelude::*}; +use snafu::{ResultExt, Snafu}; + +pub mod error_state; +pub mod unexpected_state; + +pub use error_state::ErrorState; +pub use unexpected_state::UnexpectedState; + +#[derive(Debug, Clone, derive_more::Display)] +pub enum HomeAssistantState { + Ok(State), + Err(ErrorState), + UnexpectedErr(UnexpectedState), +} + +impl FromStr for HomeAssistantState { + type Err = Infallible; + + fn from_str(s: &str) -> Result::Err> { + if let Ok(ok) = State::from_str(s) { + return Ok(HomeAssistantState::Ok(ok)); + } + + if let Ok(error) = ErrorState::from_str(s) { + return Ok(HomeAssistantState::Err(error)); + } + + Ok(HomeAssistantState::UnexpectedErr(UnexpectedState(s.into()))) + } +} + +#[derive(Debug, Snafu)] +pub enum ExtractHomeAssistantStateError { + /// couldn't extract the object as a string + ExtractStringError { source: PyErr }, +} + +impl From for PyErr { + fn from(error: ExtractHomeAssistantStateError) -> Self { + match &error { + ExtractHomeAssistantStateError::ExtractStringError { .. } => { + PyException::new_err(error.to_string()) + } + } + } +} + +// TODO: replace with a derive(PyFromStr) (analogous to serde_with::DeserializeFromStr) once I make one +impl<'a, 'py, State: FromStr + FromPyObject<'a, 'py>> FromPyObject<'a, 'py> + for HomeAssistantState +{ + type Error = ExtractHomeAssistantStateError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { + let s = ob.extract::<&str>().context(ExtractStringSnafu)?; + + let Ok(state) = s.parse(); + + Ok(state) + } +} diff --git a/home-assistant/src/state/unexpected_state.rs b/home-assistant/src/state/unexpected_state.rs new file mode 100644 index 0000000..6c6986b --- /dev/null +++ b/home-assistant/src/state/unexpected_state.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use pyo3::{exceptions::PyException, prelude::*}; +use snafu::{ResultExt, Snafu}; + +#[derive(Debug, Clone, derive_more::Display)] +pub struct UnexpectedState(pub Arc); + +#[derive(Debug, Snafu)] +pub enum ExtractUnexpectedStateError { + /// couldn't extract the object as a string + ExtractStringError { source: PyErr }, +} + +impl From for PyErr { + fn from(error: ExtractUnexpectedStateError) -> Self { + match &error { + ExtractUnexpectedStateError::ExtractStringError { .. } => { + PyException::new_err(error.to_string()) + } + } + } +} + +// TODO: replace with a derive(PyFromStr) (analogous to serde_with::DeserializeFromStr) once I make one +impl<'a, 'py> FromPyObject<'a, 'py> for UnexpectedState { + type Error = ExtractUnexpectedStateError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { + let s = ob.extract::().context(ExtractStringSnafu)?; + let s = s.into(); + + Ok(UnexpectedState(s)) + } +} diff --git a/home-assistant/src/state_machine.rs b/home-assistant/src/state_machine.rs index a88c3d9..720db4f 100644 --- a/home-assistant/src/state_machine.rs +++ b/home-assistant/src/state_machine.rs @@ -1,34 +1,58 @@ +use std::sync::Arc; + use super::entity_id::EntityId; use super::state_object::StateObject; use pyo3::prelude::*; -use python_utils::{detach, validate_type_by_name}; +use python_utils::{detach, validate_type_by_name, TypeByNameValidationError}; +use snafu::{ResultExt, Snafu}; #[derive(Debug)] pub struct StateMachine(Py); -impl<'py> FromPyObject<'py> for StateMachine { - fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { +impl<'a, 'py> FromPyObject<'a, 'py> for StateMachine { + type Error = TypeByNameValidationError; + + fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result { // region: Validation - validate_type_by_name(ob, "StateMachine")?; + validate_type_by_name(&ob, "StateMachine")?; // endregion: Validation Ok(Self(detach(ob))) } } +#[derive(Debug, Clone, Snafu)] +pub enum GetStateError { + /// couldn't get this state object from the state machine + GetStateObjectError { source: Arc }, + + /// couldn't extract the state as a [`StateObject`] + ExtractStateObjectError { source: ExtractStateObjectError }, +} + impl StateMachine { pub fn get< + 'a, 'py, - State: FromPyObject<'py>, - Attributes: FromPyObject<'py>, - ContextEvent: FromPyObject<'py>, + State: FromPyObjectOwned<'py>, + Attributes: FromPyObjectOwned<'py>, + ContextEvent: FromPyObjectOwned<'py>, >( - &self, + &'a self, py: Python<'py>, entity_id: EntityId, - ) -> PyResult>> { + ) -> Result< + Option>, + GetStateError< + > as FromPyObject<'a, 'py>>::Error, + >, + > { let args = (entity_id.to_string(),); - let state = self.0.call_method1(py, "get", args)?; - state.extract(py) + let state = self + .0 + .call_method1(py, "get", args) + .map_err(Arc::new) + .context(GetStateObjectSnafu)?; + Ok(state.extract(py).context(ExtractStateObjectSnafu)?) } } diff --git a/home-assistant/src/state_object.rs b/home-assistant/src/state_object.rs index c50eaf7..f81159c 100644 --- a/home-assistant/src/state_object.rs +++ b/home-assistant/src/state_object.rs @@ -2,7 +2,7 @@ use super::{ event::{context::context::Context, specific::state_changed}, home_assistant::HomeAssistant, }; -use crate::entity_id::EntityId; +use crate::{entity_id::EntityId, home_assistant::GetStatesError, state_machine::GetStateError}; use chrono::{DateTime, Utc}; use emitter_and_signal::signal::Signal; use once_cell::sync::OnceCell; @@ -10,6 +10,7 @@ use pyo3::{ prelude::*, types::{PyCFunction, PyDict, PyTuple}, }; +use snafu::{ResultExt, Snafu}; use std::{future::Future, sync::Arc}; use tokio::{select, sync::mpsc}; @@ -24,48 +25,86 @@ pub struct StateObject { pub context: Context, } +pub type ExtractStateObjectError<'a, 'py, State, Attributes, ContextEvent> = + as FromPyObject<'a, 'py>>::Error; + +#[derive(Debug, Snafu)] +pub enum CreateSignalError { + /// couldn't get the state machine from the Home Assistant object + GetStatesError { source: GetStatesError }, +} + +#[derive(Debug, Clone, Snafu)] +pub enum StateObjectSignalError { + /// couldn't get the state object directly from the state machine + GetFromStateMachine { + source: GetStateError, + }, + + /// couldn't get the state object from the new state event + GetFromNewStateEvent { source: Arc }, +} + impl< - State: Send + Sync + 'static + for<'py> FromPyObject<'py>, - Attributes: Send + Sync + 'static + for<'py> FromPyObject<'py>, - ContextEvent: Send + Sync + 'static + for<'py> FromPyObject<'py>, + State: Send + Sync + 'static + for<'a, 'py> FromPyObject<'a, 'py>, + Attributes: Send + Sync + 'static + for<'a, 'py> FromPyObject<'a, 'py>, + ContextEvent: Send + Sync + 'static + for<'a, 'py> FromPyObject<'a, 'py>, > StateObject { - pub fn store( - py: Python<'_>, - home_assistant: &HomeAssistant, + pub fn signal<'a, 'py>( + py: Python<'py>, + home_assistant: &'py HomeAssistant, entity_id: EntityId, - ) -> PyResult<( - Signal>>, - impl Future>, - )> { - let state_machine = home_assistant.states(py)?; - let current = state_machine.get(py, entity_id.clone())?; + ) -> Result< + ( + Signal< + Option< + Arc< + Result< + Self, + StateObjectSignalError<>::Error>, + >, + >, + >, + >, + impl Future>, + ), + CreateSignalError, + > { + let state_machine = home_assistant.states(py).context(GetStatesSnafu)?; + let current = state_machine + .get(py, entity_id.clone()) + .context(GetFromStateMachineSnafu) + .transpose(); - let py_home_assistant = home_assistant.into_pyobject(py)?.unbind(); + let Ok(py_home_assistant) = home_assistant.into_pyobject(py); + let py_home_assistant = py_home_assistant.unbind(); - let (store, task) = Signal::new(current.map(Arc::new), |mut publisher_stream| async move { - while let Some(publisher) = publisher_stream.wait().await { - let (new_state_sender, mut new_state_receiver) = mpsc::channel(8); + let (signal, task) = Signal::new( + current.map(Arc::new), + |mut publisher_stream| async move { + while let Some(publisher) = publisher_stream.wait().await { + let (new_state_sender, mut new_state_receiver) = mpsc::channel(8); - let untrack = Python::with_gil::<_, PyResult<_>>(|py| { - static EVENT_MODULE: OnceCell> = OnceCell::new(); + let untrack = Python::attach::<_, PyResult<_>>(|py| { + static EVENT_MODULE: OnceCell> = OnceCell::new(); - let event_module = EVENT_MODULE - .get_or_try_init(|| { - Result::<_, PyErr>::Ok( - py.import("homeassistant.helpers.event")?.unbind(), - ) - })? - .bind(py); + let event_module = EVENT_MODULE + .get_or_try_init(|| { + Result::<_, PyErr>::Ok( + py.import("homeassistant.helpers.event")?.unbind(), + ) + })? + .bind(py); - let untrack = { - let callback = + let untrack = { + let callback = move |args: &Bound<'_, PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>| { #[cfg(feature = "tracing")] tracing::debug!("calling the closure"); - if let Ok((event,)) = args.extract::<( + let new_state_res = args.extract::<( state_changed::Event< State, Attributes, @@ -75,77 +114,65 @@ impl< ContextEvent, Py, >, - )>() { - let new_state = event.data.new_state; + )>().map(|event| event.0.data.new_state).map_err(Arc::new).context(GetFromNewStateEventSnafu); + + new_state_sender.try_send(new_state_res).unwrap(); + }; + let callback = PyCFunction::new_closure(py, None, None, callback)?; + let args = ( + py_home_assistant.clone_ref(py), + vec![entity_id.clone()], + callback, + ); + event_module.call_method1("async_track_state_change_event", args)? + }; + + let untrack = untrack.unbind(); + + Ok(untrack) + }); + + if let Ok(untrack) = untrack { + #[cfg(feature = "tracing")] + tracing::debug!( + "untrack is ok, going to wait for the next relevant event..." + ); + loop { + select! { + biased; + _ = publisher.all_unsubscribed() => { + #[cfg(feature = "tracing")] + tracing::debug!("calling untrack"); + let res = Python::attach(|py| untrack.call0(py)); #[cfg(feature = "tracing")] - tracing::debug!("sending a new state"); // TODO: remove - new_state_sender.try_send(new_state).unwrap(); + tracing::debug!(?res); + break; } - }; - let callback = PyCFunction::new_closure(py, None, None, callback)?; - let args = ( - py_home_assistant.clone_ref(py), - vec![entity_id.clone()], - callback, - ); - event_module.call_method1("async_track_state_change_event", args)? - }; - #[cfg(feature = "tracing")] - tracing::debug!(?untrack, "as any"); - - let is_callable = untrack.is_callable(); - #[cfg(feature = "tracing")] - tracing::debug!(?is_callable); - - // let untrack = untrack.downcast_into::()?; - // tracing::debug!(?untrack, "as downcast"); - - let untrack = untrack.unbind(); - #[cfg(feature = "tracing")] - tracing::debug!(?untrack, "as unbound"); - - Ok(untrack) - }); - - if let Ok(untrack) = untrack { - #[cfg(feature = "tracing")] - tracing::debug!("untrack is ok, going to wait for the next relevant event..."); - loop { - select! { - biased; - _ = publisher.all_unsubscribed() => { - #[cfg(feature = "tracing")] - tracing::debug!("calling untrack"); - let res = Python::with_gil(|py| untrack.call0(py)); - - #[cfg(feature = "tracing")] - tracing::debug!(?res); - break; - } - new_state = new_state_receiver.recv() => { - match new_state { - Some(new_state) => { - #[cfg(feature = "tracing")] - tracing::debug!("publishing new state"); - publisher.publish(new_state.map(Arc::new)) - }, - None => { - #[cfg(feature = "tracing")] - tracing::debug!("channel dropped"); - break - }, + new_state_res_option = new_state_receiver.recv() => { + match new_state_res_option { + Some(new_state_res) => { + #[cfg(feature = "tracing")] + tracing::debug!("publishing new state"); + publisher.publish(new_state_res.transpose().map(Arc::new)); + }, + None => { + #[cfg(feature = "tracing")] + tracing::debug!("channel dropped"); + break + }, + } } } } + } else { + #[cfg(feature = "tracing")] + tracing::debug!("untrack is err"); } - } else { - #[cfg(feature = "tracing")] - tracing::debug!("untrack is err"); } - } - }); + }, + ); - Ok((store, task)) + Ok((signal, task)) } } diff --git a/home-assistant/src/unit_of_measurement/mod.rs b/home-assistant/src/unit_of_measurement/mod.rs new file mode 100644 index 0000000..e49bf37 --- /dev/null +++ b/home-assistant/src/unit_of_measurement/mod.rs @@ -0,0 +1 @@ +pub mod power; diff --git a/home-assistant/src/unit_of_measurement/power.rs b/home-assistant/src/unit_of_measurement/power.rs new file mode 100644 index 0000000..852f6b2 --- /dev/null +++ b/home-assistant/src/unit_of_measurement/power.rs @@ -0,0 +1,102 @@ +use std::str::FromStr; + +use pyo3::{ + exceptions::{PyException, PyValueError}, + prelude::*, +}; +use snafu::{ResultExt, Snafu}; +use strum::EnumString; +use uom::{ + si::{ + energy::btu, + power::{gigawatt, kilowatt, megawatt, milliwatt, terawatt, watt}, + quantities::{Energy, Power, Time}, + time::hour, + Units, SI, + }, + Conversion, +}; + +/// Power units +#[derive(Debug, Clone, Copy, EnumString, strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum UnitOfMeasurement { + #[strum(serialize = "mW")] + MilliWatt, + #[strum(serialize = "W")] + Watt, + #[strum(serialize = "kW")] + KiloWatt, + #[strum(serialize = "MW")] + MegaWatt, + #[strum(serialize = "GW")] + GigaWatt, + #[strum(serialize = "TW")] + TeraWatt, + #[strum(serialize = "BTU/h")] + BtuPerhour, +} + +#[derive(Debug, Snafu)] +pub enum ExtractUnitOfMeasurementError { + /// couldn't extract the object as a string + ExtractStringError { source: PyErr }, + + /// couldn't parse the string as a [`UnitOfMeasurement`] + ParseError { + source: ::Err, + }, +} + +impl From for PyErr { + fn from(error: ExtractUnitOfMeasurementError) -> Self { + match &error { + ExtractUnitOfMeasurementError::ExtractStringError { .. } => { + PyException::new_err(error.to_string()) + } + ExtractUnitOfMeasurementError::ParseError { .. } => { + PyValueError::new_err(error.to_string()) + } + } + } +} + +// TODO: replace with a derive(PyFromStr) (analogous to serde_with::DeserializeFromStr) once I make one +impl<'a, 'py> FromPyObject<'a, 'py> for UnitOfMeasurement { + type Error = ExtractUnitOfMeasurementError; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> Result { + let s = obj.extract().context(ExtractStringSnafu)?; + let unit_of_measurement = UnitOfMeasurement::from_str(s).context(ParseSnafu)?; + + Ok(unit_of_measurement) + } +} + +impl UnitOfMeasurement { + pub fn into_uom(&self, amount: V) -> Power + where + V: uom::num::Num + uom::Conversion, + milliwatt: Conversion, + watt: Conversion, + kilowatt: Conversion, + megawatt: Conversion, + gigawatt: Conversion, + terawatt: Conversion, + btu: Conversion, + hour: Conversion, + SI: Units, + { + match self { + UnitOfMeasurement::MilliWatt => Power::new::(amount), + UnitOfMeasurement::Watt => Power::new::(amount), + UnitOfMeasurement::KiloWatt => Power::new::(amount), + UnitOfMeasurement::MegaWatt => Power::new::(amount), + UnitOfMeasurement::GigaWatt => Power::new::(amount), + UnitOfMeasurement::TeraWatt => Power::new::(amount), + UnitOfMeasurement::BtuPerhour => { + Energy::new::(amount) / Time::new::(V::one()) + } + } + } +}