feat: early interface with Python

This commit is contained in:
2025-03-13 15:38:51 -04:00
parent afe5eae96b
commit 96495b2a85
22 changed files with 623 additions and 6 deletions

View File

@@ -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<Arbitrary>),
Map(Map),
DateTime(DateTime<Tz>),
}
impl From<MapKey> 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<Arbitrary> 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::<IValue>::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<Self> {
if let Ok(map_key) = ob.extract::<MapKey>() {
Ok(map_key.into())
} else if let Ok(map) = ob.extract() {
Ok(Self::Map(map))
} else if let Ok(f) = ob.extract::<f64>() {
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}"
)))
}
}
}

View File

@@ -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<f64> for FiniteF64 {
type Error = NotFinite;
fn try_from(value: f64) -> Result<Self, Self::Error> {
if value.is_finite() {
Ok(Self(value))
} else {
Err(NotFinite { value })
}
}
}

16
src/arbitrary/map.rs Normal file
View File

@@ -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<MapKey, Arbitrary>);
impl<'py> FromPyObject<'py> for Map {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
let inner: BTreeMap<MapKey, Arbitrary> = ob.extract()?;
Ok(Self(inner))
}
}

87
src/arbitrary/map_key.rs Normal file
View File

@@ -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<MapKey>),
DateTime(DateTime<Tz>),
}
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<Self> {
if let Ok(_none) = ob.downcast::<PyNone>() {
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<MapKey> for IString {
fn from(map_key: MapKey) -> Self {
Self::from(map_key.to_string())
}
}
impl TryFrom<Arbitrary> for MapKey {
type Error = MapKeyFromArbitraryError;
fn try_from(arbitrary: Arbitrary) -> Result<Self, Self::Error> {
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 }),
}
}
}

4
src/arbitrary/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod arbitrary;
pub mod finite_f64;
pub mod map;
pub mod map_key;

View File

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

View File

@@ -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: <Domain as FromStr>::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<Self, Self::Err> {
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<EntityIdParsingError> 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<Self> {
let s = ob.extract()?;
let entity_id = EntityId::from_str(s)?;
Ok(entity_id)
}
}

View File

@@ -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<String>,
pub parent_id: Option<String>,
/// In order to prevent cycles, the user must extract this to an [`Event<Arbitrary>`](super::event::Event) themself (or even specify a specific type parameter!)
origin_event: Py<PyAny>,
}
impl Context {
pub fn origin_event<'py, Type: FromPyObject<'py>, Data: FromPyObject<'py>>(
&self,
py: Python<'py>,
) -> PyResult<Event<Type, Data>> {
self.origin_event.extract(py)
}
}

View File

@@ -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<Self> {
let s = ob.extract::<String>()?;
if let Ok(ulid) = s.parse() {
Ok(Id::Ulid(ulid))
} else {
Ok(Id::Other(s))
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod context;
pub mod id;

View File

@@ -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<Type, Data> {
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<PyAny>,
time_fired_timestamp: f64,
}
impl<Type, Data> Event<Type, Data> {
pub fn context<'py>(&self, py: Python<'py>) -> PyResult<Context> {
self.context.extract(py)
}
pub fn time_fired(&self) -> Option<DateTime<Utc>> {
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)
}
}

View File

@@ -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<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()))?;
Ok(event_origin)
}
}

View File

@@ -0,0 +1,4 @@
pub mod context;
pub mod event;
pub mod event_origin;
pub mod specific;

View File

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

View File

@@ -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<Self> {
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<State>,
pub new_state: Option<State>,
}
/// A state changed event is fired when on state write the state is changed.
pub type Event = super::super::event::Event<Type, Data>;

View File

@@ -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<PyAny>);
impl<'source> FromPyObject<'source> for HomeAssistant {
fn extract_bound(ob: &Bound<'source, PyAny>) -> PyResult<Self> {
// 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<String, PyErr> {
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<bool, PyErr> {
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<bool, PyErr> {
let is_stopping = self.0.getattr(*py, "is_stopping")?;
is_stopping.extract(*py)
}
pub fn states(&self, py: &Python) -> Result<StateMachine, PyErr> {
let states = self.0.getattr(*py, "states")?;
states.extract(*py)
}
}

View File

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

View File

@@ -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<str>);
#[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<Self, Self::Err> {
for c in s.chars() {
match c {
'a'..='z' => {}
'_' => {}
_ => return Err(ObjectIdParsingError { encountered: c }),
}
}
Ok(Self(s.into()))
}
}
impl From<ObjectIdParsingError> for PyErr {
fn from(error: ObjectIdParsingError) -> Self {
PyValueError::new_err(error.to_string())
}
}

View File

@@ -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<DateTime<Utc>>,
pub last_reported: Option<DateTime<Utc>>,
pub last_updated: Option<DateTime<Utc>>,
pub context: Context,
}

View File

@@ -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<PyAny>);
impl<'py> FromPyObject<'py> for StateMachine {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
// 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<Option<State>, PyErr> {
let args = (entity_id.to_string(),);
let state = self.0.call_method1(*py, "get", args)?;
state.extract(*py)
}
}

View File

@@ -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<Bound<'p, PyAny>> {
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(())
}

21
src/python_utils.rs Normal file
View File

@@ -0,0 +1,21 @@
use pyo3::{exceptions::PyTypeError, prelude::*};
/// Create a GIL-independent reference (similar to [`Arc`](std::sync::Arc))
pub fn detach<T>(bound: &Bound<T>) -> Py<T> {
let py = bound.py();
bound.as_unbound().clone_ref(py)
}
pub fn validate_type_by_name(bound: &Bound<PyAny>, 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(());
}