diff --git a/src/arbitrary/arbitrary.rs b/src/arbitrary/arbitrary.rs new file mode 100644 index 0000000..5cc8017 --- /dev/null +++ b/src/arbitrary/arbitrary.rs @@ -0,0 +1,91 @@ +use chrono::DateTime; +use chrono_tz::Tz; +use ijson::{IArray, INumber, IObject, IString, IValue}; +use pyo3::{ + exceptions::{PyTypeError, PyValueError}, + prelude::*, +}; +use snafu::Snafu; + +use super::{finite_f64::FiniteF64, map::Map, map_key::MapKey}; + +#[derive(Debug, Clone, derive_more::From, derive_more::TryInto)] +pub enum Arbitrary { + Null, + Bool(bool), + Integer(i64), + Float(FiniteF64), + String(String), + Array(Vec), + Map(Map), + DateTime(DateTime), +} + +impl From for Arbitrary { + fn from(map_key: MapKey) -> Self { + match map_key { + MapKey::Null => Arbitrary::Null, + MapKey::Bool(b) => Arbitrary::Bool(b), + MapKey::Integer(int) => Arbitrary::Integer(int), + MapKey::String(s) => Arbitrary::String(s), + // close enough + MapKey::Tuple(vec) => Arbitrary::Array(vec.into_iter().map(Into::into).collect()), + MapKey::DateTime(date_time) => Arbitrary::DateTime(date_time), + } + } +} + +#[derive(Debug, Snafu)] +pub enum MapKeyFromArbitraryError { + #[snafu(display("floats aren't supported as map keys yet. got {value:?}"))] + FloatNotSupported { value: FiniteF64 }, + #[snafu(display("a map cannot be a map key. got {value:?}"))] + MapCannotBeAMapKey { value: Map }, +} + +impl From for IValue { + fn from(value: Arbitrary) -> Self { + match value { + Arbitrary::Null => IValue::NULL, + Arbitrary::Bool(true) => IValue::TRUE, + Arbitrary::Bool(false) => IValue::FALSE, + Arbitrary::Integer(int) => INumber::from(int).into(), + Arbitrary::Float(float) => INumber::try_from(f64::from(float)).unwrap().into(), + Arbitrary::String(s) => IString::from(s).into(), + Arbitrary::Array(vec) => { + IArray::from_iter(vec.into_iter().map(Into::::into)).into() + } + Arbitrary::Map(Map(btree_map)) => { + let mut object = IObject::new(); + + for (key, value) in btree_map { + let key: IString = key.into(); + object.insert(key, value); + } + + object.into() + } + Arbitrary::DateTime(date_time) => IString::from(date_time.to_rfc3339()).into(), + } + } +} + +impl<'py> FromPyObject<'py> for Arbitrary { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + if let Ok(map_key) = ob.extract::() { + Ok(map_key.into()) + } else if let Ok(map) = ob.extract() { + Ok(Self::Map(map)) + } else if let Ok(f) = ob.extract::() { + let f = FiniteF64::try_from(f).map_err(|err| PyValueError::new_err(err.to_string()))?; + Ok(Self::Float(f)) + } else if let Ok(vec) = ob.extract() { + Ok(Self::Array(vec)) + } else { + let type_name = ob.get_type().fully_qualified_name()?; + Err(PyTypeError::new_err(format!( + "can't extract an arbitrary from a {type_name}" + ))) + } + } +} diff --git a/src/arbitrary/finite_f64.rs b/src/arbitrary/finite_f64.rs new file mode 100644 index 0000000..b5bf69e --- /dev/null +++ b/src/arbitrary/finite_f64.rs @@ -0,0 +1,22 @@ +use snafu::Snafu; + +#[derive(Debug, Clone, derive_more::Into)] +pub struct FiniteF64(f64); + +#[derive(Debug, Snafu)] +#[snafu(display("{value:?} is not finite"))] +pub struct NotFinite { + value: f64, +} + +impl TryFrom for FiniteF64 { + type Error = NotFinite; + + fn try_from(value: f64) -> Result { + if value.is_finite() { + Ok(Self(value)) + } else { + Err(NotFinite { value }) + } + } +} diff --git a/src/arbitrary/map.rs b/src/arbitrary/map.rs new file mode 100644 index 0000000..601152f --- /dev/null +++ b/src/arbitrary/map.rs @@ -0,0 +1,16 @@ +use std::collections::BTreeMap; + +use pyo3::prelude::*; + +use super::{arbitrary::Arbitrary, map_key::MapKey}; + +#[derive(Debug, Clone, derive_more::From, derive_more::Into)] +pub struct Map(pub BTreeMap); + +impl<'py> FromPyObject<'py> for Map { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + let inner: BTreeMap = ob.extract()?; + + Ok(Self(inner)) + } +} diff --git a/src/arbitrary/map_key.rs b/src/arbitrary/map_key.rs new file mode 100644 index 0000000..0f57b58 --- /dev/null +++ b/src/arbitrary/map_key.rs @@ -0,0 +1,87 @@ +use std::fmt::Display; + +use chrono::DateTime; +use chrono_tz::Tz; +use ijson::IString; +use itertools::Itertools; +use pyo3::{exceptions::PyTypeError, prelude::*, types::PyNone}; + +use super::arbitrary::{Arbitrary, MapKeyFromArbitraryError}; + +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub enum MapKey { + Null, + Bool(bool), + Integer(i64), + String(String), + Tuple(Vec), + DateTime(DateTime), +} + +impl Display for MapKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MapKey::Null => write!(f, "null"), + MapKey::Bool(b) => write!(f, "{b}"), + MapKey::Integer(i) => write!(f, "{i}"), + MapKey::String(s) => write!(f, "{s}"), + MapKey::Tuple(vec) => { + let comma_separated = + Itertools::intersperse(vec.iter().map(ToString::to_string), ", ".to_string()); + write!(f, "({})", String::from_iter(comma_separated)) + } + MapKey::DateTime(date_time) => { + write!(f, "{}", date_time.to_rfc3339()) + } + } + } +} + +impl<'py> FromPyObject<'py> for MapKey { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + if let Ok(_none) = ob.downcast::() { + Ok(Self::Null) + } else if let Ok(b) = ob.extract() { + Ok(Self::Bool(b)) + } else if let Ok(int) = ob.extract() { + Ok(Self::Integer(int)) + } else if let Ok(s) = ob.extract() { + Ok(Self::String(s)) + } else if let Ok(tuple) = ob.extract() { + Ok(Self::Tuple(tuple)) + } else { + let type_name = ob.get_type().fully_qualified_name()?; + Err(PyTypeError::new_err(format!( + "can't extract a map key from a {type_name}" + ))) + } + } +} + +impl From for IString { + fn from(map_key: MapKey) -> Self { + Self::from(map_key.to_string()) + } +} + +impl TryFrom for MapKey { + type Error = MapKeyFromArbitraryError; + + fn try_from(arbitrary: Arbitrary) -> Result { + match arbitrary { + Arbitrary::Null => Ok(MapKey::Null), + Arbitrary::Bool(b) => Ok(MapKey::Bool(b)), + Arbitrary::Integer(i) => Ok(MapKey::Integer(i)), + Arbitrary::String(s) => Ok(MapKey::String(s)), + Arbitrary::Array(vec) => { + let tuple = Result::from_iter(vec.into_iter().map(TryInto::try_into))?; + Ok(MapKey::Tuple(tuple)) + } + Arbitrary::DateTime(date_time) => Ok(MapKey::DateTime(date_time)), + Arbitrary::Float(float) => { + Err(MapKeyFromArbitraryError::FloatNotSupported { value: float }) + } + Arbitrary::Map(map) => Err(MapKeyFromArbitraryError::MapCannotBeAMapKey { value: map }), + } + } +} diff --git a/src/arbitrary/mod.rs b/src/arbitrary/mod.rs new file mode 100644 index 0000000..0b69f7c --- /dev/null +++ b/src/arbitrary/mod.rs @@ -0,0 +1,4 @@ +pub mod arbitrary; +pub mod finite_f64; +pub mod map; +pub mod map_key; diff --git a/src/home_assistant/domain.rs b/src/home_assistant/domain.rs new file mode 100644 index 0000000..c8edf9b --- /dev/null +++ b/src/home_assistant/domain.rs @@ -0,0 +1,34 @@ +use strum::EnumString; + +#[derive(Debug, Clone, EnumString, strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum Domain { + Automation, + BinarySensor, + Button, + Camera, + Climate, + Conversation, + Cover, + DeviceTracker, + Group, + InputDatetime, + InputNumber, + InputSelect, + InputText, + Light, + Lock, + MediaPlayer, + Notify, + Person, + Remote, + Scene, + Select, + Sensor, + Sun, + Switch, + Tag, + Update, + Weather, + Zone, +} diff --git a/src/home_assistant/entity_id.rs b/src/home_assistant/entity_id.rs new file mode 100644 index 0000000..0a7bb81 --- /dev/null +++ b/src/home_assistant/entity_id.rs @@ -0,0 +1,60 @@ +use std::{fmt::Display, str::FromStr}; + +use pyo3::{exceptions::PyValueError, prelude::*}; +use snafu::{ResultExt, Snafu}; + +use super::{ + domain::Domain, + object_id::{ObjectId, ObjectIdParsingError}, +}; + +#[derive(Debug, Clone)] +pub struct EntityId(pub Domain, pub ObjectId); + +#[derive(Debug, Clone, Snafu)] +pub enum EntityIdParsingError { + #[snafu(display("entity IDs have a dot / period in them, e.g. light.kitchen_lamp"))] + MissingDot, + + #[snafu(display("could not parse the domain part of the entity ID"))] + ParsingDomain { source: ::Err }, + + #[snafu(display("could not parse the object ID part of the entity ID"))] + ParsingObjectId { source: ObjectIdParsingError }, +} + +impl FromStr for EntityId { + type Err = EntityIdParsingError; + + fn from_str(s: &str) -> Result { + let (domain, object_id) = s.split_once('.').ok_or(EntityIdParsingError::MissingDot)?; + + let domain = domain.parse().context(ParsingDomainSnafu)?; + let object_id = object_id.parse().context(ParsingObjectIdSnafu)?; + + Ok(Self(domain, object_id)) + } +} + +impl Display for EntityId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self(domain, object_id) = self; + + write!(f, "{domain}.{object_id}") + } +} + +impl From for PyErr { + fn from(error: EntityIdParsingError) -> Self { + 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)?; + + Ok(entity_id) + } +} diff --git a/src/home_assistant/event/context/context.rs b/src/home_assistant/event/context/context.rs new file mode 100644 index 0000000..b033918 --- /dev/null +++ b/src/home_assistant/event/context/context.rs @@ -0,0 +1,24 @@ +use pyo3::prelude::*; + +use crate::home_assistant::event::event::Event; + +use super::id::Id; + +/// The context that triggered something. +#[derive(Debug, FromPyObject)] +pub struct Context { + pub id: Id, + pub user_id: Option, + pub parent_id: Option, + /// In order to prevent cycles, the user must extract this to an [`Event`](super::event::Event) themself (or even specify a specific type parameter!) + origin_event: Py, +} + +impl Context { + pub fn origin_event<'py, Type: FromPyObject<'py>, Data: FromPyObject<'py>>( + &self, + py: Python<'py>, + ) -> PyResult> { + self.origin_event.extract(py) + } +} diff --git a/src/home_assistant/event/context/id.rs b/src/home_assistant/event/context/id.rs new file mode 100644 index 0000000..d3517cf --- /dev/null +++ b/src/home_assistant/event/context/id.rs @@ -0,0 +1,20 @@ +use pyo3::prelude::*; +use ulid::Ulid; + +#[derive(Debug, Clone)] +pub enum Id { + Ulid(Ulid), + Other(String), +} + +impl<'py> FromPyObject<'py> for Id { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + let s = ob.extract::()?; + + if let Ok(ulid) = s.parse() { + Ok(Id::Ulid(ulid)) + } else { + Ok(Id::Other(s)) + } + } +} diff --git a/src/home_assistant/event/context/mod.rs b/src/home_assistant/event/context/mod.rs new file mode 100644 index 0000000..23cc631 --- /dev/null +++ b/src/home_assistant/event/context/mod.rs @@ -0,0 +1,2 @@ +pub mod context; +pub mod id; diff --git a/src/home_assistant/event/event.rs b/src/home_assistant/event/event.rs new file mode 100644 index 0000000..c65a9e1 --- /dev/null +++ b/src/home_assistant/event/event.rs @@ -0,0 +1,30 @@ +use chrono::{DateTime, Utc}; +use pyo3::prelude::*; + +use super::{context::context::Context, event_origin::EventOrigin}; + +/// Representation of an event within the bus. +#[derive(Debug, FromPyObject)] +pub struct Event { + pub event_type: Type, + pub data: Data, + pub origin: EventOrigin, + /// In order to prevent cycles, the user must extract this to a [`Context`](super::context::Context) themself, using the [`context`](Self::context) method + context: Py, + time_fired_timestamp: f64, +} + +impl Event { + pub fn context<'py>(&self, py: Python<'py>) -> PyResult { + self.context.extract(py) + } + + pub fn time_fired(&self) -> Option> { + const NANOS_PER_SEC: i32 = 1_000_000_000; + + let secs = self.time_fired_timestamp as i64; + let nsecs = (self.time_fired_timestamp.fract() * (NANOS_PER_SEC as f64)) as u32; + + DateTime::from_timestamp(secs, nsecs) + } +} diff --git a/src/home_assistant/event/event_origin.rs b/src/home_assistant/event/event_origin.rs new file mode 100644 index 0000000..2c1fddf --- /dev/null +++ b/src/home_assistant/event/event_origin.rs @@ -0,0 +1,21 @@ +use std::str::FromStr; + +use pyo3::{exceptions::PyValueError, prelude::*}; + +#[derive(Debug, Clone, strum::EnumString, strum::Display)] +#[strum(serialize_all = "UPPERCASE")] +pub enum EventOrigin { + Local, + 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()))?; + + Ok(event_origin) + } +} diff --git a/src/home_assistant/event/mod.rs b/src/home_assistant/event/mod.rs new file mode 100644 index 0000000..da586ea --- /dev/null +++ b/src/home_assistant/event/mod.rs @@ -0,0 +1,4 @@ +pub mod context; +pub mod event; +pub mod event_origin; +pub mod specific; diff --git a/src/home_assistant/event/specific/mod.rs b/src/home_assistant/event/specific/mod.rs new file mode 100644 index 0000000..437ee54 --- /dev/null +++ b/src/home_assistant/event/specific/mod.rs @@ -0,0 +1 @@ +pub mod state_changed; diff --git a/src/home_assistant/event/specific/state_changed.rs b/src/home_assistant/event/specific/state_changed.rs new file mode 100644 index 0000000..20e152f --- /dev/null +++ b/src/home_assistant/event/specific/state_changed.rs @@ -0,0 +1,34 @@ +use std::str::FromStr; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::home_assistant::{entity_id::EntityId, state::State}; + +#[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>()?; + + if s == "state_changed" { + Ok(Type) + } else { + Err(PyValueError::new_err(format!( + "expected a string of value 'state_changed', but got {s}" + ))) + } + } +} + +#[derive(Debug, FromPyObject)] +#[pyo3(from_item_all)] +pub struct Data { + pub entity_id: EntityId, + pub old_state: Option, + pub new_state: Option, +} + +/// A state changed event is fired when on state write the state is changed. +pub type Event = super::super::event::Event; diff --git a/src/home_assistant/home_assistant.rs b/src/home_assistant/home_assistant.rs new file mode 100644 index 0000000..90f98ed --- /dev/null +++ b/src/home_assistant/home_assistant.rs @@ -0,0 +1,43 @@ +use pyo3::prelude::*; + +use crate::python_utils::{detach, validate_type_by_name}; + +use super::state_machine::StateMachine; + +#[derive(Debug)] +pub struct HomeAssistant(Py); + +impl<'source> FromPyObject<'source> for HomeAssistant { + fn extract_bound(ob: &Bound<'source, PyAny>) -> PyResult { + // region: Validation + validate_type_by_name(ob, "HomeAssistant")?; + // endregion: Validation + + Ok(Self(detach(ob))) + } +} + +impl HomeAssistant { + /// Return the representation + pub fn repr(&self, py: &Python) -> Result { + let bound = self.0.bind(*py); + let repr = bound.repr()?; + repr.extract() + } + + /// Return if Home Assistant is running. + pub fn is_running(&self, py: &Python) -> Result { + let is_running = self.0.getattr(*py, "is_running")?; + is_running.extract(*py) + } + /// Return if Home Assistant is stopping. + pub fn is_stopping(&self, py: &Python) -> Result { + let is_stopping = self.0.getattr(*py, "is_stopping")?; + is_stopping.extract(*py) + } + + pub fn states(&self, py: &Python) -> Result { + let states = self.0.getattr(*py, "states")?; + states.extract(*py) + } +} diff --git a/src/home_assistant/mod.rs b/src/home_assistant/mod.rs new file mode 100644 index 0000000..4a1e3cf --- /dev/null +++ b/src/home_assistant/mod.rs @@ -0,0 +1,7 @@ +pub mod domain; +pub mod entity_id; +pub mod event; +pub mod home_assistant; +pub mod object_id; +pub mod state; +pub mod state_machine; diff --git a/src/home_assistant/object_id.rs b/src/home_assistant/object_id.rs new file mode 100644 index 0000000..501cf38 --- /dev/null +++ b/src/home_assistant/object_id.rs @@ -0,0 +1,35 @@ +use std::{str::FromStr, sync::Arc}; + +use pyo3::{exceptions::PyValueError, PyErr}; +use snafu::Snafu; + +#[derive(Debug, Clone, derive_more::Display)] +pub struct ObjectId(Arc); + +#[derive(Debug, Clone, Snafu)] +#[snafu(display("expected a lowercase ASCII alphabetical character (i.e. a through z) or an underscore (i.e. _) but encountered {encountered}"))] +pub struct ObjectIdParsingError { + encountered: char, +} + +impl FromStr for ObjectId { + type Err = ObjectIdParsingError; + + fn from_str(s: &str) -> Result { + for c in s.chars() { + match c { + 'a'..='z' => {} + '_' => {} + _ => return Err(ObjectIdParsingError { encountered: c }), + } + } + + Ok(Self(s.into())) + } +} + +impl From for PyErr { + fn from(error: ObjectIdParsingError) -> Self { + PyValueError::new_err(error.to_string()) + } +} diff --git a/src/home_assistant/state.rs b/src/home_assistant/state.rs new file mode 100644 index 0000000..59a3e9b --- /dev/null +++ b/src/home_assistant/state.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use pyo3::prelude::*; + +use crate::{arbitrary::map::Map, home_assistant::entity_id::EntityId}; + +use super::event::context::context::Context; + +#[derive(Debug, FromPyObject)] +pub struct State { + pub entity_id: EntityId, + pub state: String, + pub attributes: Map, + pub last_changed: Option>, + pub last_reported: Option>, + pub last_updated: Option>, + pub context: Context, +} diff --git a/src/home_assistant/state_machine.rs b/src/home_assistant/state_machine.rs new file mode 100644 index 0000000..791af1e --- /dev/null +++ b/src/home_assistant/state_machine.rs @@ -0,0 +1,29 @@ +use pyo3::prelude::*; + +use crate::{ + home_assistant::entity_id::EntityId, + python_utils::{detach, validate_type_by_name}, +}; + +use super::state::State; + +#[derive(Debug)] +pub struct StateMachine(Py); + +impl<'py> FromPyObject<'py> for StateMachine { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + // region: Validation + validate_type_by_name(ob, "StateMachine")?; + // endregion: Validation + + Ok(Self(detach(ob))) + } +} + +impl StateMachine { + pub fn get(&self, py: &Python, entity_id: EntityId) -> Result, PyErr> { + let args = (entity_id.to_string(),); + let state = self.0.call_method1(*py, "get", args)?; + state.extract(*py) + } +} diff --git a/src/lib.rs b/src/lib.rs index fa295a2..37097d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,27 +1,42 @@ use std::time::Duration; +use home_assistant::home_assistant::HomeAssistant; use pyo3::prelude::*; use tokio::time::interval; +use tracing::Level; +use tracing_subscriber::fmt::format::FmtSpan; -async fn real_main() -> ! { - let duration = Duration::from_millis(400); +mod arbitrary; +mod home_assistant; +mod python_utils; + +async fn real_main(home_assistant: HomeAssistant) -> ! { + tracing_subscriber::fmt() + .with_max_level(Level::TRACE) + .with_span_events(FmtSpan::ACTIVE) + .pretty() + .init(); + + let duration = Duration::from_millis(5900); let mut interval = interval(duration); loop { let instant = interval.tick().await; - println!("it is now {instant:?}"); + tracing::debug!(?instant, "it is now"); } } #[pyfunction] -fn main(py: Python) -> PyResult<&PyAny> { - pyo3_asyncio::tokio::future_into_py(py, async { Ok(real_main().await) }) +fn main<'p>(py: Python<'p>, home_assistant: HomeAssistant) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py::<_, ()>(py, async { + real_main(home_assistant).await; + }) } /// A Python module implemented in Rust. #[pymodule] -fn smart_home_in_rust_with_home_assistant(_py: Python, m: &PyModule) -> PyResult<()> { +fn smart_home_in_rust_with_home_assistant(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(main, m)?)?; Ok(()) } diff --git a/src/python_utils.rs b/src/python_utils.rs new file mode 100644 index 0000000..49b0e1d --- /dev/null +++ b/src/python_utils.rs @@ -0,0 +1,21 @@ +use pyo3::{exceptions::PyTypeError, prelude::*}; + +/// Create a GIL-independent reference (similar to [`Arc`](std::sync::Arc)) +pub fn detach(bound: &Bound) -> Py { + let py = bound.py(); + bound.as_unbound().clone_ref(py) +} + +pub fn validate_type_by_name(bound: &Bound, expected_type_name: &str) -> PyResult<()> { + let py_type = bound.get_type(); + let type_name = py_type.name()?; + let type_name = type_name.to_str()?; + + if type_name != expected_type_name { + let fully_qualified_type_name = py_type.fully_qualified_name()?; + let fully_qualified_type_name = fully_qualified_type_name.to_str()?; + return Err(PyTypeError::new_err(format!("expected an instance of {expected_type_name} but got an instance of {fully_qualified_type_name}"))); + } + + return Ok(()); +}