chore+feat(home-assistant): update to pyo3 0.27 and update extraction errors, switch out SmolStr for Arc<str>, tighten up light service calls and implement some for notify, start implementing units of measurement like for power

This commit is contained in:
2026-01-07 02:10:03 -05:00
parent 97aef026b2
commit fa36b39e81
35 changed files with 1255 additions and 259 deletions

View File

@@ -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 }

View File

@@ -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<EntityIdParsingError> 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<ExtractEntityIdError> 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<Self> {
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<Self, Self::Error> {
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>;

View File

@@ -28,7 +28,7 @@ impl<'py, Event: IntoPyObject<'py>> IntoPyObject<'py> for Context<Event> {
.bind(py);
let context_class = homeassistant_core.getattr("Context")?;
let context_class = context_class.downcast_into::<PyType>()?;
let context_class = context_class.cast_into::<PyType>()?;
let context_instance = context_class.call1((self.user_id, self.parent_id, self.id))?;

View File

@@ -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<str>),
}
impl<'py> FromPyObject<'py> for Id {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
let s = ob.extract::<String>()?;
#[derive(Debug, Snafu)]
pub enum ExtractIdError {
/// couldn't extract the given object as a string
ExtractStringError { source: PyErr },
}
impl From<ExtractIdError> 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<Self, Self::Error> {
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<Self::Output, Self::Error> {
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),
}
}
}

View File

@@ -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<Self> {
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: <EventOrigin as FromStr>::Err,
},
}
impl From<ExtractEventOriginError> 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<Self, Self::Error> {
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)
}

View File

@@ -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<Self> {
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<ExtractTypeError> 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<Self, Self::Error> {
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() })
}
}
}

View File

@@ -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<PyAny>);
impl<'source> FromPyObject<'source> for HomeAssistant {
fn extract_bound(ob: &Bound<'source, PyAny>) -> PyResult<Self> {
impl<'a, 'py> FromPyObject<'a, 'py> for HomeAssistant {
type Error = TypeByNameValidationError;
fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result<Self, Self::Error> {
// 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<String, PyErr> {
@@ -48,13 +69,19 @@ impl HomeAssistant {
is_stopping.extract(py)
}
pub fn states(&self, py: Python<'_>) -> Result<StateMachine, PyErr> {
let states = self.0.getattr(py, "states")?;
states.extract(py)
pub fn states(&self, py: Python<'_>) -> Result<StateMachine, GetStatesError> {
let states = self
.0
.getattr(py, "states")
.context(GetStatesAttributeSnafu)?;
states.extract(py).context(ExtractStateMachineSnafu)
}
pub fn services(&self, py: Python<'_>) -> Result<ServiceRegistry, PyErr> {
let services = self.0.getattr(py, "services")?;
services.extract(py)
pub fn services(&self, py: Python<'_>) -> Result<ServiceRegistry, GetServicesError> {
let services = self
.0
.getattr(py, "services")
.context(GetServicesAttributeSnafu)?;
services.extract(py).context(ExtractServiceRegistrySnafu)
}
}

View File

@@ -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<
<StateObject<HomeAssistantState<LightState>, LightAttributes, Py<PyAny>> as FromPyObject<'static, 'static>>::Error
> },
/// this entity does not have a state object in the registry
EntityMissing,
}
@@ -40,12 +50,12 @@ impl HomeAssistantLight {
StateObject<HomeAssistantState<LightState>, LightAttributes, Py<PyAny>>,
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)

View File

@@ -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<Context<()>> = 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(())
}

View File

@@ -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 };

View File

@@ -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)

View File

@@ -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<Self> {
let s = ob.extract::<String>()?;
#[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: <LightState as FromStr>::Err,
},
}
impl From<ExtractLightStateError> 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<Self, Self::Error> {
let s = ob.extract::<&str>().context(ExtractStringSnafu)?;
let state = LightState::from_str(&s).context(ParseSnafu)?;
Ok(state)
}

View File

