Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preview image via cloud upload and cleanup after #51

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions custom_components/meural/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"async_preview_image",
)

platform.async_register_entity_service(
"preview_image_cloud",
{
vol.Required("content_url"): str,
vol.Required("content_type"): str,
vol.Optional("name"): str,
vol.Optional("author"): str,
vol.Optional("description"): str,
vol.Optional("medium"): str,
vol.Optional("year"): str,
vol.Optional("duration"): int,
},
"async_preview_image_cloud",
)


platform.async_register_entity_service(
"reset_brightness",
{},
Expand Down Expand Up @@ -494,6 +510,15 @@ async def async_set_shuffle(self, shuffle):

async def async_play_media(self, media_type, media_id, **kwargs):
"""Play media from media_source."""
use_cloud = kwargs.get("use_cloud", False)
if use_cloud:
name = kwargs.get("name")
author = kwargs.get("author")
description = kwargs.get("description")
medium = kwargs.get("medium")
year = kwargs.get("year")
duration = kwargs.get("duration")

if media_source.is_media_source_id(media_id):
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
media_type = sourced_media.mime_type
Expand All @@ -516,19 +541,24 @@ async def async_play_media(self, media_type, media_id, **kwargs):
# Prepend external URL.
hass_url = get_url(self.hass, allow_internal=True)
media_id = f"{hass_url}{media_id}"

_LOGGER.info("Meural device %s: Playing media. Media type is %s, previewing image from %s", self.name, media_type, media_id)
await self.local_meural.send_postcard(media_id, media_type)
if use_cloud:
await self.meural.send_postcard_cloud(self._meural_device, media_id, media_type, name, author, description, medium, year, duration)
else:
await self.local_meural.send_postcard(media_id, media_type)

# Play gallery (playlist or album) by ID.
elif media_type in ['playlist']:
_LOGGER.info("Meural device %s: Playing media. Media type is %s, playing gallery %s", self.name, media_type, media_id)
await self.local_meural.send_change_gallery(media_id)

# "Preview image from URL.
elif media_type in [ 'image/jpg', 'image/png', 'image/jpeg' ]:
if media_type in [ 'image/jpg', 'image/png', 'image/jpeg', 'image/gif' ]:
_LOGGER.info("Meural device %s: Playing media. Media type is %s, previewing image from %s", self.name, media_type, media_id)
await self.local_meural.send_postcard(media_id, media_type)
if use_cloud:
await self.meural.send_postcard_cloud(self._meural_device, media_id, media_type, name, author, description, medium, year, duration)
else:
await self.local_meural.send_postcard(media_id, media_type)

# Play item (artwork) by ID. Play locally if item is in currently displayed gallery. If not, play using Meural server."""
elif media_type in ['item']:
Expand Down Expand Up @@ -556,7 +586,15 @@ async def async_preview_image(self, content_url, content_type):
"""Preview image from URL."""
if content_type in [ 'image/jpg', 'image/png', 'image/jpeg' ]:
_LOGGER.info("Meural device %s: Previewing image. Media type is %s, previewing image from %s", self.name, content_type, content_url)
await self.local_meural.send_postcard(content_url, content_type)
await self.async_play_media(media_type=content_type, media_id=content_url)
else:
_LOGGER.error("Meural device %s: Previewing image. Does not support media type %s", self.name, content_type)

async def async_preview_image_cloud(self, content_url, content_type, name=None, author=None, description=None, medium=None, year=None, duration=None):
"""Preview image from URL."""
if content_type in [ 'image/jpg', 'image/png', 'image/jpeg', 'image/gif' ]:
_LOGGER.info("Meural device %s: Previewing image via meural cloud. Media type is %s, previewing image from %s", self.name, content_type, content_url)
await self.async_play_media(media_type=content_type, media_id=content_url, use_cloud=True, name=name, author=author, description=description, medium=medium, year=year, duration=duration)
else:
_LOGGER.error("Meural device %s: Previewing image. Does not support media type %s", self.name, content_type)

Expand Down
110 changes: 98 additions & 12 deletions custom_components/meural/pymeural.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import asyncio
import logging
import json
from datetime import date

from typing import Dict
import aiohttp
import async_timeout

from .util import snake_case

from aiohttp.client_exceptions import ClientResponseError

from homeassistant.exceptions import HomeAssistantError

_LOGGER = logging.getLogger(__name__)

BASE_URL = "https://api.meural.com/v0/"
BASE_URL = "https://api.meural.com/v1/"


async def authenticate(
Expand Down Expand Up @@ -50,26 +53,30 @@ def __init__(self, username, password, token, token_update_callback, session: ai
self.token = token
self.token_update_callback = token_update_callback

async def request(self, method, path, data=None) -> Dict:
async def request(self, method, path, data=None, data_key=None) -> Dict:
fetched_new_token = self.token is None
if self.token == None:
await self.get_new_token()
url = f"{BASE_URL}{path}"
kwargs = {}
if data:
if method == "get":
if data_key == "data":
kwargs["data"] = data
elif method == "get":
kwargs["query"] = data
else:
kwargs["json"] = data
headers = {
"Authorization": f"Token {self.token}",
"x-meural-api-version": "3",
}

with async_timeout.timeout(10):
try:
resp = await self.session.request(
method,
url,
headers={
"Authorization": f"Token {self.token}",
"x-meural-api-version": "3",
},
headers=headers,
raise_for_status=True,
**kwargs,
)
Expand All @@ -79,14 +86,16 @@ async def request(self, method, path, data=None) -> Dict:
# If a new token was just fetched and it fails again, just raise
if fetched_new_token:
raise
_LOGGER.info('Meural: Sending Request failed. Re-Authenticating')
_LOGGER.info(
'Meural: Sending Request failed. Re-Authenticating')
self.token = None
return await self.request(method, path, data)
except Exception as err:
_LOGGER.error('Meural: Sending Request failed. Raising: %s' %err)
_LOGGER.error(
'Meural: Sending Request failed. Raising: %s' % err)
raise
response = await resp.json()
return response["data"]
return response["data"] if response != None else None

async def get_new_token(self):
self.token = await authenticate(self.session, self.username, self.password)
Expand Down Expand Up @@ -128,6 +137,81 @@ async def sync_device(self, device_id):
async def get_item(self, item_id):
return await self.request("get", f"items/{item_id}")

async def update_content(self, id, name=None, author=None, description=None, medium=None, year=None):
_LOGGER.info(f"Meural: Updating postcard. Id is {id}")

name = "Homeassistant Preview Image" if name == None else name
author = "Homeassistant" if author == None else author
description = "Preview Image from Home Assistant Meural component" if description == None else description
medium = "Photo" if medium == None else medium
# the year has to be a date. other format doesn't work
year = date.today() if year == None else str(year)

data = aiohttp.FormData()
data.add_field("name", name)
data.add_field("author", author)
data.add_field("description", description)
data.add_field("medium", medium)
data.add_field("year", year)

response = await self.request("put", f"items/{id}",
data=data, data_key="data")

_LOGGER.info(f'Meural: Updating postcard. {id} updated')
return response

async def upload_content(self, url, content_type, name):
# photo uploads are done doing a multipart/form-data form
# with key 'image' or 'video' and value being the image/video data

_LOGGER.info('Meural: Sending postcard. URL is %s' % (url))
name = "homeassistant-preview-image.jpg" if name == None else snake_case(
name) + '.jpg'
with async_timeout.timeout(10):
response = await self.session.get(url)
content = await response.read()
_LOGGER.info(
'Meural: Sending postcard. Downloaded %d bytes of image' % (len(content)))

data = aiohttp.FormData()

data.add_field('image', content, filename=name,
content_type=content_type)

response = await self.request("post", f"items",
data=data, data_key="data")

_LOGGER.info('Meural: Sending postcard. Image uploaded')
return response

async def preview_item(self, device_id, item_id):
return await self.request("post", f"devices/{device_id}/preview/{item_id}")

async def delete_item(self, item_id):
return await self.request("delete", f"items/{item_id}")

async def send_postcard_cloud(self, device, url, content_type, name, author, description, medium, year, duration):
_LOGGER.info('Meural device %s: Uploading content. URL is %s' % (
device['alias'], url))
response = await self.upload_content(url, content_type=content_type, name=name)

item_id = response["id"]
response = await self.update_content(id=item_id, name=name, author=author, description=description, medium=medium, year=year)

_LOGGER.info('Meural device %s: Sending postcard. Item Id: %s' % (
device['alias'], item_id))
response = await self.preview_item(device_id=device["id"], item_id=item_id)
_LOGGER.info('Meural device %s: Sending postcard. Sent for preview: %s' % (
device['alias'], item_id))

duration = 60 if duration == None else duration
await asyncio.sleep(duration)

response = await self.delete_item(item_id=item_id)
_LOGGER.info('Meural device %s: Sending postcard. Deleted the item: %s' % (
device['alias'], item_id))
return response

class LocalMeural:
def __init__(self, device, session: aiohttp.ClientSession):
self.ip = device["localIp"]
Expand Down Expand Up @@ -234,26 +318,28 @@ async def send_postcard(self, url, content_type):
data = aiohttp.FormData()
data.add_field('photo', image, content_type=content_type)
response = await self.session.post(f"http://{self.ip}/remote/postcard",
data=data)
data=data)
_LOGGER.info('Meural device %s: Sending postcard. Response: %s' % (
self.device['alias'], response))
text = await response.text()

r = json.loads(text)
_LOGGER.info('Meural device %s: Sending postcard. Image uploaded, status: %s, response: %s' % (
self.device['alias'], r['status'], r['response']))
self.device['alias'], r['status'], r['response']))
if r['status'] != 'pass':
_LOGGER.error('Meural device %s: Sending postcard. Could not upload, response: %s' % (
self.device['alias'], r['response']))

