├── .coveragerc ├── .gitignore ├── .travis.yml ├── Dockerfile ├── README.rst ├── docker_run.sh ├── pyps4 ├── __init__.py ├── cli.py ├── connection.py ├── ddp.py ├── errors.py └── ps4.py ├── setup.py └── tests ├── credentials.json ├── test_connection.py ├── test_ddp.py └── test_ps4.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */python?.?/* 4 | */site-packages/nose/* 5 | */tests/* 6 | */ndg/* 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.pyc 3 | *.swp 4 | build/ 5 | dist/ 6 | *.egg-info/ 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - '2.7' 5 | - '3.4' 6 | - '3.5' 7 | - '3.6' 8 | install: 9 | - pip install python-coveralls 10 | - pip install coverage 11 | - pip install nose 12 | - pip install future 13 | script: 14 | - python setup.py install 15 | - nosetests --with-coverage 16 | after_success: 17 | - coveralls 18 | deploy: 19 | provider: pypi 20 | user: hthiery 21 | password: 22 | secure: oYABOrGYP7Jbrlurm9Htll+szbbc6TMPfokLOQvdQMIA/hOfmVYalN4l6te0zwzuUgP9fkf85ueq1cDuazmInzRWFWdMrIwA9za2uVeMD23Qm5BUL7Y6ris4ZfO6KLt7SCqXp7FmuYwSW1N67gql3DMaGk1GReYcmUjPcFMLjaeWMRyzLjeuPB/psblUFYFQdc9//PL14MNHCGWbn+o0sbn1DorDZ+BVW7+JzJXqO3CAzOD2e6g/+e6qbBs/Yi2sCXl5cKPqVg6QbGWSPMMVikVkPMfVsB9bFqkLBQxIn0iZqdJBR8fV44izn1qA6EPPBbM4GY0Pqp9sSgCAH9mxYF0ESfQf9B8JtZ7hD9L9sP7W40KnutWoEymw2rnBoA1hRxMIwvnax8hR2hYMM0V5BjIetD5aZBJLay1V/dOh8HqiUlW7DOqHBNWIEqwzHY8yo2cs6iHsHplengeHqBjbBwXZfTluxcpIXbeqHFu1LW4V7s2uKUTOdp4UekVS79CPEyh3pgedRRRDyhg9XJzJrwPYXdXeCt6tZNINe4ae7/yIXIw18+fTXh+/GdiERAl9YYNrjIhz5hJozxPgZyGONiZBgB1TGgC+9m73zXgZIMlkxBom5UGIJBypA98lsviF/IlF/xrmOv091CPMt9QDBQFkR/YLEaIc9vzsLTWCXpc= 23 | on: 24 | tags: true 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | MAINTAINER Henrik Nicolaisen 3 | 4 | ENV PATH "$PATH:/usr/local/bin" 5 | 6 | RUN apt-get update 7 | RUN apt-get install -y tcpdump net-tools 8 | 9 | COPY . ps4 10 | 11 | RUN cd ps4 && \ 12 | python3 setup.py install --force 13 | 14 | ENTRYPOINT [ "ps4-ctrl" ] 15 | CMD [ "-h", "-v" ] 16 | 17 | SHELL ["/bin/bash", "-c"] 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Python Library to access PS4 2 | ============================ 3 | 4 | |BuildStatus| |PypiVersion| |PyPiPythonVersions| |Coveralls| |CodeClimate| 5 | 6 | Motivation 7 | ---------- 8 | The intention for this project is to have a pure python implementation of the `ps4-waker`_ features. 9 | 10 | Tested with 11 | ----------- 12 | PS 4 system version: 13 | 14 | - 5.03 (05030061) 15 | - 5.05 (05050031) 16 | - 5.50 (05508011) 17 | 18 | Credentials 19 | ----------- 20 | Currently there is no way to generate the credential file with this library. 21 | The credential file can be used from the ps4-waker. 22 | 23 | References 24 | ---------- 25 | 26 | - https://github.com/dsokoloski/ps4-wake 27 | - https://github.com/dhleong/ps4-waker 28 | 29 | .. _ps4-waker: https://github.com/dhleong/ps4-waker 30 | 31 | 32 | .. |BuildStatus| image:: https://travis-ci.org/hthiery/python-ps4.png?branch=master 33 | :target: https://travis-ci.org/hthiery/python-ps4 34 | .. |PyPiVersion| image:: https://badge.fury.io/py/pyps4.svg 35 | :target: http://badge.fury.io/py/pyps4 36 | .. |PyPiPythonVersions| image:: https://img.shields.io/pypi/pyversions/pyps4.svg 37 | :alt: Python versions 38 | :target: http://badge.fury.io/py/pyps4 39 | .. |Coveralls| image:: https://coveralls.io/repos/github/hthiery/python-ps4/badge.svg?branch=master 40 | :target: https://coveralls.io/github/hthiery/python-ps4?branch=master 41 | .. |CodeClimate| image:: https://api.codeclimate.com/v1/badges/193b80aebe76c6d8a2a2/maintainability 42 | :target: https://codeclimate.com/github/hthiery/python-ps4/maintainability 43 | :alt: Maintainability 44 | -------------------------------------------------------------------------------- /docker_run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Run in docker 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | docker build -t ps4-ctrl . 8 | docker run --rm --net=host -it ps4-ctrl 9 | 10 | echo "-------------------" 11 | echo "to test stuff use :" 12 | echo " docker run --rm --net=host -it ps4-ctrl -v -C 903a0d528cd5029272d15d0d771d7bb0f4e09974779a21c351b0f6c321fca498 search" 13 | echo " docker run --rm --net=host -it ps4-ctrl -v -H 10.254.2.107 -C 903a0d528cd5029272d15d0d771d7bb0f4e09974779a21c351b0f6c321fca498 wakeup" 14 | echo " docker run --rm --net=host -it --entrypoint /bin/bash ps4-ctrl" 15 | -------------------------------------------------------------------------------- /pyps4/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .connection import * 3 | from .ddp import * 4 | from .errors import * 5 | from .ps4 import Ps4, open_credential_file 6 | -------------------------------------------------------------------------------- /pyps4/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | 6 | import argparse 7 | import logging 8 | import pprint 9 | import sys 10 | 11 | import pyps4 12 | 13 | try: 14 | from version import __version__ 15 | except ImportError: 16 | __version__ = 'dev' 17 | 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | def cmd_search(_, __): 23 | """The search command.""" 24 | info = pyps4.search(broadcast=True) 25 | pprint.pprint(info) 26 | 27 | 28 | def cmd_status(playstation, _): 29 | """The status command.""" 30 | info = playstation.get_status() 31 | pprint.pprint(info) 32 | 33 | 34 | def cmd_launch(playstation, _): 35 | """The status command.""" 36 | playstation.launch() 37 | 38 | 39 | def cmd_wakeup(playstation, _): 40 | """Wakeup the PS4.""" 41 | playstation.wakeup() 42 | 43 | 44 | def cmd_login(playstation, _): 45 | """Login the PS4.""" 46 | try: 47 | playstation.login() 48 | except pyps4.NotReady: 49 | print('playstaion not ready') 50 | sys.exit(1) 51 | 52 | 53 | def cmd_standby(playstation, _): 54 | """Set the PS4 in standby.""" 55 | try: 56 | playstation.standby() 57 | except pyps4.NotReady: 58 | print('playstaion not ready') 59 | sys.exit(1) 60 | 61 | 62 | def cmd_start_title(playstation, args): 63 | """Set the PS4 in standby.""" 64 | try: 65 | playstation.start_title(args.title_id) 66 | except pyps4.NotReady: 67 | print('playstaion not ready') 68 | sys.exit(1) 69 | 70 | 71 | def cmd_remote_control(playstation, args): 72 | """Send a remote control button.""" 73 | try: 74 | playstation.remote_control(args.button, args.hold_time) 75 | except pyps4.NotReady: 76 | print('playstaion not ready') 77 | sys.exit(1) 78 | 79 | 80 | def main(args=None): 81 | """The main function.""" 82 | parser = argparse.ArgumentParser( 83 | description='PS4 CLI tool.') 84 | parser.add_argument('-v', action='store_true', dest='verbose', 85 | help='be more verbose') 86 | parser.add_argument('-H', '--host', type=str, dest='host', 87 | help='PS4 IP address', default=None) 88 | parser.add_argument('-c', '--credential_file', type=str, 89 | dest='credential_file', default=None, 90 | help='The credential file') 91 | parser.add_argument('-C', '--credential', type=str, 92 | dest='credential', default=None, 93 | help='The credential as string') 94 | parser.add_argument('-V', '--version', action='version', 95 | version='{version}'.format(version=__version__), 96 | help='Print version') 97 | 98 | _sub = parser.add_subparsers(title='Commands') 99 | _sub.required = True 100 | 101 | # search all devices 102 | subparser = _sub.add_parser('search', help='Search for PS4 devices') 103 | subparser.set_defaults(func=cmd_search) 104 | 105 | # info 106 | subparser = _sub.add_parser('status', help='Show current status') 107 | subparser.set_defaults(func=cmd_status) 108 | 109 | # launch 110 | subparser = _sub.add_parser('launch', help='Show current status') 111 | subparser.set_defaults(func=cmd_launch) 112 | 113 | # wake 114 | subparser = _sub.add_parser('wakeup', help='Wakeup the PS4') 115 | subparser.set_defaults(func=cmd_wakeup) 116 | 117 | # login 118 | subparser = _sub.add_parser('login', help='Login the PS4') 119 | subparser.set_defaults(func=cmd_login) 120 | 121 | # standby 122 | subparser = _sub.add_parser('standby', help='Standby the PS4') 123 | subparser.set_defaults(func=cmd_standby) 124 | 125 | # start 126 | subparser = _sub.add_parser('start', help='Start a title') 127 | subparser.add_argument('title_id', type=str, 128 | metavar="TITLE", help='Game title') 129 | subparser.set_defaults(func=cmd_start_title) 130 | 131 | # remote 132 | subparser = _sub.add_parser('remote', help='Send remote control') 133 | subparser.add_argument('button', type=str, 134 | metavar="BUTTON", help='button') 135 | subparser.add_argument('hold_time', type=int, default=0, 136 | metavar="HOLD_TIME", help='hold time') 137 | subparser.set_defaults(func=cmd_remote_control) 138 | 139 | args = parser.parse_args(args) 140 | 141 | playstation = None 142 | 143 | if not hasattr(args, 'func'): 144 | parser.print_help() 145 | sys.exit() 146 | 147 | if args.verbose: 148 | logging.basicConfig() 149 | logging.getLogger('pyps4').setLevel(logging.DEBUG) 150 | 151 | try: 152 | playstation = pyps4.Ps4(args.host, credential=args.credential, 153 | credentials_file=args.credential_file) 154 | args.func(playstation, args) 155 | finally: 156 | pass 157 | 158 | 159 | if __name__ == '__main__': 160 | main() 161 | -------------------------------------------------------------------------------- /pyps4/connection.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function 3 | 4 | import binascii 5 | import logging 6 | import socket 7 | 8 | from construct import (Bytes, Const, Int32ul, Padding, Struct) 9 | from Cryptodome.Cipher import AES, PKCS1_OAEP 10 | from Cryptodome.PublicKey import RSA 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | PUBLIC_KEY = ( 15 | '-----BEGIN PUBLIC KEY-----\n' 16 | 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfAO/MDk5ovZpp7xlG9J\n' 17 | 'JKc4Sg4ztAz+BbOt6Gbhub02tF9bryklpTIyzM0v817pwQ3TCoigpxEcWdTykhDL\n' 18 | 'cGhAbcp6E7Xh8aHEsqgtQ/c+wY1zIl3fU//uddlB1XuipXthDv6emXsyyU/tJWqc\n' 19 | 'zy9HCJncLJeYo7MJvf2TE9nnlVm1x4flmD0k1zrvb3MONqoZbKb/TQVuVhBv7SM+\n' 20 | 'U5PSi3diXIx1Nnj4vQ8clRNUJ5X1tT9XfVmKQS1J513XNZ0uYHYRDzQYujpLWucu\n' 21 | 'ob7v50wCpUm3iKP1fYCixMP6xFm0jPYz1YQaMV35VkYwc40qgk3av0PDS+1G0dCm\n' 22 | 'swIDAQAB\n' 23 | '-----END PUBLIC KEY-----') 24 | 25 | 26 | def _get_public_key_rsa(): 27 | key = RSA.importKey(PUBLIC_KEY) 28 | return key.publickey() 29 | 30 | 31 | class Connection(object): 32 | """The TCP connection class.""" 33 | def __init__(self, host, credential=None, port=997): 34 | self._host = host 35 | self._credential = credential 36 | self._port = port 37 | self._socket = None 38 | self._cipher = None 39 | self._decipher = None 40 | self._random_seed = None 41 | 42 | def connect(self): 43 | """Open the connection.""" 44 | _LOGGER.debug('Connect') 45 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 46 | self._socket.connect((self._host, self._port)) 47 | self._random_seed = \ 48 | b'\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 49 | self._send_hello_request() 50 | data = self._recv_hello_request() 51 | self._set_crypto_init_vector(data.seed) 52 | self._send_handshake_request(data.seed) 53 | 54 | def disconnect(self): 55 | """Close the connection.""" 56 | self._socket.close() 57 | self._reset_crypto_init_vector() 58 | self._random_seed = None 59 | 60 | def login(self): 61 | """Login.""" 62 | _LOGGER.debug('Login') 63 | self._send_login_request() 64 | msg = self._recv_msg() 65 | msg = self._decipher.decrypt(msg) 66 | _LOGGER.debug('RX: %s %s', len(msg), binascii.hexlify(msg)) 67 | 68 | def standby(self): 69 | """Request standby.""" 70 | _LOGGER.debug('Request standby') 71 | self._send_standby_request() 72 | msg = self._recv_msg() 73 | msg = self._decipher.decrypt(msg) 74 | _LOGGER.debug('RX: %s %s', len(msg), binascii.hexlify(msg)) 75 | 76 | def start_title(self, title_id): 77 | """Start an application/game title.""" 78 | _LOGGER.debug('Start title: %s', title_id) 79 | self._send_boot_request(title_id) 80 | msg = self._recv_msg() 81 | msg = self._decipher.decrypt(msg) 82 | _LOGGER.debug('RX: %s %s', len(msg), binascii.hexlify(msg)) 83 | 84 | def remote_control(self, op, hold_time=0): 85 | """Send reomte control command.""" 86 | _LOGGER.debug('Remote control: %s (%s)', op, hold_time) 87 | self._send_remote_control_request(op, hold_time) 88 | #msg = self._recv_msg() 89 | #msg = self._decipher.decrypt(msg) 90 | #_LOGGER.debug('RX: %s %s', len(msg), binascii.hexlify(msg)) 91 | 92 | def _send_msg(self, msg, encrypted=False): 93 | _LOGGER.debug('TX: %s %s', len(msg), binascii.hexlify(msg)) 94 | if encrypted: 95 | msg = self._cipher.encrypt(msg) 96 | _LOGGER.debug('TX(cypted): %s %s', len(msg), binascii.hexlify(msg)) 97 | self._socket.send(msg) 98 | 99 | def _recv_msg(self, encrypted=False): 100 | msg = self._socket.recv(1024) 101 | _LOGGER.debug('RX: %s %s', len(msg), binascii.hexlify(msg)) 102 | return msg 103 | 104 | def _set_crypto_init_vector(self, init_vector): 105 | self._cipher = AES.new(self._random_seed, AES.MODE_CBC, init_vector) 106 | self._decipher = AES.new(self._random_seed, AES.MODE_CBC, init_vector) 107 | 108 | def _reset_crypto_init_vector(self): 109 | self._cipher = None 110 | self._decipher = None 111 | 112 | def _send_hello_request(self): 113 | fmt = Struct( 114 | 'length' / Const(b'\x1c\x00\x00\x00'), 115 | 'type' / Const(b'\x70\x63\x63\x6f'), 116 | 'version' / Const(b'\x00\x00\x02\x00'), 117 | 'dummy' / Padding(16), 118 | ) 119 | 120 | msg = fmt.build({}) 121 | self._send_msg(msg) 122 | 123 | def _recv_hello_request(self): 124 | fmt = Struct( 125 | 'length' / Int32ul, 126 | 'type' / Int32ul, 127 | 'version' / Int32ul, 128 | 'dummy' / Bytes(8), 129 | 'seed' / Bytes(16), 130 | ) 131 | 132 | msg = self._recv_msg() 133 | data = fmt.parse(msg) 134 | return data 135 | 136 | def _send_handshake_request(self, seed): 137 | fmt = Struct( 138 | 'length' / Const(b'\x18\x01\x00\x00'), 139 | 'type' / Const(b'\x20\x00\x00\x00'), 140 | 'key' / Bytes(256), 141 | 'seed' / Bytes(16), 142 | ) 143 | 144 | recipient_key = _get_public_key_rsa() 145 | cipher_rsa = PKCS1_OAEP.new(recipient_key) 146 | key = cipher_rsa.encrypt(self._random_seed) 147 | 148 | _LOGGER.debug('key %s', binascii.hexlify(key)) 149 | 150 | msg = fmt.build({'key': key, 'seed': seed}) 151 | self._send_msg(msg) 152 | 153 | def _send_login_request(self): 154 | fmt = Struct( 155 | 'length' / Const(b'\x80\x01\x00\x00'), 156 | 'type' / Const(b'\x1e\x00\x00\x00'), 157 | 'pass_code' / Const(b'\x00\x00\x00\x00'), 158 | 'magic_number' / Const(b'\x01\x02\x00\x00'), 159 | 'account_id' / Bytes(64), 160 | 'app_label' / Bytes(256), 161 | 'os_version' / Bytes(16), 162 | 'model' / Bytes(16), 163 | 'pin_code' / Bytes(16), 164 | ) 165 | 166 | config = { 167 | 'app_label': b'PlayStation'.ljust(256, b'\x00'), 168 | 'account_id': self._credential.encode().ljust(64, b'\x00'), 169 | 'os_version': b'4.4'.ljust(16, b'\x00'), 170 | 'model': b'PS4 Waker'.ljust(16, b'\x00'), 171 | 'pin_code': b''.ljust(16, b'\x00'), 172 | } 173 | 174 | _LOGGER.debug('config %s', config) 175 | 176 | msg = fmt.build(config) 177 | self._send_msg(msg, encrypted=True) 178 | 179 | def _send_standby_request(self): 180 | fmt = Struct( 181 | 'length' / Const(b'\x08\x00\x00\x00'), 182 | 'type' / Const(b'\x1a\x00\x00\x00'), 183 | 'dummy' / Padding(8), 184 | ) 185 | 186 | msg = fmt.build({}) 187 | self._send_msg(msg, encrypted=True) 188 | 189 | def _send_boot_request(self, title_id): 190 | fmt = Struct( 191 | 'length' / Const(b'\x18\x00\x00\x00'), 192 | 'type' / Const(b'\x0a\x00\x00\x00'), 193 | 'title_id' / Bytes(16), 194 | 'dummy' / Padding(8), 195 | ) 196 | 197 | msg = fmt.build({'title_id': title_id.encode().ljust(16, b'\x00')}) 198 | self._send_msg(msg, encrypted=True) 199 | 200 | def _send_remote_control_request(self, op, hold_time=0): 201 | fmt = Struct( 202 | 'length' / Const(b'\x10\x00\x00\x00'), 203 | 'type' / Const(b'\x1c\x00\x00\x00'), 204 | 'op' / Int32ul, 205 | 'hold_time' / Int32ul, 206 | ) 207 | 208 | msg = fmt.build({'op': op, 'hold_time': hold_time}) 209 | self._send_msg(msg, encrypted=True) 210 | -------------------------------------------------------------------------------- /pyps4/ddp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function 3 | 4 | import re 5 | import socket 6 | 7 | UDP_IP = '0.0.0.0' 8 | UDP_PORT = 0 9 | 10 | DDP_PORT = 987 11 | DDP_VERSION = '00020020' 12 | 13 | 14 | def get_ddp_message(msg_type, data=None): 15 | """Get DDP message.""" 16 | msg = u'{} * HTTP/1.1\n'.format(msg_type) 17 | if data: 18 | for key, value in data.items(): 19 | msg += '{}:{}\n'.format(key, value) 20 | msg += 'device-discovery-protocol-version:{}\n'.format(DDP_VERSION) 21 | return msg 22 | 23 | 24 | def parse_ddp_response(rsp): 25 | """Parse the response.""" 26 | data = {} 27 | for line in rsp.splitlines(): 28 | re_status = re.compile(r'HTTP/1.1 (?P\d+) (?P.*)') 29 | line = line.strip() 30 | # skip empty lines 31 | if not line: 32 | continue 33 | elif re_status.match(line): 34 | data[u'status_code'] = int(re_status.match(line).group('code')) 35 | data[u'status'] = re_status.match(line).group('status') 36 | else: 37 | values = line.split(':') 38 | data[values[0]] = values[1] 39 | return data 40 | 41 | 42 | def get_ddp_search_message(): 43 | """Get DDP search message.""" 44 | return get_ddp_message('SRCH') 45 | 46 | 47 | def get_ddp_wake_message(credential): 48 | """Get DDP wake message.""" 49 | data = { 50 | 'user-credential': credential, 51 | 'client-type': 'a', 52 | 'auth-type': 'C', 53 | } 54 | return get_ddp_message('WAKEUP', data) 55 | 56 | 57 | def get_ddp_launch_message(credential): 58 | """Get DDP launch message.""" 59 | data = { 60 | 'user-credential': credential, 61 | 'client-type': 'a', 62 | 'auth-type': 'C', 63 | } 64 | return get_ddp_message('LAUNCH', data) 65 | 66 | 67 | def _send_recv_msg(host, broadcast, msg, receive=True): 68 | """Send a ddp message and receive the response.""" 69 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 70 | sock.bind((UDP_IP, UDP_PORT)) 71 | sock.settimeout(3.0) 72 | 73 | if broadcast: 74 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 75 | host = '255.255.255.255' 76 | 77 | sock.sendto(msg.encode('utf-8'), (host, DDP_PORT)) 78 | 79 | if receive: 80 | return sock.recvfrom(1024) 81 | 82 | 83 | def _send_msg(host, broadcast, msg): 84 | """Send a ddp message.""" 85 | _send_recv_msg(host, broadcast, msg, receive=False) 86 | 87 | 88 | def search(host=None, broadcast=True): 89 | """Discover PS4s.""" 90 | msg = get_ddp_search_message() 91 | data, addr = _send_recv_msg(host, broadcast, msg) 92 | 93 | ps_list = [] 94 | data = parse_ddp_response(data.decode('utf-8')) 95 | data[u'host-ip'] = addr[0] 96 | ps_list.append(data) 97 | return ps_list 98 | 99 | 100 | def get_status(host): 101 | """Get status.""" 102 | ps_list = search(host=host) 103 | return ps_list[0] 104 | 105 | 106 | def wakeup(host, credential, broadcast=None): 107 | """Wakeup PS4s.""" 108 | msg = get_ddp_wake_message(credential) 109 | _send_msg(host, broadcast, msg) 110 | 111 | 112 | def launch(host, credential, broadcast=None): 113 | """Launch.""" 114 | msg = get_ddp_launch_message(credential) 115 | _send_msg(host, broadcast, msg) 116 | -------------------------------------------------------------------------------- /pyps4/errors.py: -------------------------------------------------------------------------------- 1 | class NotReady(Exception): 2 | pass 3 | 4 | 5 | class UnknownButton(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /pyps4/ps4.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function 3 | import json 4 | import logging 5 | import time 6 | 7 | from .connection import Connection 8 | from .ddp import get_status, launch, wakeup 9 | from .errors import NotReady, UnknownButton 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | def open_credential_file(filename): 15 | """Open credential file.""" 16 | return json.load(open(filename)) 17 | 18 | 19 | class Ps4(object): 20 | """The PS4 object.""" 21 | 22 | STATUS_OK = 200 23 | STATUS_STANDBY = 620 24 | 25 | def __init__(self, host, credential=None, credentials_file=None, 26 | broadcast=False): 27 | """Initialize the instance. 28 | 29 | Keyword arguments: 30 | host -- the host IP address 31 | credential -- the credential string 32 | credential_file -- the credendtial file generated with ps4-waker 33 | broadcast -- use broadcast IP address (default False) 34 | """ 35 | self._host = host 36 | self._broadcast = broadcast 37 | self._socket = None 38 | self._credential = None 39 | self._connected = False 40 | 41 | if credential: 42 | self._credential = credential 43 | if credentials_file: 44 | creds = open_credential_file(credentials_file) 45 | self._credential = creds['user-credential'] 46 | 47 | self._connection = Connection(host, credential=self._credential) 48 | 49 | def open(self): 50 | """Open a connection to the PS4.""" 51 | if self.is_standby(): 52 | raise NotReady 53 | 54 | if not self._connected: 55 | self.wakeup() 56 | self.launch() 57 | time.sleep(0.5) 58 | self._connection.connect() 59 | self._connected = True 60 | 61 | def close(self): 62 | """Close the connection to the PS4.""" 63 | self._connection.disconnect() 64 | self._connected = False 65 | 66 | def get_status(self): 67 | """Get current status info. 68 | 69 | Return a dictionary with status information. 70 | """ 71 | return get_status(self._host) 72 | 73 | def launch(self): 74 | """Launch.""" 75 | launch(self._host, self._credential) 76 | 77 | def wakeup(self): 78 | """Wakeup.""" 79 | wakeup(self._host, self._credential) 80 | 81 | def login(self): 82 | """Login.""" 83 | self.open() 84 | self._connection.login() 85 | self.close() 86 | 87 | def standby(self): 88 | """Standby.""" 89 | self.open() 90 | self._connection.login() 91 | self._connection.standby() 92 | self.close() 93 | 94 | def start_title(self, title_id): 95 | """Start title. 96 | 97 | `title_id`: title to start 98 | """ 99 | self.open() 100 | self._connection.login() 101 | self._connection.start_title(title_id) 102 | self.close() 103 | 104 | def remote_control(self, button_name, hold_time=0): 105 | """ Send a remote control button press. 106 | 107 | Documentation from ps4-waker source: 108 | near as I can tell, here's how this works: 109 | - For a simple tap, you send the key with holdTime=0, 110 | followed by KEY_OFF and holdTime = 0 111 | - For a long press/hold, you still send the key with 112 | holdTime=0, the follow it with the key again, but 113 | specifying holdTime as the hold duration. 114 | - After sending a direction, you should send KEY_OFF 115 | to clean it up (since it can just be held forever). 116 | Doing this after a long-press of PS just breaks it, 117 | however. 118 | """ 119 | buttons = [] 120 | buttons.append(button_name) 121 | 122 | self.open() 123 | self._connection.login() 124 | self._connection.remote_control(1024, 0) 125 | 126 | for button in buttons: 127 | try: 128 | operation = { 129 | 'up': 1, 130 | 'down': 2, 131 | 'right': 4, 132 | 'left': 8, 133 | 'enter': 16, 134 | 'back': 32, 135 | 'option': 64, 136 | 'ps': 128, 137 | 'key_off': 256, 138 | 'cancel': 512, 139 | 'open_rc': 1024, 140 | 'close_rc': 2048, 141 | }[button.lower()] 142 | except KeyError: 143 | raise UnknownButton 144 | 145 | self._connection.remote_control(operation, hold_time) 146 | self._connection.remote_control(256, 0) 147 | time.sleep(0.4) 148 | 149 | self._connection.remote_control(2048, 0) 150 | self.close() 151 | 152 | def get_host_status(self): 153 | """Get PS4 status code. 154 | 155 | STATUS_OK: 200 156 | STATUS_STANDBY: 620 157 | """ 158 | return self.get_status()['status_code'] 159 | 160 | def is_running(self): 161 | """Return if the PS4 is running. 162 | 163 | Returns True or False. 164 | """ 165 | return True if self.get_host_status() == self.STATUS_OK else False 166 | 167 | def is_standby(self): 168 | """Return if the PS4 is in standby. 169 | 170 | Returns True or False. 171 | """ 172 | return True if self.get_host_status() == self.STATUS_STANDBY else False 173 | 174 | def get_system_version(self): 175 | """Get the system version.""" 176 | return self.get_status()['system-version'] 177 | 178 | def get_host_id(self): 179 | """Get the host id.""" 180 | return self.get_status()['host-id'] 181 | 182 | def get_host_name(self): 183 | """Get the host name.""" 184 | return self.get_status()['host-name'] 185 | 186 | def get_running_app_titleid(self): 187 | """Return the title Id of the running application.""" 188 | return self.get_status()['running-app-titleid'] 189 | 190 | def get_running_app_name(self): 191 | """Return the name of the running application.""" 192 | return self.get_status()['running-app-name'] 193 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup, find_packages 5 | import os 6 | import re 7 | import subprocess 8 | 9 | name = 'pyps4' 10 | version_py = os.path.join(os.path.dirname(__file__), name, 'version.py') 11 | try: 12 | version = subprocess.check_output( 13 | ['git', 'describe', '--tags', '--always', '--dirty'], 14 | stderr=subprocess.STDOUT).rstrip().decode('ascii') 15 | with open(version_py, 'w') as f: 16 | f.write('# This file was autogenerated by setup.py\n') 17 | f.write('__version__ = \'%s\'\n' % (version,)) 18 | except (OSError, subprocess.CalledProcessError, IOError) as e: 19 | try: 20 | with open(version_py, 'r') as f: 21 | for line in f.readlines(): 22 | val = re.findall("__version__ = '([^']+)'", line) 23 | version = val[0] 24 | except IOError: 25 | version = 'unknown' 26 | 27 | with open('README.rst') as f: 28 | readme = f.read() 29 | 30 | setup(name = name, 31 | version = version, 32 | description = 'PS4 Python Library', 33 | long_description = readme, 34 | author = 'Heiko Thiery', 35 | author_email = 'heiko.thiery@gmail.com', 36 | packages = find_packages(), 37 | url = 'http://github.com/hthiery/python-ps4', 38 | license = 'LGPLv2+', 39 | classifiers = [ 40 | 'Development Status :: 3 - Alpha', 41 | 'Environment :: Console', 42 | 'License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)', 43 | 'Natural Language :: English', 44 | 'Operating System :: OS Independent', 45 | 'Programming Language :: Python :: 2', 46 | 'Programming Language :: Python :: 2.7', 47 | 'Programming Language :: Python :: 3', 48 | 'Programming Language :: Python :: 3.4', 49 | 'Programming Language :: Python :: 3.5', 50 | 'Topic :: Software Development :: Libraries :: Python Modules', 51 | ], 52 | keywords = 'playstation sony ps4', 53 | install_requires = [ 54 | 'construct', 55 | 'pycryptodomex', 56 | ], 57 | entry_points = { 58 | 'console_scripts': [ 59 | 'ps4-ctrl=pyps4.cli:main', 60 | ] 61 | }, 62 | test_suite = 'tests', 63 | include_package_data = True, 64 | ) 65 | -------------------------------------------------------------------------------- /tests/credentials.json: -------------------------------------------------------------------------------- 1 | {"client-type":"a","auth-type":"C","user-credential":"1234567890"} 2 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from nose.tools import eq_, ok_ 4 | from mock import MagicMock 5 | 6 | import pyps4 7 | 8 | 9 | CRED = '0000000000000000000000000000000000000000000000000000000000000000' 10 | 11 | 12 | class TestConnection(object): 13 | 14 | def test_send_hello_request(self): 15 | mock = MagicMock() 16 | 17 | conn = pyps4.Connection('10.10.10.10') 18 | conn._send_msg = mock 19 | conn._send_hello_request() 20 | conn._send_msg.assert_called_with( 21 | b'\x1c\x00\x00\x00pcco\x00\x00\x02\x00\x00\x00' 22 | + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') 23 | 24 | # def test_send_handshake_request(self): 25 | # mock = MagicMock() 26 | # 27 | # conn = pyps4.Connection('10.10.10.10') 28 | # conn._send_msg = mock 29 | # conn._send_handshake_request(b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f') 30 | # conn._send_msg.assert_called_with( 31 | # b'\x1c\x00\x00\x00pcco\x00\x00\x02\x00\x00\x00' 32 | # + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') 33 | 34 | def test_send_login_request(self): 35 | mock = MagicMock() 36 | 37 | conn = pyps4.Connection('10.10.10.10', credential=CRED) 38 | conn._send_msg = mock 39 | conn._send_login_request() 40 | conn._send_msg.assert_called_with( 41 | b'\x80\x01\x00\x00\x1e\x00\x00\x00\x00\x00\x00\x00\x01\x02\x00\x000000000000000000000000000000000000000000000000000000000000000000PlayStation\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x004.4\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00PS4 Waker\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 42 | encrypted=True) 43 | 44 | def test_send_standby_request(self): 45 | mock = MagicMock() 46 | 47 | conn = pyps4.Connection('10.10.10.10', credential=CRED) 48 | conn._send_msg = mock 49 | conn._send_standby_request() 50 | conn._send_msg.assert_called_with( 51 | b'\x08\x00\x00\x00\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 52 | encrypted=True) 53 | 54 | def test_send_boot_request(self): 55 | mock = MagicMock() 56 | 57 | conn = pyps4.Connection('10.10.10.10', credential=CRED) 58 | conn._send_msg = mock 59 | conn._send_boot_request(title_id='abcdef') 60 | conn._send_msg.assert_called_with( 61 | b'\x18\x00\x00\x00\n\x00\x00\x00abcdef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 62 | encrypted=True) 63 | 64 | -------------------------------------------------------------------------------- /tests/test_ddp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from nose.tools import eq_, ok_ 4 | 5 | import pyps4 6 | 7 | 8 | class TestPs4(object): 9 | 10 | def test_parse_ddp_response(self): 11 | response = ''' 12 | HTTP/1.1 200 Ok 13 | host-id:F8461CE2701E 14 | host-type:PS4 15 | host-name:PS4 16 | host-request-port:997 17 | device-discovery-protocol-version:00020020 18 | system-version:05030061''' 19 | data = pyps4.parse_ddp_response(response) 20 | eq_(data['status'], 'Ok') 21 | eq_(data['status_code'], 200) 22 | eq_(data['host-id'], 'F8461CE2701E') 23 | eq_(data['host-type'], 'PS4') 24 | eq_(data['host-name'], 'PS4') 25 | eq_(data['host-request-port'], '997') 26 | eq_(data['device-discovery-protocol-version'], '00020020') 27 | eq_(data['system-version'], '05030061') 28 | 29 | response = ''' 30 | HTTP/1.1 620 Server Standby 31 | host-id:F8461CE2701E 32 | host-type:PS4 33 | host-name:PS4 34 | host-request-port:997 35 | device-discovery-protocol-version:00020020 36 | system-version:05030061''' 37 | data = pyps4.parse_ddp_response(response) 38 | eq_(data['status'], 'Server Standby') 39 | eq_(data['status_code'], 620) 40 | 41 | def test_get_ddp_search_message(self): 42 | msg = pyps4.get_ddp_search_message() 43 | ok_(msg.startswith('SRCH * HTTP/1.1\n')) 44 | ok_('device-discovery-protocol-version:00020020\n' in msg) 45 | 46 | def test_get_ddp_wake_message(self): 47 | msg = pyps4.get_ddp_wake_message('12345') 48 | ok_(msg.startswith('WAKEUP * HTTP/1.1\n')) 49 | ok_('client-type:a\n' in msg) 50 | ok_('user-credential:12345\n' in msg) 51 | ok_('auth-type:C\n' in msg) 52 | ok_('device-discovery-protocol-version:00020020\n' in msg) 53 | 54 | def test_get_ddp_launch_message(self): 55 | msg = pyps4.get_ddp_launch_message('12345') 56 | ok_(msg.startswith('LAUNCH * HTTP/1.1\n')) 57 | ok_('client-type:a\n' in msg) 58 | ok_('user-credential:12345\n' in msg) 59 | ok_('auth-type:C\n' in msg) 60 | ok_('device-discovery-protocol-version:00020020\n' in msg) 61 | -------------------------------------------------------------------------------- /tests/test_ps4.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | from nose.tools import eq_, ok_ 6 | from mock import MagicMock 7 | 8 | import pyps4 9 | 10 | CREDENTIALS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 11 | 'credentials.json') 12 | 13 | 14 | class TestPs4(object): 15 | 16 | def test_ps4(self): 17 | playstation = pyps4.Ps4('10.10.10.10') 18 | eq_(playstation._host, '10.10.10.10') 19 | eq_(playstation._broadcast, False) 20 | 21 | playstation = pyps4.Ps4(None, broadcast=True) 22 | eq_(playstation._host, None) 23 | eq_(playstation._broadcast, True) 24 | 25 | playstation = pyps4.Ps4('10.10.10.10', credential='abcdef') 26 | eq_(playstation._credential, 'abcdef') 27 | 28 | playstation = pyps4.Ps4('0.0.0.0', credentials_file=CREDENTIALS_FILE) 29 | eq_(playstation._credential, '1234567890') 30 | 31 | def test_open_credentials_file(self): 32 | creds = pyps4.open_credential_file(CREDENTIALS_FILE) 33 | 34 | ok_('client-type' in creds) 35 | ok_('auth-type' in creds) 36 | ok_('user-credential' in creds) 37 | 38 | def test_get_host_status(self): 39 | mock = MagicMock() 40 | mock.side_effect = [ 41 | {'status_code': 200}, 42 | {'status_code': 620}, 43 | ] 44 | 45 | playstation = pyps4.Ps4('10.10.10.10') 46 | playstation.get_status = mock 47 | eq_(playstation.get_host_status(), 200) 48 | eq_(playstation.get_host_status(), 620) 49 | 50 | def test_is_running(self): 51 | mock = MagicMock() 52 | mock.side_effect = [ 53 | {'status_code': 200}, 54 | {'status_code': 620}, 55 | {'status_code': 100}, 56 | ] 57 | 58 | playstation = pyps4.Ps4('10.10.10.10') 59 | playstation.get_status = mock 60 | eq_(playstation.is_running(), True) 61 | eq_(playstation.is_running(), False) 62 | eq_(playstation.is_running(), False) 63 | 64 | def test_is_standby(self): 65 | mock = MagicMock() 66 | mock.side_effect = [ 67 | {'status_code': 620}, 68 | {'status_code': 200}, 69 | {'status_code': 100}, 70 | ] 71 | 72 | playstation = pyps4.Ps4('10.10.10.10') 73 | playstation.get_status = mock 74 | eq_(playstation.is_standby(), True) 75 | eq_(playstation.is_standby(), False) 76 | eq_(playstation.is_standby(), False) 77 | 78 | def test_get_host_id(self): 79 | mock = MagicMock() 80 | mock.side_effect = [ 81 | {'host-id': 'test-A'}, 82 | {'host-name': 'test-B'}, 83 | {'running-app-titleid': 'test-C'}, 84 | {'running-app-name': 'test-D'}, 85 | {'system-version': 'test-E'}, 86 | ] 87 | 88 | playstation = pyps4.Ps4('10.10.10.10') 89 | playstation.get_status = mock 90 | 91 | eq_(playstation.get_host_id(), 'test-A') 92 | eq_(playstation.get_host_name(), 'test-B') 93 | eq_(playstation.get_running_app_titleid(), 'test-C') 94 | eq_(playstation.get_running_app_name(), 'test-D') 95 | eq_(playstation.get_system_version(), 'test-E') 96 | --------------------------------------------------------------------------------