@@ -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<PyAny>);
impl<'source> FromPyObject<'source> for HassLogger {
fn extract_bound(ob: &Bound<'source, PyAny>) -> PyResult<Self> {
impl<'a, 'py> FromPyObject<'a, 'py> for HassLogger {
type Error = TypeByNameValidationError;
fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result<Self, Self::Error> {
// region: Validation
validate_type_by_name(ob, "HassLogger")?;
validate_type_by_name(&ob, "HassLogger")?;
// endregion: Validation
Ok(Self(detach(ob)))

View File

@@ -0,0 +1 @@
pub mod service;

View File

@@ -0,0 +1,3 @@
mod request_location_update;
pub use request_location_update::RequestLocationUpdate;

View File

@@ -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)
}
}

View File

@@ -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<Self, Self::Err> {
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<Self::Output, Self::Error> {
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<Self::Output, Self::Error> {
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<Self::Output, Self::Error> {
let s = self.to_string();
s.into_pyobject(py)
}
}
// TODO: better typed versions like `CallNumber` or `OpenWebpage` where Action: From<CallNumber> and Action: From<OpenWebPage>
#[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<IntoPyObjectViaDisplay<Url>>,
pub behavior: Option<Behavior>,
// 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<ActivationMode>,
// TODO: make this written as authenticationRequired
/// (iOS only) require entering a passcode to use the action
pub authentication_required: Option<bool>,
/// (iOS only) color the action's title red, indicating a destructive action
pub destructive: Option<bool>,
// TODO: make this written as textInputButtonTitle
/// (iOS only) Title to use for text input for actions that prompt
pub text_input_button_title: Option<String>,
// TODO: make this written as textInputPlaceholder
/// (iOS only) Placeholder to use for text input for actions that prompt
pub text_input_placeholder: Option<String>,
// TODO: proper type
/// (iOS only) icon to use for the notification
pub icon: Option<String>,
}
#[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<Self::Output, Self::Error> {
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<Vec<Action>>,
media_stream: Option<MediaStream>,
tts_text: Option<String>,
visibility: Option<Visibility>,
}
#[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<String>,
#[builder(setter(!strip_option))]
data: NotifyMobileAppServiceDataData,
}

View File

@@ -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<String>,
pub message: NonSpecialMessage,
#[builder(default, setter(strip_option))]
pub actions: Option<Vec1<Action>>,
#[builder(default, setter(strip_option))]
pub visibility: Option<Visibility>,
}
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)
}
}

View File

@@ -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<MediaStream>,
}
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)
}
}

View File

@@ -0,0 +1 @@
pub mod mobile_app;

View File

@@ -0,0 +1 @@
mod power;

View File

@@ -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<ExtractPowerError> 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<Self, Self::Error> {
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<Option<Arc<Result<uom::si::f64::Power, StateObjectSignalError<PyErr>>>>>,
impl Future<Output = Result<(), emitter_and_signal::signal::JoinError>>,
),
CreateSignalError,
> {
let entity_id = EntityId(Domain::Light, object_id);
let (signal, task1) =
StateObject::<FromPyObjectViaParse<f64>, PowerSensorAttributes, Py<PyAny>>::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(|_| ()) },
))
}

View File

@@ -0,0 +1,2 @@
pub mod device_classes;
pub mod state_classes;

View File

@@ -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<ExtractMeasurementError> 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<Self, Self::Error> {
let string: &str = obj.extract().context(ExtractStringSnafu)?;
ensure!(
string == "measurement",
NotMeasurementSnafu {
actual: string.to_owned()
}
);
Ok(Self)
}
}

View File

@@ -0,0 +1 @@
pub mod measurement;

View File

@@ -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<PyAny>);
impl<'py> FromPyObject<'py> for ServiceRegistry {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
impl<'a, 'py> FromPyObject<'a, 'py> for ServiceRegistry {
type Error = TypeByNameValidationError;
fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result<Self, Self::Error> {
// 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<CallServiceError> 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<ServiceData = ServiceData>,
context: Option<Context<Event>>,
target: Option<Target>,
return_response: bool,
) -> PyResult<ServiceResponse> {
) -> Result<ServiceResponse, CallServiceError> {
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)
}
}

View File

@@ -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<str>);
#[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}"))]

View File

@@ -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<Self> {
let s = ob.extract::<String>()?;
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<Self> {
let s = ob.extract::<String>()?;
let s = SmolStr::new(s);
Ok(UnexpectedState(s))
}
}
#[derive(Debug, Clone, derive_more::Display)]
pub enum HomeAssistantState<State> {
Ok(State),
Err(ErrorState),
UnexpectedErr(UnexpectedState),
}
impl<State: FromStr> FromStr for HomeAssistantState<State> {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, <Self as FromStr>::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<State> {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
let s = ob.extract::<String>()?;
let Ok(state) = s.parse();
Ok(state)
}
}

View File

@@ -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: <ErrorState as FromStr>::Err,
},
}
impl From<ExtractErrorStateError> 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<Self, Self::Error> {
let s = ob.extract::<String>().context(ExtractStringSnafu)?;
let state = ErrorState::from_str(&s).context(UnexpectedValueSnafu)?;
Ok(state)
}
}

View File

@@ -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<State> {
Ok(State),
Err(ErrorState),
UnexpectedErr(UnexpectedState),
}
impl<State: FromStr> FromStr for HomeAssistantState<State> {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, <Self as FromStr>::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<ExtractHomeAssistantStateError> 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<State>
{
type Error = ExtractHomeAssistantStateError;
fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result<Self, Self::Error> {
let s = ob.extract::<&str>().context(ExtractStringSnafu)?;
let Ok(state) = s.parse();
Ok(state)
}
}

View File

@@ -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<str>);
#[derive(Debug, Snafu)]
pub enum ExtractUnexpectedStateError {
/// couldn't extract the object as a string
ExtractStringError { source: PyErr },
}
impl From<ExtractUnexpectedStateError> 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<Self, Self::Error> {
let s = ob.extract::<String>().context(ExtractStringSnafu)?;
let s = s.into();
Ok(UnexpectedState(s))
}
}

