Skip to content

Commit

Permalink
Improvements to SSDP discovery and associated tests (#58)
Browse files Browse the repository at this point in the history
*  update setup py
  - minor version
  - python3

* Python 3 compatibility changes (#57)

* Py3-compatibility changes

Don't require six to check version

* Version increment

* Move examples

* Adds tox.ini for multi-python testing

* Update travis.yaml

* Travis doesn't support Py3.7 yet.

See travis-ci/travis-ci#9815

* Improves SSDP discovery, adds tests

Discovery refactor

Discovery tests

Configure broadcast address for testing

Allow testing against live camera

Adds tox.ini for multi-python testing

Update travis.yaml

* Update README with testing info

* Relax 'six' minimum in test-requirements.txt

TravisCI's Py3.4 environment already has six 1.10 installed.
  • Loading branch information
bjmc authored and Bloodevil committed Jul 9, 2019
1 parent d93e00d commit 8dd8474
Show file tree
Hide file tree
Showing 22 changed files with 531 additions and 105 deletions.
9 changes: 5 additions & 4 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
language: python
python:
- "2.6"
- "2.7"
- "3.4"
- "3.5"
- "3.6"

install:
- pip install -r ./requirements.txt
- pip install -e .
- pip install -r ./test-requirements.txt

script: python src/tests.py
script: python -m unittest discover
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ using repo:
git clone https://github.com/Bloodevil/sony_camera_api.git
python setup.py install

Running tests
=============

You will likely want to set up a [virtualenv](https://virtualenv.pypa.io/en/stable/) first and complete the following steps inside it.

Install requirements:

pip install -r test-requirements.txt

Run tests:

python -m unittest discover

(The `run_tests.sh` script does both of these automatically)

By default, the test suite verifies behavior locally using dummy services.

If you want to run tests live against your real camera, connect to the camera's
wireless access point and set the `TEST_LIVE_CAMERA` environment variable.
For example:

TEST_LIVE_CAMERA=1 python -m unittest discover

**CAUTION:** Use with your camera at your own risk. This is free software that offers no warranty. For details, see LICENSE.


Usage
====

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import print_function

import pysony
import time
import fnmatch
Expand Down
4 changes: 3 additions & 1 deletion src/example/pyLiveView.py → examples/pyLiveView.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#
# Sample application to connect to camera, and start a video recording
# with or without a GUI LiveView screen
from __future__ import print_function

import signal
import threading
Expand All @@ -18,7 +19,8 @@

# Hack for windows
import platform
from cStringIO import StringIO

StringIO = six.StringIO

try:
import pygtk
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import print_function

from pysony import SonyAPI, ControlPoint
from struct import unpack
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import print_function

import pysony
import six

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import print_function

from pysony import SonyAPI, ControlPoint

import time
Expand Down
14 changes: 8 additions & 6 deletions src/example/timer_photo.py → examples/timer_photo.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from __future__ import print_function

from pysony import SonyAPI, payload_header
import urllib2
import thread
import time
import shutil
import os
from flask import Flask, url_for

from six.moves import urllib, _thread

app = Flask(__name__)
@app.route("/")
def view():
Expand All @@ -21,9 +23,9 @@ def liveview_and_save(timer=5):
try:
live = camera.startLiveview()
liveview_url = live['result'][0]
f = urllib2.urlopen(liveview_url)
except:
print live
f = urllib.request.urlopen(liveview_url)
except Exception:
print(live)
raise
if not os.path.exists("./static"):
os.makedirs("./static")
Expand All @@ -46,6 +48,6 @@ def liveview_and_save(timer=5):


if __name__ == "__main__":
thread.start_new_thread(liveview_and_save, ())
_thread.start_new_thread(liveview_and_save, ())
if app:
app.run()
4 changes: 4 additions & 0 deletions run_tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh

pip install -r test-requirements.txt
python -m unittest discover
10 changes: 7 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@

try:
README = open(os.path.join(here, 'README.md')).read()
except:
except IOError:
README = 'https://github.com/Bloodevil/sony_camera_api/blob/master/README.md'

version = '0.1.11'
version = '0.1.12'

install_requires = [
]

test_requirements = ['six>=1.10.0,<2']

setup(name='pysony',
version = version,
description = "Sony Camera Remote API for python",
Expand All @@ -28,13 +30,15 @@
install_requires=install_requires,
packages=find_packages('src'),
package_dir = {'': 'src'},
test_suite='tests',
tests_require=test_requirements,
py_modules=["pysony"],
keywords=['sony', 'camera', 'remote', 'api'],
classifiers=[
'License :: OSI Approved :: MIT License',
# topic
# environment ...
'Programming Language :: Python :: 2',
# add python 3
'Programming Language :: Python :: 3',
],
)
4 changes: 3 additions & 1 deletion src/api_generator.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import print_function

from api_list import exist_param, no_param


Expand All @@ -15,4 +17,4 @@ def gen():

return result

print gen()
print(gen())
138 changes: 64 additions & 74 deletions src/pysony.py
Original file line number Diff line number Diff line change
@@ -1,124 +1,114 @@
# Echo client program
import six
if six.PY3:
from queue import LifoQueue
from urllib.request import urlopen
else:
from Queue import LifoQueue
from urllib2 import urlopen
import json
import logging
import socket
import sys
import threading
import time
import re
import json
from collections import defaultdict
from struct import unpack, unpack_from
import logging
from xml.etree import ElementTree

if sys.version_info < (3, 0):
from Queue import LifoQueue
from urllib2 import urlopen
else:
from queue import LifoQueue
from urllib.request import urlopen

SSDP_ADDR = "239.255.255.250" # The remote host
SSDP_PORT = 1900 # The same port as used by the server
SSDP_MX = 1
SSDP_ST = "urn:schemas-sony-com:service:ScalarWebAPI:1"
SSDP_TIMEOUT = 10000 #msec
PACKET_BUFFER_SIZE = 1024

logger = logging.getLogger('pysony')


SSDP_MSG_TEMPLATE = '\r\n'.join((
'M-SEARCH * HTTP/1.1',
'HOST: 239.255.255.250:1900',
'MAN: "ssdp:discover"',
'MX: {mx_timeout}',
'ST: {}'.format(SSDP_ST),
'USER-AGENT: pysony',
'',
''
))

logger = logging.getLogger(__name__)
# Find all available cameras using uPNP
# Improved with code from 'https://github.com/storborg/sonypy' under MIT license.

class ControlPoint(object):
def __init__(self):
def __init__(self, addr=SSDP_ADDR, port=SSDP_PORT):
self.addr, self.port = addr, port
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.1)
# Set the socket to broadcast mode.
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL , 2)
self.__udp_socket = sock
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
self._udp_socket = sock

def close(self):
self._udp_socket.close()

def discover(self, duration=1):
msg = '\r\n'.join(["M-SEARCH * HTTP/1.1",
"HOST: 239.255.255.250:1900",
"MAN: \"ssdp:discover\"",
"MX: " + str(duration),
"ST: " + SSDP_ST,
"USER-AGENT: pysony",
"",
""])

# Send the message.
msg_bytes = bytearray(msg, 'utf8')
self.__udp_socket.sendto(msg_bytes, (SSDP_ADDR, SSDP_PORT))

# Get the responses.
# Send discovery message:
self._send_ssdp(duration)
# Listen for responses:
packets = self._listen_for_discover(duration)
endpoints = []
for host,addr,data in packets:
resp = self._parse_ssdp_response(data)
endpoint = self._read_device_definition(resp['location'])
for packet in packets:
resp = self._parse_ssdp_response(packet)
endpoint = self._read_device_definition(resp['location']).replace('/sony', '')
endpoints.append(endpoint)
return endpoints

def _send_ssdp(self, duration):
msg = SSDP_MSG_TEMPLATE.format(mx_timeout=duration)
self._udp_socket.sendto(msg.encode('utf-8'), (self.addr, self.port))

def _listen_for_discover(self, duration):
packets = defaultdict(lambda: b'') # {(host, port): data}
start = time.time()
packets = {} # {(host, port): data}
while (time.time() < (start + duration)):
try:
data, (host, port) = self.__udp_socket.recvfrom(1024)
data, (host, port) = self._udp_socket.recvfrom(PACKET_BUFFER_SIZE)
except socket.timeout:
break
packets.setdefault((host, port), b'')
packets[host, port] += data
return [(host, port, data) for (host, post), data in packets.items()]
pass
else:
packets[(host, port)] += data
return packets.values()

def _parse_ssdp_response(self, data):
data_str = data.decode('utf8')
lines = data_str.split('\r\n')
lines = data_str.strip().splitlines()
assert lines[0] == 'HTTP/1.1 200 OK'
headers = {}
for line in lines[1:]:
if line:
try:
key, val = line.split(': ', 1)
headers[key.lower()] = val
except:
logger.debug("Cannot parse SSDP response for this line: %s", line)
pass
for line in filter(None, lines[1:]):
try:
key, val = line.split(': ', 1)
headers[key.lower()] = val
except ValueError:
logger.debug("Cannot parse SSDP response for this line: %s", line)
return headers

def _parse_device_definition(self, doc):
"""
Parse the XML device definition file.
"""
dd_regex = ('<av:X_ScalarWebAPI_Service>'
'\s*'
'<av:X_ScalarWebAPI_ServiceType>'
'(.+?)'
'</av:X_ScalarWebAPI_ServiceType>'
'\s*'
'<av:X_ScalarWebAPI_ActionList_URL>'
'(.+?)'
'/sony' # and also strip '/sony'
'</av:X_ScalarWebAPI_ActionList_URL>'
'\s*'
'<av:X_ScalarWebAPI_AccessType\s*/>' # Note: QX10 has 'Type />', HX60 has 'Type/>'
'\s*'
'</av:X_ScalarWebAPI_Service>')

doc_str = doc.decode('utf8')
xml_tree = ElementTree.parse(doc)
namespace = {'av': 'urn:schemas-sony-com:av'}
services = {}
for m in re.findall(dd_regex, doc_str):
service_name = m[0]
endpoint = m[1]
services[service_name] = endpoint
for elm in xml_tree.findall('.//av:X_ScalarWebAPI_Service', namespace):
svc_type = elm.find('av:X_ScalarWebAPI_ServiceType', namespace).text
svc_loc = elm.find('av:X_ScalarWebAPI_ActionList_URL', namespace).text
services[svc_type] = svc_loc

return services

def _read_device_definition(self, url):
"""
Fetch and parse the device definition, and extract the URL endpoint for
the camera API service.
"""
r = urlopen(url)
services = self._parse_device_definition(r.read())
resp = urlopen(url)
services = self._parse_device_definition(resp)

return services['camera']

Expand Down Expand Up @@ -271,7 +261,7 @@ def _cmd(self, method=None, param=[], target=None, version='1.0', minversion='1.

if self.maxversion < minversion:
raise ValueError("Method %s with 'minversion' %s exceeds user supplied 'maxversion' %s", method, minversion, self.maxversion)

if version < minversion:
version = minversion
if version > self.maxversion:
Expand Down
16 changes: 0 additions & 16 deletions src/tests.py

This file was deleted.

2 changes: 2 additions & 0 deletions test-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.
six>=1.10.0,<2
Empty file added tests/__init__.py
Empty file.
Loading

0 comments on commit 8dd8474

Please sign in to comment.