return response


class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""


class DeviceTurnedOff(HomeAssistantError):
"""Error to indicate device turned off or not connected to the network."""
37 changes: 33 additions & 4 deletions custom_components/meural/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ reset_brightness:
description: Entity ID of the Meural Canvas to update.
example: "media_player.meural-123"
toggle_informationcard:
description: Toggle display of the information card, a museum-style placard, on your Canvas.
description: Toggle display of the information card, a museum-style placard, on your Canvas.
fields:
entity_id:
description: Entity ID of the Meural Canvas to toggle display on.
Expand All @@ -39,6 +39,35 @@ preview_image:
content_type:
description: The type of image to preview. Can be image/jpg or image/png.
example: "image/png"

preview_image_cloud:
description: Preview an image from an URL on Meural Canvas (Uses Meural cloud storage to work around the sticky image issue when displaying an image using local API).
fields:
entity_id:
description: Entity ID of the Meural Canvas to update.
example: "media_player.meural-123"
content_url:
description: URL of the image to preview.
example: "https://home-assistant.io/images/cast/splash.png"
content_type:
description: The type of image to preview. Can be image/jpg or image/png.
example: "image/png"
name:
description: Name of the media item
example: "Game of Thrones"
author:
description: Author or Artist name
example: George Martin
description:
description: Description for the media item
example: Game of Thrones Poster image
medium:
description: Medium for the media item
example: Photography
year:
description: Year. This needs to be a date or just the year. Other format doesn't work
example: 2022/05/01

set_device_option:
description: Set the configuration options of a Meural Canvas.
fields:
Expand All @@ -47,9 +76,9 @@ set_device_option:
example: "media_player.meural-123"
orientation:
description: Override the orientation of images on your Canvas. Can be horizontal, vertical.
example: "horizontal"
example: "horizontal"
orientationMatch:
description: Your Canvas will only show images that match its current orientation, i.e. if your Canvas is in vertical, only vertical images will display.
description: Your Canvas will only show images that match its current orientation, i.e. if your Canvas is in vertical, only vertical images will display.
example: "true"
alsEnabled:
description: Your Canvas will automatically adjusts its brightness to match its surroundings using the Ambient Light Sensor.
Expand Down Expand Up @@ -84,7 +113,7 @@ set_device_option:
backgroundColor:
description: Color displayed behind images that don't fill the frame. Can be grey, white, black.
example: "black"
fillMode:
fillMode:
description: How images will fill the screen if they don't match the Canvas' aspect ratio. Can be contain, auto crop, as is, stretch.
example: "auto crop"
galleryRotation:
Expand Down
7 changes: 7 additions & 0 deletions custom_components/meural/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from re import sub

def snake_case(s):
return '_'.join(
sub('([A-Z][a-z]+)', r' \1',
sub('([A-Z]+)', r' \1',
s.replace('-', ' '))).split()).lower()