View File

@@ -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<PyAny>);
impl<'py> FromPyObject<'py> for StateMachine {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
impl<'a, 'py> FromPyObject<'a, 'py> for StateMachine {
type Error = TypeByNameValidationError;
fn extract(ob: Borrowed<'a, 'py, PyAny>) -> Result<Self, Self::Error> {
// 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<ExtractStateObjectError: 'static + snafu::Error> {
/// couldn't get this state object from the state machine
GetStateObjectError { source: Arc<PyErr> },
/// 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<Option<StateObject<State, Attributes, ContextEvent>>> {
) -> Result<
Option<StateObject<State, Attributes, ContextEvent>>,
GetStateError<
<Option<StateObject<State, Attributes, ContextEvent>> 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)?)
}
}

View File

@@ -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,30 +25,68 @@ pub struct StateObject<State, Attributes, ContextEvent> {
pub context: Context<ContextEvent>,
}
pub type ExtractStateObjectError<'a, 'py, State, Attributes, ContextEvent> =
<StateObject<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<ExtractStateObjectError: 'static + snafu::Error> {
/// couldn't get the state object directly from the state machine
GetFromStateMachine {
source: GetStateError<ExtractStateObjectError>,
},
/// couldn't get the state object from the new state event
GetFromNewStateEvent { source: Arc<PyErr> },
}
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<State, Attributes, ContextEvent>
{
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<Option<Arc<Self>>>,
) -> Result<
(
Signal<
Option<
Arc<
Result<
Self,
StateObjectSignalError<<Self as FromPyObject<'a, 'py>>::Error>,
>,
>,
>,
>,
impl Future<Output = Result<(), emitter_and_signal::signal::JoinError>>,
)> {
let state_machine = home_assistant.states(py)?;
let current = state_machine.get(py, entity_id.clone())?;
),
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 {
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| {
let untrack = Python::attach::<_, PyResult<_>>(|py| {
static EVENT_MODULE: OnceCell<Py<PyModule>> = OnceCell::new();
let event_module = EVENT_MODULE
@@ -65,7 +104,7 @@ impl<
#[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,13 +114,9 @@ impl<
ContextEvent,
Py<PyAny>,
>,
)>() {
let new_state = event.data.new_state;
)>().map(|event| event.0.data.new_state).map_err(Arc::new).context(GetFromNewStateEventSnafu);
#[cfg(feature = "tracing")]
tracing::debug!("sending a new state"); // TODO: remove
new_state_sender.try_send(new_state).unwrap();
}
new_state_sender.try_send(new_state_res).unwrap();
};
let callback = PyCFunction::new_closure(py, None, None, callback)?;
let args = (
@@ -91,44 +126,35 @@ impl<
);
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::<PyFunction>()?;
// 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...");
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));
let res = Python::attach(|py| untrack.call0(py));
#[cfg(feature = "tracing")]
tracing::debug!(?res);
break;
}
new_state = new_state_receiver.recv() => {
match new_state {
Some(new_state) => {
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.map(Arc::new))
publisher.publish(new_state_res.transpose().map(Arc::new));
},
None => {
#[cfg(feature = "tracing")]
@@ -144,8 +170,9 @@ impl<
tracing::debug!("untrack is err");
}
}
});
},
);
Ok((store, task))
Ok((signal, task))
}
}

View File

@@ -0,0 +1 @@
pub mod power;

View File

@@ -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: <UnitOfMeasurement as FromStr>::Err,
},
}
impl From<ExtractUnitOfMeasurementError> 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<Self, Self::Error> {
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<V>(&self, amount: V) -> Power<V>
where
V: uom::num::Num + uom::Conversion<V, T = V>,
milliwatt: Conversion<V, T = V>,
watt: Conversion<V, T = V>,
kilowatt: Conversion<V, T = V>,
megawatt: Conversion<V, T = V>,
gigawatt: Conversion<V, T = V>,
terawatt: Conversion<V, T = V>,
btu: Conversion<V, T = V>,
hour: Conversion<V, T = V>,
SI<V>: Units<V>,
{
match self {
UnitOfMeasurement::MilliWatt => Power::new::<milliwatt>(amount),
UnitOfMeasurement::Watt => Power::new::<watt>(amount),
UnitOfMeasurement::KiloWatt => Power::new::<kilowatt>(amount),
UnitOfMeasurement::MegaWatt => Power::new::<megawatt>(amount),
UnitOfMeasurement::GigaWatt => Power::new::<gigawatt>(amount),
UnitOfMeasurement::TeraWatt => Power::new::<terawatt>(amount),
UnitOfMeasurement::BtuPerhour => {
Energy::new::<btu>(amount) / Time::new::<hour>(V::one())
}
}
}
}