410 lines
13 KiB
Python
410 lines
13 KiB
Python
"""Support for PlayStation 4 consoles."""
|
|
import logging
|
|
import socket
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.media_player import (
|
|
ENTITY_IMAGE_URL, MediaPlayerDevice)
|
|
from homeassistant.components.media_player.const import (
|
|
MEDIA_TYPE_GAME, MEDIA_TYPE_APP, SUPPORT_SELECT_SOURCE,
|
|
SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON)
|
|
from homeassistant.components.ps4 import format_unique_id
|
|
from homeassistant.const import (
|
|
ATTR_COMMAND, ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_REGION,
|
|
CONF_TOKEN, STATE_IDLE, STATE_OFF, STATE_PLAYING)
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.util.json import load_json, save_json
|
|
|
|
from .const import DOMAIN as PS4_DOMAIN, REGIONS as deprecated_regions
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
SUPPORT_PS4 = SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \
|
|
SUPPORT_STOP | SUPPORT_SELECT_SOURCE
|
|
|
|
PS4_DATA = 'ps4_data'
|
|
ICON = 'mdi:playstation'
|
|
GAMES_FILE = '.ps4-games.json'
|
|
MEDIA_IMAGE_DEFAULT = None
|
|
|
|
COMMANDS = (
|
|
'up',
|
|
'down',
|
|
'right',
|
|
'left',
|
|
'enter',
|
|
'back',
|
|
'option',
|
|
'ps',
|
|
)
|
|
|
|
SERVICE_COMMAND = 'send_command'
|
|
|
|
PS4_COMMAND_SCHEMA = vol.Schema({
|
|
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
|
vol.Required(ATTR_COMMAND): vol.In(list(COMMANDS))
|
|
})
|
|
|
|
|
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
|
"""Set up PS4 from a config entry."""
|
|
config = config_entry
|
|
|
|
def add_entities(entities, update_before_add=False):
|
|
"""Sync version of async add devices."""
|
|
hass.add_job(async_add_entities, entities, update_before_add)
|
|
|
|
await hass.async_add_executor_job(
|
|
setup_platform, hass, config,
|
|
add_entities, None)
|
|
|
|
async def async_service_handle(hass):
|
|
"""Handle for services."""
|
|
def service_command(call):
|
|
entity_ids = call.data[ATTR_ENTITY_ID]
|
|
command = call.data[ATTR_COMMAND]
|
|
for device in hass.data[PS4_DATA].devices:
|
|
if device.entity_id in entity_ids:
|
|
device.send_command(command)
|
|
|
|
hass.services.async_register(
|
|
PS4_DOMAIN, SERVICE_COMMAND, service_command,
|
|
schema=PS4_COMMAND_SCHEMA)
|
|
|
|
await async_service_handle(hass)
|
|
|
|
|
|
def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
"""Set up PS4 Platform."""
|
|
import pyps4_homeassistant as pyps4
|
|
hass.data[PS4_DATA] = PS4Data()
|
|
games_file = hass.config.path(GAMES_FILE)
|
|
creds = config.data[CONF_TOKEN]
|
|
device_list = []
|
|
for device in config.data['devices']:
|
|
host = device[CONF_HOST]
|
|
region = device[CONF_REGION]
|
|
name = device[CONF_NAME]
|
|
ps4 = pyps4.Ps4(host, creds)
|
|
device_list.append(PS4Device(
|
|
name, host, region, ps4, creds, games_file))
|
|
add_entities(device_list, True)
|
|
|
|
|
|
class PS4Data():
|
|
"""Init Data Class."""
|
|
|
|
def __init__(self):
|
|
"""Init Class."""
|
|
self.devices = []
|
|
|
|
|
|
class PS4Device(MediaPlayerDevice):
|
|
"""Representation of a PS4."""
|
|
|
|
def __init__(self, name, host, region, ps4, creds, games_file):
|
|
"""Initialize the ps4 device."""
|
|
self._ps4 = ps4
|
|
self._host = host
|
|
self._name = name
|
|
self._region = region
|
|
self._creds = creds
|
|
self._state = None
|
|
self._games_filename = games_file
|
|
self._media_content_id = None
|
|
self._media_title = None
|
|
self._media_image = None
|
|
self._media_type = None
|
|
self._source = None
|
|
self._games = {}
|
|
self._source_list = []
|
|
self._retry = 0
|
|
self._disconnected = False
|
|
self._info = None
|
|
self._unique_id = None
|
|
self._power_on = False
|
|
|
|
async def async_added_to_hass(self):
|
|
"""Subscribe PS4 events."""
|
|
self.hass.data[PS4_DATA].devices.append(self)
|
|
|
|
def update(self):
|
|
"""Retrieve the latest data."""
|
|
try:
|
|
status = self._ps4.get_status()
|
|
if self._info is None:
|
|
# Add entity to registry
|
|
self.get_device_info(status)
|
|
self._games = self.load_games()
|
|
if self._games is not None:
|
|
self._source_list = list(sorted(self._games.values()))
|
|
# Non-Breaking although data returned may be inaccurate.
|
|
if self._region in deprecated_regions:
|
|
_LOGGER.info("""Region: %s has been deprecated.
|
|
Please remove PS4 integration
|
|
and Re-configure again to utilize
|
|
current regions""", self._region)
|
|
except socket.timeout:
|
|
status = None
|
|
if status is not None:
|
|
self._retry = 0
|
|
self._disconnected = False
|
|
if status.get('status') == 'Ok':
|
|
# Check if only 1 device in Hass.
|
|
if len(self.hass.data[PS4_DATA].devices) == 1:
|
|
# Enable keep alive feature for PS4 Connection.
|
|
# Only 1 device is supported, Since have to use port 997.
|
|
self._ps4.keep_alive = True
|
|
else:
|
|
self._ps4.keep_alive = False
|
|
if self._power_on:
|
|
# Auto Login after Turn On.
|
|
self._ps4.open()
|
|
self._power_on = False
|
|
title_id = status.get('running-app-titleid')
|
|
name = status.get('running-app-name')
|
|
if title_id and name is not None:
|
|
self._state = STATE_PLAYING
|
|
if self._media_content_id != title_id:
|
|
self._media_content_id = title_id
|
|
self.get_title_data(title_id, name)
|
|
else:
|
|
self.idle()
|
|
else:
|
|
self.state_off()
|
|
elif self._retry > 5:
|
|
self.state_unknown()
|
|
else:
|
|
self._retry += 1
|
|
|
|
def idle(self):
|
|
"""Set states for state idle."""
|
|
self.reset_title()
|
|
self._state = STATE_IDLE
|
|
|
|
def state_off(self):
|
|
"""Set states for state off."""
|
|
self.reset_title()
|
|
self._state = STATE_OFF
|
|
|
|
def state_unknown(self):
|
|
"""Set states for state unknown."""
|
|
self.reset_title()
|
|
self._state = None
|
|
if self._disconnected is False:
|
|
_LOGGER.warning("PS4 could not be reached")
|
|
self._disconnected = True
|
|
self._retry = 0
|
|
|
|
def reset_title(self):
|
|
"""Update if there is no title."""
|
|
self._media_title = None
|
|
self._media_content_id = None
|
|
self._source = None
|
|
|
|
def get_title_data(self, title_id, name):
|
|
"""Get PS Store Data."""
|
|
from pyps4_homeassistant.errors import PSDataIncomplete
|
|
app_name = None
|
|
art = None
|
|
try:
|
|
title = self._ps4.get_ps_store_data(
|
|
name, title_id, self._region)
|
|
except PSDataIncomplete:
|
|
_LOGGER.error(
|
|
"Could not find data in region: %s for PS ID: %s",
|
|
self._region, title_id)
|
|
else:
|
|
app_name = title.name
|
|
art = title.cover_art
|
|
finally:
|
|
self._media_title = app_name or name
|
|
self._source = self._media_title
|
|
self._media_image = art
|
|
if title.game_type == 'App':
|
|
self._media_type = MEDIA_TYPE_APP
|
|
else:
|
|
self._media_type = MEDIA_TYPE_GAME
|
|
self.update_list()
|
|
|
|
def update_list(self):
|
|
"""Update Game List, Correct data if different."""
|
|
if self._media_content_id in self._games:
|
|
store = self._games[self._media_content_id]
|
|
if store != self._media_title:
|
|
self._games.pop(self._media_content_id)
|
|
if self._media_content_id not in self._games:
|
|
self.add_games(self._media_content_id, self._media_title)
|
|
self._games = self.load_games()
|
|
self._source_list = list(sorted(self._games.values()))
|
|
|
|
def load_games(self):
|
|
"""Load games for sources."""
|
|
g_file = self._games_filename
|
|
try:
|
|
games = load_json(g_file)
|
|
|
|
# If file does not exist, create empty file.
|
|
except FileNotFoundError:
|
|
games = {}
|
|
self.save_games(games)
|
|
return games
|
|
|
|
def save_games(self, games):
|
|
"""Save games to file."""
|
|
g_file = self._games_filename
|
|
try:
|
|
save_json(g_file, games)
|
|
except OSError as error:
|
|
_LOGGER.error("Could not save game list, %s", error)
|
|
|
|
# Retry loading file
|
|
if games is None:
|
|
self.load_games()
|
|
|
|
def add_games(self, title_id, app_name):
|
|
"""Add games to list."""
|
|
games = self._games
|
|
if title_id is not None and title_id not in games:
|
|
game = {title_id: app_name}
|
|
games.update(game)
|
|
self.save_games(games)
|
|
|
|
def get_device_info(self, status):
|
|
"""Set device info for registry."""
|
|
_sw_version = status['system-version']
|
|
_sw_version = _sw_version[1:4]
|
|
sw_version = "{}.{}".format(_sw_version[0], _sw_version[1:])
|
|
self._info = {
|
|
'name': status['host-name'],
|
|
'model': 'PlayStation 4',
|
|
'identifiers': {
|
|
(PS4_DOMAIN, status['host-id'])
|
|
},
|
|
'manufacturer': 'Sony Interactive Entertainment Inc.',
|
|
'sw_version': sw_version
|
|
}
|
|
|
|
self._unique_id = format_unique_id(self._creds, status['host-id'])
|
|
|
|
async def async_will_remove_from_hass(self):
|
|
"""Remove Entity from Hass."""
|
|
# Close TCP Socket
|
|
if self._ps4.connected:
|
|
await self.hass.async_add_executor_job(self._ps4.close)
|
|
self.hass.data[PS4_DATA].devices.remove(self)
|
|
|
|
@property
|
|
def device_info(self):
|
|
"""Return information about the device."""
|
|
return self._info
|
|
|
|
@property
|
|
def unique_id(self):
|
|
"""Return Unique ID for entity."""
|
|
return self._unique_id
|
|
|
|
@property
|
|
def entity_picture(self):
|
|
"""Return picture."""
|
|
if self._state == STATE_PLAYING and self._media_content_id is not None:
|
|
image_hash = self.media_image_hash
|
|
if image_hash is not None:
|
|
return ENTITY_IMAGE_URL.format(
|
|
self.entity_id, self.access_token, image_hash)
|
|
return MEDIA_IMAGE_DEFAULT
|
|
|
|
@property
|
|
def name(self):
|
|
"""Return the name of the device."""
|
|
return self._name
|
|
|
|
@property
|
|
def state(self):
|
|
"""Return the state of the device."""
|
|
return self._state
|
|
|
|
@property
|
|
def icon(self):
|
|
"""Icon."""
|
|
return ICON
|
|
|
|
@property
|
|
def media_content_id(self):
|
|
"""Content ID of current playing media."""
|
|
return self._media_content_id
|
|
|
|
@property
|
|
def media_content_type(self):
|
|
"""Content type of current playing media."""
|
|
return self._media_type
|
|
|
|
@property
|
|
def media_image_url(self):
|
|
"""Image url of current playing media."""
|
|
if self._media_content_id is None:
|
|
return MEDIA_IMAGE_DEFAULT
|
|
return self._media_image
|
|
|
|
@property
|
|
def media_title(self):
|
|
"""Title of current playing media."""
|
|
return self._media_title
|
|
|
|
@property
|
|
def supported_features(self):
|
|
"""Media player features that are supported."""
|
|
return SUPPORT_PS4
|
|
|
|
@property
|
|
def source(self):
|
|
"""Return the current input source."""
|
|
return self._source
|
|
|
|
@property
|
|
def source_list(self):
|
|
"""List of available input sources."""
|
|
return self._source_list
|
|
|
|
def turn_off(self):
|
|
"""Turn off media player."""
|
|
self._ps4.standby()
|
|
|
|
def turn_on(self):
|
|
"""Turn on the media player."""
|
|
self._power_on = True
|
|
self._ps4.wakeup()
|
|
|
|
def media_pause(self):
|
|
"""Send keypress ps to return to menu."""
|
|
self.send_remote_control('ps')
|
|
|
|
def media_stop(self):
|
|
"""Send keypress ps to return to menu."""
|
|
self.send_remote_control('ps')
|
|
|
|
def select_source(self, source):
|
|
"""Select input source."""
|
|
for title_id, game in self._games.items():
|
|
if source.lower().encode(encoding='utf-8') == \
|
|
game.lower().encode(encoding='utf-8') \
|
|
or source == title_id:
|
|
_LOGGER.debug(
|
|
"Starting PS4 game %s (%s) using source %s",
|
|
game, title_id, source)
|
|
self._ps4.start_title(
|
|
title_id, running_id=self._media_content_id)
|
|
return
|
|
_LOGGER.warning(
|
|
"Could not start title. '%s' is not in source list", source)
|
|
return
|
|
|
|
def send_command(self, command):
|
|
"""Send Button Command."""
|
|
self.send_remote_control(command)
|
|
|
|
def send_remote_control(self, command):
|
|
"""Send RC command."""
|
|
self._ps4.remote_control(command)
|