Skip to content

Instantly share code, notes, and snippets.

@fabaff
Last active January 7, 2023 02:34
Show Gist options
  • Save fabaff/c5e7b3c98f4e914a3020b6e6180f0c74 to your computer and use it in GitHub Desktop.
Save fabaff/c5e7b3c98f4e914a3020b6e6180f0c74 to your computer and use it in GitHub Desktop.
Update SpaceAPi component with support for SpaceAPI v14
"""Support for the SpaceAPI."""
from contextlib import suppress
import voluptuous as vol
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ICON,
ATTR_LOCATION,
ATTR_NAME,
ATTR_STATE,
ATTR_UNIT_OF_MEASUREMENT,
CONF_ADDRESS,
CONF_EMAIL,
CONF_ENTITY_ID,
CONF_LOCATION,
CONF_SENSORS,
CONF_STATE,
CONF_URL,
)
import homeassistant.core as ha
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
ATTR_API_COMPATIBILITY = "api_compatibility"
ATTR_ADDRESS = "address"
ATTR_SPACEFED = "spacefed"
ATTR_CAM = "cam"
ATTR_FEEDS = "feeds"
ATTR_PROJECTS = "projects"
ATTR_LAT = "lat"
ATTR_LON = "lon"
ATTR_TIMEZONE = "timezone"
ATTR_CLOSE = "close"
ATTR_CONTACT = "contact"
ATTR_LASTCHANGE = "lastchange"
ATTR_LOGO = "logo"
ATTR_OPEN = "open"
ATTR_SENSORS = "sensors"
ATTR_SPACE = "space"
ATTR_UNIT = "unit"
ATTR_URL = "url"
ATTR_VALUE = "value"
ATTR_SENSOR_LOCATION = "location"
CONF_CONTACT = "contact"
CONF_HUMIDITY = "humidity"
CONF_ICON_CLOSED = "icon_closed"
CONF_ICON_OPEN = "icon_open"
CONF_ICONS = "icons"
CONF_IRC = "irc"
CONF_SPACEFED = "spacefed"
CONF_SPACENET = "spacenet"
CONF_SPACESAML = "spacesaml"
CONF_SPACEPHONE = "spacephone"
CONF_CAM = "cam"
CONF_FEEDS = "feeds"
CONF_FEED_BLOG = "blog"
CONF_FEED_WIKI = "wiki"
CONF_FEED_CALENDAR = "calendar"
CONF_FEED_FLICKER = "flicker"
CONF_FEED_TYPE = "type"
CONF_FEED_URL = "url"
CONF_CACHE = "cache"
CONF_CACHE_SCHEDULE = "schedule"
CONF_PROJECTS = "projects"
CONF_LOGO = "logo"
CONF_PHONE = "phone"
CONF_SIP = "sip"
CONF_KEYMASTERS = "keymasters"
CONF_KEYMASTER_NAME = "name"
CONF_KEYMASTER_IRC_NICK = "irc_nick"
CONF_KEYMASTER_PHONE = "phone"
CONF_KEYMASTER_EMAIL = "email"
CONF_KEYMASTER_TWITTER = "twitter"
CONF_KEYMASTER_XMPP = "xmpp"
CONF_KEYMASTER_MASTODON = "mastodon"
CONF_KEYMASTER_MATRIX = "matrix"
CONF_TWITTER = "twitter"
CONF_FACEBOOK = "facebook"
CONF_IDENTICA = "identica"
CONF_FOURSQUARE = "foursquare"
CONF_ML = "ml"
CONF_MASTODON = "mastodon"
CONF_GOPHER = "gopher"
CONF_MATRIX = "matrix"
CONF_MUMBLE = "mumble"
CONF_XMPP = "xmpp"
CONF_ISSUE_MAIL = "issue_mail"
CONF_SPACE = "space"
CONF_TEMPERATURE = "temperature"
CONF_TIMEZONE = "timezone"
DATA_SPACEAPI = "data_spaceapi"
DOMAIN = "spaceapi"
SENSOR_TYPES = [CONF_HUMIDITY, CONF_TEMPERATURE]
SPACEAPI_VERSION = "14"
URL_API_SPACEAPI = "/api/spaceapi"
LOCATION_SCHEMA = vol.Schema(
{vol.Optional(CONF_ADDRESS): cv.string, vol.Optional(CONF_TIMEZONE): cv.string}
)
SPACEFED_SCHEMA = vol.Schema(
{
vol.Optional(CONF_SPACENET): cv.boolean,
vol.Optional(CONF_SPACESAML): cv.boolean,
vol.Optional(CONF_SPACEPHONE): cv.boolean,
}
)
FEED_SCHEMA = vol.Schema(
{vol.Optional(CONF_FEED_TYPE): cv.string, vol.Required(CONF_FEED_URL): cv.url}
)
FEEDS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_FEED_BLOG): FEED_SCHEMA,
vol.Optional(CONF_FEED_WIKI): FEED_SCHEMA,
vol.Optional(CONF_FEED_CALENDAR): FEED_SCHEMA,
vol.Optional(CONF_FEED_FLICKER): FEED_SCHEMA,
}
)
KEYMASTER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_KEYMASTER_NAME): cv.string,
vol.Optional(CONF_KEYMASTER_IRC_NICK): cv.string,
vol.Optional(CONF_KEYMASTER_PHONE): cv.string,
vol.Optional(CONF_KEYMASTER_EMAIL): cv.string,
vol.Optional(CONF_KEYMASTER_TWITTER): cv.string,
vol.Optional(CONF_KEYMASTER_XMPP): cv.string,
vol.Optional(CONF_KEYMASTER_MASTODON): cv.string,
vol.Optional(CONF_KEYMASTER_MATRIX): cv.string,
}
)
CONTACT_SCHEMA = vol.Schema(
{
vol.Optional(CONF_EMAIL): cv.string,
vol.Optional(CONF_FACEBOOK): cv.string,
vol.Optional(CONF_FOURSQUARE): cv.string,
vol.Optional(CONF_GOPHER): cv.string,
vol.Optional(CONF_IDENTICA): cv.string,
vol.Optional(CONF_IRC): cv.string,
vol.Optional(CONF_ISSUE_MAIL): cv.string,
vol.Optional(CONF_MASTODON): cv.string,
vol.Optional(CONF_MATRIX): cv.string,
vol.Optional(CONF_ML): cv.string,
vol.Optional(CONF_MUMBLE): cv.string,
vol.Optional(CONF_PHONE): cv.string,
vol.Optional(CONF_SIP): cv.string,
vol.Optional(CONF_TWITTER): cv.string,
vol.Optional(CONF_XMPP): cv.string,
vol.Optional(CONF_KEYMASTERS): vol.All(
cv.ensure_list, [KEYMASTER_SCHEMA], vol.Length(min=1)
),
},
required=False,
)
STATE_SCHEMA = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Inclusive(CONF_ICON_CLOSED, CONF_ICONS): cv.url,
vol.Inclusive(CONF_ICON_OPEN, CONF_ICONS): cv.url,
},
required=False,
)
SENSOR_SCHEMA = vol.Schema(
{vol.In(SENSOR_TYPES): [cv.entity_id], cv.string: [cv.entity_id]}
)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_CONTACT): CONTACT_SCHEMA,
vol.Optional(CONF_LOCATION): LOCATION_SCHEMA,
vol.Required(CONF_LOGO): cv.url,
vol.Required(CONF_SPACE): cv.string,
vol.Required(CONF_STATE): STATE_SCHEMA,
vol.Required(CONF_URL): cv.string,
vol.Optional(CONF_SENSORS): SENSOR_SCHEMA,
vol.Optional(CONF_SPACEFED): SPACEFED_SCHEMA,
vol.Optional(CONF_CAM): vol.All(
cv.ensure_list, [cv.url], vol.Length(min=1)
),
vol.Optional(CONF_FEEDS): FEEDS_SCHEMA,
vol.Optional(CONF_PROJECTS): vol.All(cv.ensure_list, [cv.url]),
}
)
},
extra=vol.ALLOW_EXTRA,
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the SpaceAPI with the HTTP interface."""
hass.data[DATA_SPACEAPI] = config[DOMAIN]
hass.http.register_view(APISpaceApiView)
return True
class APISpaceApiView(HomeAssistantView):
"""View to provide details according to the SpaceAPI."""
url = URL_API_SPACEAPI
name = "api:spaceapi"
@staticmethod
def get_sensor_data(hass, spaceapi, sensor):
"""Get data from a sensor."""
if not (sensor_state := hass.states.get(sensor)):
return None
sensor_data = {ATTR_NAME: sensor_state.name, ATTR_VALUE: sensor_state.state}
if ATTR_SENSOR_LOCATION in sensor_state.attributes:
sensor_data[ATTR_LOCATION] = sensor_state.attributes[ATTR_SENSOR_LOCATION]
else:
sensor_data[ATTR_LOCATION] = spaceapi[CONF_SPACE]
# Some sensors don't have a unit of measurement
if ATTR_UNIT_OF_MEASUREMENT in sensor_state.attributes:
sensor_data[ATTR_UNIT] = sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT]
return sensor_data
@ha.callback
def get(self, request):
"""Get SpaceAPI data."""
hass = request.app["hass"]
spaceapi = dict(hass.data[DATA_SPACEAPI])
is_sensors = spaceapi.get("sensors")
location = {ATTR_LAT: hass.config.latitude, ATTR_LON: hass.config.longitude}
try:
location[ATTR_ADDRESS] = spaceapi[ATTR_LOCATION][CONF_ADDRESS]
location[ATTR_TIMEZONE] = spaceapi[ATTR_LOCATION][CONF_TIMEZONE]
except KeyError:
pass
except TypeError:
pass
state_entity = spaceapi["state"][ATTR_ENTITY_ID]
if (space_state := hass.states.get(state_entity)) is not None:
state = {
ATTR_OPEN: space_state.state != "off",
ATTR_LASTCHANGE: dt_util.as_timestamp(space_state.last_updated),
}
else:
state = {ATTR_OPEN: "null", ATTR_LASTCHANGE: 0}
with suppress(KeyError):
state[ATTR_ICON] = {
ATTR_OPEN: spaceapi["state"][CONF_ICON_OPEN],
ATTR_CLOSE: spaceapi["state"][CONF_ICON_CLOSED],
}
data = {
ATTR_API_COMPATIBILITY: [SPACEAPI_VERSION],
ATTR_CONTACT: spaceapi[CONF_CONTACT],
ATTR_LOCATION: location,
ATTR_LOGO: spaceapi[CONF_LOGO],
ATTR_SPACE: spaceapi[CONF_SPACE],
ATTR_STATE: state,
ATTR_URL: spaceapi[CONF_URL],
}
with suppress(KeyError):
data[ATTR_CAM] = spaceapi[CONF_CAM]
with suppress(KeyError):
data[ATTR_SPACEFED] = spaceapi[CONF_SPACEFED]
with suppress(KeyError):
data[ATTR_FEEDS] = spaceapi[CONF_FEEDS]
with suppress(KeyError):
data[ATTR_PROJECTS] = spaceapi[CONF_PROJECTS]
if is_sensors is not None:
sensors = {}
for sensor_type in is_sensors:
sensors[sensor_type] = []
for sensor in spaceapi["sensors"][sensor_type]:
sensor_data = self.get_sensor_data(hass, spaceapi, sensor)
sensors[sensor_type].append(sensor_data)
data[ATTR_SENSORS] = sensors
return self.json(data)
@fabaff
Copy link
Author

fabaff commented Dec 21, 2022

Sample config:

spaceapi:
  space: Hackerspace
  logo: https://hackerspace.hackerspace/logo.svg
  url: https://hackerspace.hackerspace
  location:
    address: Somewhere on Earth
    timezone: "Europe/Zurich"
  contact:
    email: [email protected]
    mastodon: "@hackerspace:hackerspace.social"
  state:
    entity_id: binary_sensor.workday

The output would looks like this:

$ http -A bearer -a "eyJh...yijY" http://localhost:8123/api/spaceapi 'Content-Type:application/json'
HTTP/1.1 200 OK
[...]
{
    "api_compatibility": [
        "14"
    ],
    "contact": {
        "email": "[email protected]",
        "mastodon": "@hackerspace:hackerspace.social"
    },
    "location": {
        "address": "Somewhere on Earth",
        "lat": 46.9698,
        "lon": 7.4584,
        "timezone": "Europe/Zurich"
    },
    "logo": "https://hackerspace.hackerspace/logo.svg",
    "space": "Hackerspace",
    "state": {
        "lastchange": 0,
        "open": "null"
    },
    "url": "https://hackerspace.hackerspace"
}

@mweinelt
Copy link

mweinelt commented Jan 7, 2023

Patch to fix two issues:

  • Sensor values should be numbers, not strings, so cast to float
  • Lookup area name before falling back to space name
diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py
index faf655c56b..30f482853f 100644
--- a/homeassistant/components/spaceapi/__init__.py
+++ b/homeassistant/components/spaceapi/__init__.py
@@ -23,6 +23,7 @@ import homeassistant.core as ha
 from homeassistant.core import HomeAssistant
 import homeassistant.helpers.config_validation as cv
 from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.template import area_name as get_area_name
 import homeassistant.util.dt as dt_util
 
 ATTR_API_COMPATIBILITY = "api_compatibility"
@@ -220,9 +221,11 @@ class APISpaceApiView(HomeAssistantView):
         """Get data from a sensor."""
         if not (sensor_state := hass.states.get(sensor)):
             return None
-        sensor_data = {ATTR_NAME: sensor_state.name, ATTR_VALUE: sensor_state.state}
+        sensor_data = {ATTR_NAME: sensor_state.name, ATTR_VALUE: float(sensor_state.state)}
         if ATTR_SENSOR_LOCATION in sensor_state.attributes:
             sensor_data[ATTR_LOCATION] = sensor_state.attributes[ATTR_SENSOR_LOCATION]
+        elif area_name := get_area_name(hass, sensor_state.entity_id):
+            sensor_data[ATTR_LOCATION] = area_name
         else:
             sensor_data[ATTR_LOCATION] = spaceapi[CONF_SPACE]
         # Some sensors don't have a unit of measurement

@mweinelt
Copy link

mweinelt commented Jan 7, 2023

This is the configuration we're using for our room state. With the booelan expression we get "True" and "False" as state values, but the integration so far only looks for "off". I think it would be reasonable to accept truthy values here.

  services.home-assistant.config.mqtt.sensor = [
   {
     name = "door_open";
     state_topic = "door/lock/state";
     availability = [ {
       topic = "door/status";
     } ];
     value_template = "{{ value == \"0\" }}";
   }
 ];

So i went with this patch as well

diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py
index faf655c56b..46bc58c6fb 100644
@@ -251,7 +254,7 @@ class APISpaceApiView(HomeAssistantView):
 
         if (space_state := hass.states.get(state_entity)) is not None:
             state = {
-                ATTR_OPEN: space_state.state != "off",
+                ATTR_OPEN: space_state.state not in ["off", "False"],
                 ATTR_LASTCHANGE: dt_util.as_timestamp(space_state.last_updated),
             }
         else:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment