summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRuben Undheim <ruben.undheim@gmail.com>2018-12-21 21:01:06 +0100
committerRuben Undheim <ruben.undheim@gmail.com>2018-12-21 21:01:06 +0100
commitd41ea366e7c2e51c9f7e68092d89e3f0be580362 (patch)
tree949fd6476faf99d225a5787356a48b54b8b15192
Import python-netdisco_2.2.0.orig.tar.gz
[dgit import orig python-netdisco_2.2.0.orig.tar.gz]
-rw-r--r--.github/release-drafter.yml4
-rw-r--r--.gitignore12
-rw-r--r--.travis.yml22
-rw-r--r--CLA.md39
-rw-r--r--CODE_OF_CONDUCT.md80
-rw-r--r--LICENSE.md194
-rw-r--r--MANIFEST.in3
-rw-r--r--README.md52
-rw-r--r--example_service.py32
-rw-r--r--netdisco/__init__.py1
-rw-r--r--netdisco/__main__.py35
-rw-r--r--netdisco/const.py46
-rw-r--r--netdisco/daikin.py102
-rw-r--r--netdisco/discoverables/__init__.py151
-rw-r--r--netdisco/discoverables/apple_tv.py16
-rw-r--r--netdisco/discoverables/arduino.py9
-rw-r--r--netdisco/discoverables/asus_router.py13
-rw-r--r--netdisco/discoverables/axis.py41
-rw-r--r--netdisco/discoverables/belkin_wemo.py19
-rw-r--r--netdisco/discoverables/bluesound.py10
-rw-r--r--netdisco/discoverables/bose_soundtouch.py10
-rw-r--r--netdisco/discoverables/cambridgeaudio.py13
-rw-r--r--netdisco/discoverables/canon_printer.py13
-rw-r--r--netdisco/discoverables/daikin.py14
-rw-r--r--netdisco/discoverables/deconz.py13
-rw-r--r--netdisco/discoverables/denonavr.py23
-rw-r--r--netdisco/discoverables/directv.py13
-rw-r--r--netdisco/discoverables/dlna_dmr.py13
-rw-r--r--netdisco/discoverables/dlna_dms.py13
-rw-r--r--netdisco/discoverables/freebox.py10
-rw-r--r--netdisco/discoverables/fritzbox.py10
-rw-r--r--netdisco/discoverables/frontier_silicon.py12
-rw-r--r--netdisco/discoverables/google_cast.py10
-rw-r--r--netdisco/discoverables/harmony.py13
-rw-r--r--netdisco/discoverables/hass_ios.py9
-rw-r--r--netdisco/discoverables/home_assistant.py9
-rw-r--r--netdisco/discoverables/homekit.py18
-rw-r--r--netdisco/discoverables/hp_printer.py13
-rw-r--r--netdisco/discoverables/huawei_router.py13
-rw-r--r--netdisco/discoverables/igd.py14
-rw-r--r--netdisco/discoverables/ikea_tradfri.py10
-rw-r--r--netdisco/discoverables/kodi.py13
-rw-r--r--netdisco/discoverables/konnected.py10
-rw-r--r--netdisco/discoverables/lg_smart_device.py10
-rw-r--r--netdisco/discoverables/logitech_mediaserver.py14
-rw-r--r--netdisco/discoverables/lutron.py11
-rw-r--r--netdisco/discoverables/nanoleaf_aurora.py9
-rw-r--r--netdisco/discoverables/netgear_router.py13
-rw-r--r--netdisco/discoverables/octoprint.py12
-rw-r--r--netdisco/discoverables/openhome.py10
-rw-r--r--netdisco/discoverables/panasonic_viera.py10
-rw-r--r--netdisco/discoverables/philips_hue.py14
-rw-r--r--netdisco/discoverables/plex_mediaserver.py21
-rw-r--r--netdisco/discoverables/roku.py10
-rw-r--r--netdisco/discoverables/sabnzbd.py13
-rw-r--r--netdisco/discoverables/samsung_printer.py13
-rw-r--r--netdisco/discoverables/samsung_tv.py25
-rw-r--r--netdisco/discoverables/songpal.py50
-rw-r--r--netdisco/discoverables/sonos.py10
-rw-r--r--netdisco/discoverables/spotify_connect.py10
-rw-r--r--netdisco/discoverables/tellstick.py14
-rw-r--r--netdisco/discoverables/tivo_dvr.py14
-rw-r--r--netdisco/discoverables/volumio.py10
-rw-r--r--netdisco/discoverables/webos_tv.py15
-rw-r--r--netdisco/discoverables/wink.py14
-rw-r--r--netdisco/discoverables/xbox_smartglass.py14
-rw-r--r--netdisco/discoverables/xiaomi_gw.py33
-rw-r--r--netdisco/discoverables/yamaha.py42
-rw-r--r--netdisco/discoverables/yeelight.py27
-rw-r--r--netdisco/discoverables/ziggo_mediabox_xl.py12
-rw-r--r--netdisco/discovery.py148
-rw-r--r--netdisco/gdm.py110
-rw-r--r--netdisco/lms.py78
-rw-r--r--netdisco/mdns.py45
-rw-r--r--netdisco/service.py88
-rw-r--r--netdisco/smartglass.py156
-rw-r--r--netdisco/ssdp.py290
-rw-r--r--netdisco/tellstick.py72
-rw-r--r--netdisco/util.py29
-rw-r--r--pylintrc2
-rw-r--r--requirements.txt2
-rwxr-xr-xscript/release16
-rw-r--r--setup.cfg6
-rw-r--r--setup.py13
-rw-r--r--tests/__init__.py1
-rw-r--r--tests/discoverables/test_yamaha.py126
-rw-r--r--tests/discoverables/yamaha_files/desc_RX-V481.xml92
-rw-r--r--tests/discoverables/yamaha_files/desc_incompatible_device.xml26
-rw-r--r--tests/discoverables/yamaha_files/desc_multiple_services_no_remote_control.xml25
-rw-r--r--tests/discoverables/yamaha_files/desc_multiple_services_remote_control_last.xml25
-rw-r--r--tests/discoverables/yamaha_files/desc_single_service.xml22
-rw-r--r--tests/test_xboxone.py60
-rw-r--r--tests/xboxone_files/discovery_responsebin0 -> 586 bytes
-rw-r--r--tox.ini20
94 files changed, 3072 insertions, 0 deletions
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
new file mode 100644
index 0000000..a4dce4c
--- /dev/null
+++ b/.github/release-drafter.yml
@@ -0,0 +1,4 @@
+template: |
+ ## What's Changed
+
+ $CHANGES
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..edc491c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+__pycache__
+**/*.pyc
+netdisco.egg-info/
+dist
+build
+pyvenv.cfg
+bin
+lib
+lib64
+pip-selfcheck.json
+.tox
+.pytest_cache/
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..ac9af0a
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,22 @@
+sudo: false
+matrix:
+ fast_finish: true
+ include:
+ - python: "3.4.2"
+ env: TOXENV=py34
+ - python: "3.4.2"
+ env: TOXENV=lint
+ - python: "3.5"
+ env: TOXENV=py35
+ - python: "3.6"
+ env: TOXENV=py36
+ - python: "3.7"
+ env: TOXENV=py37
+ sudo: true
+ dist: xenial
+cache:
+ directories:
+ - $HOME/.cache/pip
+install: pip install -U tox
+language: python
+script: tox
diff --git a/CLA.md b/CLA.md
new file mode 100644
index 0000000..f8570ce
--- /dev/null
+++ b/CLA.md
@@ -0,0 +1,39 @@
+# Contributor License Agreement
+
+```
+By making a contribution to this project, I certify that:
+
+(a) The contribution was created in whole or in part by me and I
+ have the right to submit it under the Apache 2.0 license; or
+
+(b) The contribution is based upon previous work that, to the best
+ of my knowledge, is covered under an appropriate open source
+ license and I have the right under that license to submit that
+ work with modifications, whether created in whole or in part
+ by me, under the Apache 2.0 license; or
+
+(c) The contribution was provided directly to me by some other
+ person who certified (a), (b) or (c) and I have not modified
+ it.
+
+(d) I understand and agree that this project and the contribution
+ are public and that a record of the contribution (including all
+ personal information I submit with it) is maintained indefinitely
+ and may be redistributed consistent with this project or the open
+ source license(s) involved.
+```
+
+## Attribution
+
+The text of this license is available under the [Creative Commons Attribution-ShareAlike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/). It is based on the Linux [Developer Certificate Of Origin](http://elinux.org/Developer_Certificate_Of_Origin), but is modified to explicitly use the Apache 2.0 license
+and not mention sign-off.
+
+## Signing
+
+To sign this CLA you must first submit a pull request to a repository under the Home Assistant organization.
+
+## Adoption
+
+This Contributor License Agreement (CLA) was first announced on January 21st, 2017 in [this][cla-blog] blog post and adopted January 28th, 2017.
+
+[cla-blog]: https://home-assistant.io/blog/2017/01/21/home-assistant-governance/
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..5d2149d
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,80 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, gender identity and expression, level of experience,
+nationality, personal appearance, race, religion, or sexual identity and
+orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at [safety@home-assistant.io][email]. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available [here][version].
+
+## Adoption
+
+This Code of Conduct was first adopted January 21st, 2017 and announced in [this][coc-blog] blog post.
+
+[homepage]: http://contributor-covenant.org
+[version]: http://contributor-covenant.org/version/1/4/
+[email]: mailto:safety@home-assistant.io
+[coc-blog]: https://home-assistant.io/blog/2017/01/21/home-assistant-governance/
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..b62a9b5
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,194 @@
+Apache License
+==============
+
+_Version 2.0, January 2004_
+_&lt;<http://www.apache.org/licenses/>&gt;_
+
+### Terms and Conditions for use, reproduction, and distribution
+
+#### 1. Definitions
+
+“License” shall mean the terms and conditions for use, reproduction, and
+distribution as defined by Sections 1 through 9 of this document.
+
+“Licensor” shall mean the copyright owner or entity authorized by the copyright
+owner that is granting the License.
+
+“Legal Entity” shall mean the union of the acting entity and all other entities
+that control, are controlled by, or are under common control with that entity.
+For the purposes of this definition, “control” means **(i)** the power, direct or
+indirect, to cause the direction or management of such entity, whether by
+contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
+outstanding shares, or **(iii)** beneficial ownership of such entity.
+
+“You” (or “Your”) shall mean an individual or Legal Entity exercising
+permissions granted by this License.
+
+“Source” form shall mean the preferred form for making modifications, including
+but not limited to software source code, documentation source, and configuration
+files.
+
+“Object” form shall mean any form resulting from mechanical transformation or
+translation of a Source form, including but not limited to compiled object code,
+generated documentation, and conversions to other media types.
+
+“Work” shall mean the work of authorship, whether in Source or Object form, made
+available under the License, as indicated by a copyright notice that is included
+in or attached to the work (an example is provided in the Appendix below).
+
+“Derivative Works” shall mean any work, whether in Source or Object form, that
+is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative Works
+shall not include works that remain separable from, or merely link (or bind by
+name) to the interfaces of, the Work and Derivative Works thereof.
+
+“Contribution” shall mean any work of authorship, including the original version
+of the Work and any modifications or additions to that Work or Derivative Works
+thereof, that is intentionally submitted to Licensor for inclusion in the Work
+by the copyright owner or by an individual or Legal Entity authorized to submit
+on behalf of the copyright owner. For the purposes of this definition,
+“submitted” means any form of electronic, verbal, or written communication sent
+to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems, and
+issue tracking systems that are managed by, or on behalf of, the Licensor for
+the purpose of discussing and improving the Work, but excluding communication
+that is conspicuously marked or otherwise designated in writing by the copyright
+owner as “Not a Contribution.”
+
+“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently
+incorporated within the Work.
+
+#### 2. Grant of Copyright License
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable copyright license to reproduce, prepare Derivative Works of,
+publicly display, publicly perform, sublicense, and distribute the Work and such
+Derivative Works in Source or Object form.
+
+#### 3. Grant of Patent License
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable (except as stated in this section) patent license to make, have
+made, use, offer to sell, sell, import, and otherwise transfer the Work, where
+such license applies only to those patent claims licensable by such Contributor
+that are necessarily infringed by their Contribution(s) alone or by combination
+of their Contribution(s) with the Work to which such Contribution(s) was
+submitted. If You institute patent litigation against any entity (including a
+cross-claim or counterclaim in a lawsuit) alleging that the Work or a
+Contribution incorporated within the Work constitutes direct or contributory
+patent infringement, then any patent licenses granted to You under this License
+for that Work shall terminate as of the date such litigation is filed.
+
+#### 4. Redistribution
+
+You may reproduce and distribute copies of the Work or Derivative Works thereof
+in any medium, with or without modifications, and in Source or Object form,
+provided that You meet the following conditions:
+
+* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
+this License; and
+* **(b)** You must cause any modified files to carry prominent notices stating that You
+changed the files; and
+* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
+all copyright, patent, trademark, and attribution notices from the Source form
+of the Work, excluding those notices that do not pertain to any part of the
+Derivative Works; and
+* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
+Derivative Works that You distribute must include a readable copy of the
+attribution notices contained within such NOTICE file, excluding those notices
+that do not pertain to any part of the Derivative Works, in at least one of the
+following places: within a NOTICE text file distributed as part of the
+Derivative Works; within the Source form or documentation, if provided along
+with the Derivative Works; or, within a display generated by the Derivative
+Works, if and wherever such third-party notices normally appear. The contents of
+the NOTICE file are for informational purposes only and do not modify the
+License. You may add Your own attribution notices within Derivative Works that
+You distribute, alongside or as an addendum to the NOTICE text from the Work,
+provided that such additional attribution notices cannot be construed as
+modifying the License.
+
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction, or
+distribution of Your modifications, or for any such Derivative Works as a whole,
+provided Your use, reproduction, and distribution of the Work otherwise complies
+with the conditions stated in this License.
+
+#### 5. Submission of Contributions
+
+Unless You explicitly state otherwise, any Contribution intentionally submitted
+for inclusion in the Work by You to the Licensor shall be under the terms and
+conditions of this License, without any additional terms or conditions.
+Notwithstanding the above, nothing herein shall supersede or modify the terms of
+any separate license agreement you may have executed with Licensor regarding
+such Contributions.
+
+#### 6. Trademarks
+
+This License does not grant permission to use the trade names, trademarks,
+service marks, or product names of the Licensor, except as required for
+reasonable and customary use in describing the origin of the Work and
+reproducing the content of the NOTICE file.
+
+#### 7. Disclaimer of Warranty
+
+Unless required by applicable law or agreed to in writing, Licensor provides the
+Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
+including, without limitation, any warranties or conditions of TITLE,
+NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
+solely responsible for determining the appropriateness of using or
+redistributing the Work and assume any risks associated with Your exercise of
+permissions under this License.
+
+#### 8. Limitation of Liability
+
+In no event and under no legal theory, whether in tort (including negligence),
+contract, or otherwise, unless required by applicable law (such as deliberate
+and grossly negligent acts) or agreed to in writing, shall any Contributor be
+liable to You for damages, including any direct, indirect, special, incidental,
+or consequential damages of any character arising as a result of this License or
+out of the use or inability to use the Work (including but not limited to
+damages for loss of goodwill, work stoppage, computer failure or malfunction, or
+any and all other commercial damages or losses), even if such Contributor has
+been advised of the possibility of such damages.
+
+#### 9. Accepting Warranty or Additional Liability
+
+While redistributing the Work or Derivative Works thereof, You may choose to
+offer, and charge a fee for, acceptance of support, warranty, indemnity, or
+other liability obligations and/or rights consistent with this License. However,
+in accepting such obligations, You may act only on Your own behalf and on Your
+sole responsibility, not on behalf of any other Contributor, and only if You
+agree to indemnify, defend, and hold each Contributor harmless for any liability
+incurred by, or claims asserted against, such Contributor by reason of your
+accepting any such warranty or additional liability.
+
+_END OF TERMS AND CONDITIONS_
+
+### APPENDIX: How to apply the Apache License to your work
+
+To apply the Apache License to your work, attach the following boilerplate
+notice, with the fields enclosed by brackets `[]` replaced with your own
+identifying information. (Don't include the brackets!) The text should be
+enclosed in the appropriate comment syntax for the file format. We also
+recommend that a file or class name and description of purpose be included on
+the same “printed page” as the copyright notice for easier identification within
+third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..c74765d
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include README.md
+include LICENSE.md
+graft tests
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..264c440
--- /dev/null
+++ b/README.md
@@ -0,0 +1,52 @@
+# NetDisco
+
+NetDisco is a Python 3 library to discover local devices and services. It allows to scan on demand or offer a service that will scan the network in the background in a set interval.
+
+Current methods of scanning:
+
+ - mDNS (includes Chromecast, Homekit)
+ - uPnP
+ - Plex Media Server using Good Day Mate protocol
+ - Logitech Media Server discovery protocol
+ - Daikin discovery protocol
+ - Web OS discovery protocol
+
+It is the library that powers the device discovery within [Home Assistant](https://home-assistant.io/).
+
+## Installation
+
+Netdisco is available on PyPi. Install using `pip3 install netdisco`.
+
+## Example
+
+From command-line:
+
+```bash
+python3 -m netdisco
+# To see all raw data:
+python3 -m netdisco dump
+```
+
+In your script:
+
+```python
+from netdisco.discovery import NetworkDiscovery
+
+netdis = NetworkDiscovery()
+
+netdis.scan()
+
+for dev in netdis.discover():
+ print(dev, netdis.get_info(dev))
+
+netdis.stop()
+```
+
+Will result in a list of discovered devices and their most important information:
+
+```
+DLNA ['http://192.168.1.1:8200/rootDesc.xml', 'http://192.168.1.150:32469/DeviceDescription.xml']
+google_cast [('Living Room.local.', 8009)]
+philips_hue ['http://192.168.1.2:80/description.xml']
+belkin_wemo ['http://192.168.1.10:49153/setup.xml']
+```
diff --git a/example_service.py b/example_service.py
new file mode 100644
index 0000000..149a271
--- /dev/null
+++ b/example_service.py
@@ -0,0 +1,32 @@
+"""
+Example use of DiscoveryService.
+
+Will scan every 10 seconds and print out new found entries.
+Will quit after 2 minutes.
+
+"""
+import logging
+from datetime import datetime
+import time
+
+from netdisco.service import DiscoveryService
+
+logging.basicConfig(level=logging.INFO)
+
+# Scan every 10 seconds
+nd = DiscoveryService(10)
+
+
+def new_service_listener(discoverable, service):
+ """ Print out a new service found message. """
+ print("{} - Found new service: {} {}".format(
+ datetime.now(), discoverable, service))
+
+
+nd.add_listener(new_service_listener)
+
+nd.start()
+
+time.sleep(120)
+
+nd.stop()
diff --git a/netdisco/__init__.py b/netdisco/__init__.py
new file mode 100644
index 0000000..7b4bb4c
--- /dev/null
+++ b/netdisco/__init__.py
@@ -0,0 +1 @@
+"""Module to scan the network using uPnP and mDNS for devices and services."""
diff --git a/netdisco/__main__.py b/netdisco/__main__.py
new file mode 100644
index 0000000..64037f0
--- /dev/null
+++ b/netdisco/__main__.py
@@ -0,0 +1,35 @@
+"""Command line tool to print discocvered devices or dump raw data."""
+from pprint import pprint
+import sys
+
+from netdisco.discovery import NetworkDiscovery
+
+
+def main():
+ """Handle command line execution."""
+ netdisco = NetworkDiscovery()
+
+ netdisco.scan()
+
+ print("Discovered devices:")
+ count = 0
+ for dev in netdisco.discover():
+ count += 1
+ print('{}:'.format(dev))
+ pprint(netdisco.get_info(dev))
+ print()
+ print("Discovered {} devices".format(count))
+
+ # Pass in command line argument dump to get the raw data
+ if sys.argv[-1] == 'dump':
+ print()
+ print()
+ print("Raw Data")
+ print()
+ netdisco.print_raw_data()
+
+ netdisco.stop()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/netdisco/const.py b/netdisco/const.py
new file mode 100644
index 0000000..e363c5e
--- /dev/null
+++ b/netdisco/const.py
@@ -0,0 +1,46 @@
+"""Constants of services that can be discovered."""
+
+BELKIN_WEMO = "belkin_wemo"
+DLNA_DMS = "DLNA_DMS"
+DLNA_DMR = "DLNA_DMR"
+GOOGLE_CAST = "google_cast"
+PHILIPS_HUE = "philips_hue"
+PMS = 'plex_mediaserver'
+LMS = 'logitech_mediaserver'
+ASUS_ROUTER = "asus_router"
+HUAWEI_ROUTER = "huawei_router"
+NETGEAR_ROUTER = "netgear_router"
+SONOS = "sonos"
+PANASONIC_VIERA = "panasonic_viera"
+SABNZBD = 'sabnzbd'
+KODI = 'kodi'
+HOME_ASSISTANT = "home_assistant"
+MYSTROM = 'mystrom'
+HASS_IOS = "hass_ios"
+BOSE_SOUNDTOUCH = 'bose_soundtouch'
+SAMSUNG_TV = "samsung_tv"
+FRONTIER_SILICON = "frontier_silicon"
+APPLE_TV = "apple_tv"
+HARMONY = "harmony"
+BLUESOUND = "bluesound"
+ZIGGO_MEDIABOX_XL = "ziggo_mediabox_xl"
+DECONZ = "deconz"
+TIVO_DVR = "tivo_dvr"
+FREEBOX = "freebox"
+XBOX_SMARTGLASS = "xbox_smartglass"
+
+ATTR_NAME = 'name'
+ATTR_HOST = 'host'
+ATTR_PORT = 'port'
+ATTR_HOSTNAME = 'hostname'
+ATTR_URLBASE = 'urlbase'
+ATTR_DEVICE_TYPE = 'device_type'
+ATTR_MODEL_NAME = 'model_name'
+ATTR_MODEL_NUMBER = 'model_number'
+ATTR_MANUFACTURER = 'manufacturer'
+ATTR_UDN = 'udn'
+ATTR_PROPERTIES = 'properties'
+ATTR_SSDP_DESCRIPTION = 'ssdp_description'
+ATTR_UPNP_DEVICE_TYPE = 'upnp_device_type'
+ATTR_SERIAL = 'serial'
+ATTR_MAC_ADDRESS = 'mac_address'
diff --git a/netdisco/daikin.py b/netdisco/daikin.py
new file mode 100644
index 0000000..a8e6e8f
--- /dev/null
+++ b/netdisco/daikin.py
@@ -0,0 +1,102 @@
+"""Daikin device discovery."""
+import socket
+
+from datetime import timedelta
+from urllib.parse import unquote
+
+DISCOVERY_MSG = b"DAIKIN_UDP/common/basic_info"
+
+UDP_SRC_PORT = 30000
+UDP_DST_PORT = 30050
+
+DISCOVERY_ADDRESS = '<broadcast>'
+DISCOVERY_TIMEOUT = timedelta(seconds=2)
+
+
+class Daikin:
+ """Base class to discover Daikin devices."""
+
+ def __init__(self):
+ """Initialize the Daikin discovery."""
+ self.entries = []
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Scan and return all found entries."""
+ self.scan()
+ return self.entries
+
+ def update(self):
+ """Scan network for Daikin devices."""
+ entries = []
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.settimeout(DISCOVERY_TIMEOUT.seconds)
+ sock.bind(("", UDP_SRC_PORT))
+
+ try:
+
+ sock.sendto(DISCOVERY_MSG, (DISCOVERY_ADDRESS, UDP_DST_PORT))
+
+ while True:
+ try:
+ data, (address, _) = sock.recvfrom(1024)
+
+ # pylint: disable=consider-using-dict-comprehension
+ entry = dict([e.split('=')
+ for e in data.decode("UTF-8").split(',')])
+
+ # expecting product, mac, activation code, version
+ if 'ret' not in entry or entry['ret'] != 'OK':
+ # non-OK return on response
+ continue
+
+ if 'mac' not in entry:
+ # no mac found for device"
+ continue
+
+ if 'type' not in entry or entry['type'] != 'aircon':
+ # no mac found for device"
+ continue
+
+ if 'name' in entry:
+ entry['name'] = unquote(entry['name'])
+
+ # in case the device was not configured to have an id
+ # then use the mac address
+ if 'id' in entry and entry['id'] == '':
+ entry['id'] = entry['mac']
+
+ entries.append({
+ 'id': entry['id'],
+ 'name': entry['name'],
+ 'ip': address,
+ 'mac': entry['mac'],
+ 'ver': entry['ver'],
+ })
+
+ except socket.timeout:
+ break
+
+ finally:
+ sock.close()
+
+ self.entries = entries
+
+
+def main():
+ """Test Daikin discovery."""
+ from pprint import pprint
+ daikin = Daikin()
+ pprint("Scanning for Daikin devices..")
+ daikin.update()
+ pprint(daikin.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/discoverables/__init__.py b/netdisco/discoverables/__init__.py
new file mode 100644
index 0000000..dd39785
--- /dev/null
+++ b/netdisco/discoverables/__init__.py
@@ -0,0 +1,151 @@
+"""Provides helpful stuff for discoverables."""
+# pylint: disable=abstract-method
+import ipaddress
+from urllib.parse import urlparse
+
+from ..const import (
+ ATTR_NAME, ATTR_MODEL_NAME, ATTR_HOST, ATTR_PORT, ATTR_SSDP_DESCRIPTION,
+ ATTR_SERIAL, ATTR_MODEL_NUMBER, ATTR_HOSTNAME, ATTR_MAC_ADDRESS,
+ ATTR_PROPERTIES, ATTR_MANUFACTURER, ATTR_UDN, ATTR_UPNP_DEVICE_TYPE)
+
+
+class BaseDiscoverable:
+ """Base class for discoverable services or device types."""
+
+ def is_discovered(self):
+ """Return True if it is discovered."""
+ return len(self.get_entries()) > 0
+
+ def get_info(self):
+ """Return a list with the important info for each item.
+
+ Uses self.info_from_entry internally.
+ """
+ return [self.info_from_entry(entry) for entry in self.get_entries()]
+
+ # pylint: disable=no-self-use
+ def info_from_entry(self, entry):
+ """Return an object with important info from the entry."""
+ return entry
+
+ def get_entries(self):
+ """Return all the discovered entries."""
+ raise NotImplementedError()
+
+
+class SSDPDiscoverable(BaseDiscoverable):
+ """uPnP discoverable base class."""
+
+ def __init__(self, netdis):
+ """Initialize SSDPDiscoverable."""
+ self.netdis = netdis
+
+ def info_from_entry(self, entry):
+ """Get most important info."""
+ url = urlparse(entry.location)
+ info = {
+ ATTR_HOST: url.hostname,
+ ATTR_PORT: url.port,
+ ATTR_SSDP_DESCRIPTION: entry.location
+ }
+ device = entry.description.get('device')
+
+ if device:
+ info[ATTR_NAME] = device.get('friendlyName')
+ info[ATTR_MODEL_NAME] = device.get('modelName')
+ info[ATTR_MODEL_NUMBER] = device.get('modelNumber')
+ info[ATTR_SERIAL] = device.get('serialNumber')
+ info[ATTR_MANUFACTURER] = device.get('manufacturer')
+ info[ATTR_UDN] = device.get('UDN')
+ info[ATTR_UPNP_DEVICE_TYPE] = device.get('deviceType')
+
+ return info
+
+ # Helper functions
+
+ # pylint: disable=invalid-name
+ def find_by_st(self, st):
+ """Find entries by ST (the device identifier)."""
+ return self.netdis.ssdp.find_by_st(st)
+
+ def find_by_device_description(self, values):
+ """Find entries based on values from their description."""
+ return self.netdis.ssdp.find_by_device_description(values)
+
+
+class MDNSDiscoverable(BaseDiscoverable):
+ """mDNS Discoverable base class."""
+
+ def __init__(self, netdis, typ):
+ """Initialize MDNSDiscoverable."""
+ self.netdis = netdis
+ self.typ = typ
+ self.services = {}
+
+ netdis.mdns.register_service(self)
+
+ def reset(self):
+ """Reset found services."""
+ self.services.clear()
+
+ # pylint: disable=unused-argument
+ def remove_service(self, zconf, typ, name):
+ """Callback when a service is removed."""
+ self.services.pop(name, None)
+
+ def add_service(self, zconf, typ, name):
+ """Callback when a service is found."""
+ service = None
+ tries = 0
+ while service is None and tries < 3:
+ service = zconf.get_service_info(typ, name)
+ tries += 1
+
+ if service is not None:
+ self.services[name] = service
+
+ def get_entries(self):
+ """Return all found services."""
+ return self.services.values()
+
+ def info_from_entry(self, entry):
+ """Return most important info from mDNS entries."""
+ properties = {}
+
+ for key, value in entry.properties.items():
+ if isinstance(value, bytes):
+ value = value.decode('utf-8')
+ properties[key.decode('utf-8')] = value
+
+ info = {
+ ATTR_HOST: str(ipaddress.ip_address(entry.address)),
+ ATTR_PORT: entry.port,
+ ATTR_HOSTNAME: entry.server,
+ ATTR_PROPERTIES: properties,
+ }
+
+ if "mac" in properties:
+ info[ATTR_MAC_ADDRESS] = properties["mac"]
+
+ return info
+
+ def find_by_device_name(self, name):
+ """Find entries based on the beginning of their entry names."""
+ return [entry for entry in self.services.values()
+ if entry.name.startswith(name)]
+
+
+class GDMDiscoverable(BaseDiscoverable):
+ """GDM discoverable base class."""
+
+ def __init__(self, netdis):
+ """Initialize GDMDiscoverable."""
+ self.netdis = netdis
+
+ def find_by_content_type(self, value):
+ """Find entries based on values from their content_type."""
+ return self.netdis.gdm.find_by_content_type(value)
+
+ def find_by_data(self, values):
+ """Find entries based on values from any returned field."""
+ return self.netdis.gdm.find_by_data(values)
diff --git a/netdisco/discoverables/apple_tv.py b/netdisco/discoverables/apple_tv.py
new file mode 100644
index 0000000..02cb642
--- /dev/null
+++ b/netdisco/discoverables/apple_tv.py
@@ -0,0 +1,16 @@
+"""Discover Apple TV media players."""
+from . import MDNSDiscoverable
+from ..const import ATTR_NAME, ATTR_PROPERTIES
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for Apple TV devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_appletv-v2._tcp.local.')
+
+ def info_from_entry(self, entry):
+ """Returns most important info from mDNS entries."""
+ info = super().info_from_entry(entry)
+ info[ATTR_NAME] = info[ATTR_PROPERTIES]['Name'].replace('\xa0', ' ')
+ return info
diff --git a/netdisco/discoverables/arduino.py b/netdisco/discoverables/arduino.py
new file mode 100644
index 0000000..013b6cc
--- /dev/null
+++ b/netdisco/discoverables/arduino.py
@@ -0,0 +1,9 @@
+"""Discover Arduino devices."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Arduino devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_arduino._tcp.local.')
diff --git a/netdisco/discoverables/asus_router.py b/netdisco/discoverables/asus_router.py
new file mode 100644
index 0000000..20de3a8
--- /dev/null
+++ b/netdisco/discoverables/asus_router.py
@@ -0,0 +1,13 @@
+"""Discover ASUS routers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering ASUS routers."""
+
+ def get_entries(self):
+ """Get all the ASUS uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "ASUSTeK Computer Inc.",
+ "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
+ })
diff --git a/netdisco/discoverables/axis.py b/netdisco/discoverables/axis.py
new file mode 100644
index 0000000..c4278b1
--- /dev/null
+++ b/netdisco/discoverables/axis.py
@@ -0,0 +1,41 @@
+"""Discover Axis devices."""
+from . import MDNSDiscoverable
+
+from ..const import (
+ ATTR_HOST, ATTR_PORT, ATTR_HOSTNAME, ATTR_PROPERTIES)
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Axis devices."""
+
+ def info_from_entry(self, entry):
+ """Return most important info from mDNS entries."""
+ properties = {}
+
+ for key, value in entry.properties.items():
+ if isinstance(value, bytes):
+ value = value.decode('utf-8')
+ properties[key.decode('utf-8')] = value
+
+ return {
+ ATTR_HOST: self.ip_from_host(entry.server),
+ ATTR_PORT: entry.port,
+ ATTR_HOSTNAME: entry.server,
+ ATTR_PROPERTIES: properties,
+ }
+
+ def __init__(self, nd):
+ """Initialize the Axis discovery."""
+ super(Discoverable, self).__init__(nd, '_axis-video._tcp.local.')
+
+ def ip_from_host(self, host):
+ """Attempt to return the ip address from an mDNS host.
+
+ Return host if failed.
+ """
+ ips = self.netdis.mdns.zeroconf.cache.entries_with_name(host.lower())
+
+ try:
+ return repr(ips[0]) if ips else host
+ except TypeError:
+ return host
diff --git a/netdisco/discoverables/belkin_wemo.py b/netdisco/discoverables/belkin_wemo.py
new file mode 100644
index 0000000..8f3a26e
--- /dev/null
+++ b/netdisco/discoverables/belkin_wemo.py
@@ -0,0 +1,19 @@
+"""Discover Belkin Wemo devices."""
+from . import SSDPDiscoverable
+from ..const import ATTR_MAC_ADDRESS
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Belkin WeMo platform devices."""
+
+ def info_from_entry(self, entry):
+ """Return most important info from a uPnP entry."""
+ info = super().info_from_entry(entry)
+ device = entry.description['device']
+ info[ATTR_MAC_ADDRESS] = device.get('macAddress', '')
+ return info
+
+ def get_entries(self):
+ """Return all Belkin Wemo entries."""
+ return self.find_by_device_description(
+ {'manufacturer': 'Belkin International Inc.'})
diff --git a/netdisco/discoverables/bluesound.py b/netdisco/discoverables/bluesound.py
new file mode 100644
index 0000000..4603d83
--- /dev/null
+++ b/netdisco/discoverables/bluesound.py
@@ -0,0 +1,10 @@
+"""Discover devices that implement the Bluesound platform."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Bluesound service."""
+
+ def __init__(self, nd):
+ """Initialize the Bluesound discovery."""
+ super(Discoverable, self).__init__(nd, '_musc._tcp.local.')
diff --git a/netdisco/discoverables/bose_soundtouch.py b/netdisco/discoverables/bose_soundtouch.py
new file mode 100644
index 0000000..e9f819a
--- /dev/null
+++ b/netdisco/discoverables/bose_soundtouch.py
@@ -0,0 +1,10 @@
+"""Discover Bose SoundTouch devices."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Bose SoundTouch devices."""
+
+ def __init__(self, nd):
+ """Initialize the Bose SoundTouch discovery."""
+ super(Discoverable, self).__init__(nd, '_soundtouch._tcp.local.')
diff --git a/netdisco/discoverables/cambridgeaudio.py b/netdisco/discoverables/cambridgeaudio.py
new file mode 100644
index 0000000..3538e48
--- /dev/null
+++ b/netdisco/discoverables/cambridgeaudio.py
@@ -0,0 +1,13 @@
+""" Discover Cambridge Audio StreamMagic devices. """
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Cambridge Audio StreamMagic devices."""
+
+ def get_entries(self):
+ """Get all Cambridge Audio MediaRenderer uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Cambridge Audio",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1"
+ })
diff --git a/netdisco/discoverables/canon_printer.py b/netdisco/discoverables/canon_printer.py
new file mode 100644
index 0000000..b005f61
--- /dev/null
+++ b/netdisco/discoverables/canon_printer.py
@@ -0,0 +1,13 @@
+"""Discover Canon Printers"""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Support for the discovery of Canon Printers"""
+
+ def get_entries(self):
+ """Get all the Canon Printer uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "CANON INC.",
+ "deviceType": "urn:schemas-cipa-jp:device:DPSPrinter:1"
+ })
diff --git a/netdisco/discoverables/daikin.py b/netdisco/discoverables/daikin.py
new file mode 100644
index 0000000..b260fd4
--- /dev/null
+++ b/netdisco/discoverables/daikin.py
@@ -0,0 +1,14 @@
+"""Discover Daikin devices."""
+from . import BaseDiscoverable
+
+
+class Discoverable(BaseDiscoverable):
+ """Add support for discovering a Daikin device."""
+
+ def __init__(self, netdis):
+ """Initialize the Daikin discovery."""
+ self._netdis = netdis
+
+ def get_entries(self):
+ """Get all the Daikin details."""
+ return self._netdis.daikin.entries
diff --git a/netdisco/discoverables/deconz.py b/netdisco/discoverables/deconz.py
new file mode 100644
index 0000000..0863cda
--- /dev/null
+++ b/netdisco/discoverables/deconz.py
@@ -0,0 +1,13 @@
+"""Discover deCONZ gateways."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering deCONZ Wireless Light Control gateways."""
+
+ def get_entries(self):
+ """Get all the deCONZ uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturerURL": "http://www.dresden-elektronik.de",
+ "modelDescription": "dresden elektronik Wireless Light Control"
+ })
diff --git a/netdisco/discoverables/denonavr.py b/netdisco/discoverables/denonavr.py
new file mode 100644
index 0000000..d830aa6
--- /dev/null
+++ b/netdisco/discoverables/denonavr.py
@@ -0,0 +1,23 @@
+"""Discover Denon AVR devices."""
+from urllib.parse import urlparse
+
+from . import SSDPDiscoverable
+from ..const import ATTR_HOST
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Denon AVR devices."""
+
+ def get_entries(self):
+ """Get all Denon AVR uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Denon",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1"
+ })
+
+ def info_from_entry(self, entry):
+ """Get most important info, which is name, model and host."""
+ info = super().info_from_entry(entry)
+ info[ATTR_HOST] = urlparse(
+ entry.description['device']['presentationURL']).hostname
+ return info
diff --git a/netdisco/discoverables/directv.py b/netdisco/discoverables/directv.py
new file mode 100644
index 0000000..7babd9c
--- /dev/null
+++ b/netdisco/discoverables/directv.py
@@ -0,0 +1,13 @@
+"""Discover DirecTV Receivers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering DirecTV Receivers."""
+
+ def get_entries(self):
+ """Get all the DirecTV uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "DIRECTV",
+ "deviceType": "urn:schemas-upnp-org:device:MediaServer:1"
+ })
diff --git a/netdisco/discoverables/dlna_dmr.py b/netdisco/discoverables/dlna_dmr.py
new file mode 100644
index 0000000..bbc7fe6
--- /dev/null
+++ b/netdisco/discoverables/dlna_dmr.py
@@ -0,0 +1,13 @@
+"""Discover DLNA services."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering DLNA services."""
+
+ def get_entries(self):
+ """Get all the DLNA service uPnP entries."""
+ return \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaRenderer:1") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaRenderer:2") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaRenderer:3")
diff --git a/netdisco/discoverables/dlna_dms.py b/netdisco/discoverables/dlna_dms.py
new file mode 100644
index 0000000..eac38ec
--- /dev/null
+++ b/netdisco/discoverables/dlna_dms.py
@@ -0,0 +1,13 @@
+"""Discover DLNA services."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering DLNA services."""
+
+ def get_entries(self):
+ """Get all the DLNA service uPnP entries."""
+ return self.find_by_st("urn:schemas-upnp-org:device:MediaServer:1") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaServer:2") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaServer:3") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaServer:4")
diff --git a/netdisco/discoverables/freebox.py b/netdisco/discoverables/freebox.py
new file mode 100644
index 0000000..11bc5f8
--- /dev/null
+++ b/netdisco/discoverables/freebox.py
@@ -0,0 +1,10 @@
+"""Discover Freebox routers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Freebox routers."""
+
+ def __init__(self, nd):
+ """Initialize the Freebox discovery."""
+ super(Discoverable, self).__init__(nd, '_fbx-api._tcp.local.')
diff --git a/netdisco/discoverables/fritzbox.py b/netdisco/discoverables/fritzbox.py
new file mode 100644
index 0000000..4b729f5
--- /dev/null
+++ b/netdisco/discoverables/fritzbox.py
@@ -0,0 +1,10 @@
+"""Discover AVM FRITZ devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering AVM FRITZ devices."""
+
+ def get_entries(self):
+ """Get all AVM FRITZ entries."""
+ return self.find_by_st("urn:schemas-upnp-org:device:fritzbox:1")
diff --git a/netdisco/discoverables/frontier_silicon.py b/netdisco/discoverables/frontier_silicon.py
new file mode 100644
index 0000000..6cb5fe5
--- /dev/null
+++ b/netdisco/discoverables/frontier_silicon.py
@@ -0,0 +1,12 @@
+"""Discover frontier silicon devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering frontier silicon devices."""
+
+ def get_entries(self):
+ """Get all the frontier silicon uPnP entries."""
+ return [entry for entry in self.netdis.ssdp.all()
+ if entry.st and 'fsapi' in entry.st and
+ 'urn:schemas-frontier-silicon-com' in entry.st]
diff --git a/netdisco/discoverables/google_cast.py b/netdisco/discoverables/google_cast.py
new file mode 100644
index 0000000..fb7d373
--- /dev/null
+++ b/netdisco/discoverables/google_cast.py
@@ -0,0 +1,10 @@
+"""Discover devices that implement the Google Cast platform."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Google Cast platform devices."""
+
+ def __init__(self, nd):
+ """Initialize the Cast discovery."""
+ super(Discoverable, self).__init__(nd, '_googlecast._tcp.local.')
diff --git a/netdisco/discoverables/harmony.py b/netdisco/discoverables/harmony.py
new file mode 100644
index 0000000..19d97eb
--- /dev/null
+++ b/netdisco/discoverables/harmony.py
@@ -0,0 +1,13 @@
+"""Discover Harmony Hub remotes."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Harmony Hub remotes"""
+
+ def get_entries(self):
+ """Get all the Harmony uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Logitech",
+ "deviceType": "urn:myharmony-com:device:harmony:1"
+ })
diff --git a/netdisco/discoverables/hass_ios.py b/netdisco/discoverables/hass_ios.py
new file mode 100644
index 0000000..84c1eaa
--- /dev/null
+++ b/netdisco/discoverables/hass_ios.py
@@ -0,0 +1,9 @@
+"""Discover Home Assistant iOS app."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering the Home Assistant iOS app."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_hass-ios._tcp.local.')
diff --git a/netdisco/discoverables/home_assistant.py b/netdisco/discoverables/home_assistant.py
new file mode 100644
index 0000000..2b7828e
--- /dev/null
+++ b/netdisco/discoverables/home_assistant.py
@@ -0,0 +1,9 @@
+"""Discover Home Assistant servers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Home Assistant instances."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_home-assistant._tcp.local.')
diff --git a/netdisco/discoverables/homekit.py b/netdisco/discoverables/homekit.py
new file mode 100644
index 0000000..690cb63
--- /dev/null
+++ b/netdisco/discoverables/homekit.py
@@ -0,0 +1,18 @@
+"""Discover Homekit devices."""
+from . import MDNSDiscoverable
+
+from ..const import ATTR_NAME
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering HomeKit devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_hap._tcp.local.')
+
+ def info_from_entry(self, entry):
+ info = super(Discoverable, self).info_from_entry(entry)
+ name = entry.name
+ name = name.replace('._hap._tcp.local.', '')
+ info[ATTR_NAME] = name
+ return info
diff --git a/netdisco/discoverables/hp_printer.py b/netdisco/discoverables/hp_printer.py
new file mode 100644
index 0000000..b4c6131
--- /dev/null
+++ b/netdisco/discoverables/hp_printer.py
@@ -0,0 +1,13 @@
+"""Discover HP Printers"""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Support for the discovery of HP Printers"""
+
+ def __init__(self, nd):
+ """Initialize the HP Printer discovery"""
+ super(Discoverable, self).__init__(nd, '_printer._tcp.local.')
+
+ def get_entries(self):
+ return self.find_by_device_name('HP ')
diff --git a/netdisco/discoverables/huawei_router.py b/netdisco/discoverables/huawei_router.py
new file mode 100644
index 0000000..7f1bb3d
--- /dev/null
+++ b/netdisco/discoverables/huawei_router.py
@@ -0,0 +1,13 @@
+"""Discover Huawei routers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Huawei routers."""
+
+ def get_entries(self):
+ """Get all the Huawei uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Huawei Technologies Co., Ltd.",
+ "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
+ })
diff --git a/netdisco/discoverables/igd.py b/netdisco/discoverables/igd.py
new file mode 100644
index 0000000..ba92c90
--- /dev/null
+++ b/netdisco/discoverables/igd.py
@@ -0,0 +1,14 @@
+"""Discover IGD services."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering IGD services."""
+
+ def get_entries(self):
+ """Get all the IGD service uPnP entries."""
+ return \
+ self.find_by_st(
+ "urn:schemas-upnp-org:device:InternetGatewayDevice:1") + \
+ self.find_by_st(
+ "urn:schemas-upnp-org:device:InternetGatewayDevice:2")
diff --git a/netdisco/discoverables/ikea_tradfri.py b/netdisco/discoverables/ikea_tradfri.py
new file mode 100644
index 0000000..9fa7c57
--- /dev/null
+++ b/netdisco/discoverables/ikea_tradfri.py
@@ -0,0 +1,10 @@
+"""Discover devices that implement the Ikea Tradfri platform."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Ikea Tradfri devices."""
+
+ def __init__(self, nd):
+ """Initialize the Cast discovery."""
+ super(Discoverable, self).__init__(nd, '_coap._udp.local.')
diff --git a/netdisco/discoverables/kodi.py b/netdisco/discoverables/kodi.py
new file mode 100644
index 0000000..4de74dd
--- /dev/null
+++ b/netdisco/discoverables/kodi.py
@@ -0,0 +1,13 @@
+"""Discover Kodi servers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Kodi."""
+
+ def __init__(self, nd):
+ """Initialize the Kodi discovery."""
+ super(Discoverable, self).__init__(nd, '_http._tcp.local.')
+
+ def get_entries(self):
+ return self.find_by_device_name('Kodi ')
diff --git a/netdisco/discoverables/konnected.py b/netdisco/discoverables/konnected.py
new file mode 100644
index 0000000..7057b17
--- /dev/null
+++ b/netdisco/discoverables/konnected.py
@@ -0,0 +1,10 @@
+"""Discover Konnected Security devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Konnected Security devices."""
+
+ def get_entries(self):
+ """Return all Konnected entries."""
+ return self.find_by_st('urn:schemas-konnected-io:device:Security:1')
diff --git a/netdisco/discoverables/lg_smart_device.py b/netdisco/discoverables/lg_smart_device.py
new file mode 100644
index 0000000..49a6fe9
--- /dev/null
+++ b/netdisco/discoverables/lg_smart_device.py
@@ -0,0 +1,10 @@
+"""Discover LG smart devices."""
+from . import MDNSDiscoverable
+
+
+# pylint: disable=too-few-public-methods
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering LG smart devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_lg-smart-device._tcp.local.')
diff --git a/netdisco/discoverables/logitech_mediaserver.py b/netdisco/discoverables/logitech_mediaserver.py
new file mode 100644
index 0000000..d03b8fd
--- /dev/null
+++ b/netdisco/discoverables/logitech_mediaserver.py
@@ -0,0 +1,14 @@
+"""Discover Logitech Media Server."""
+from . import BaseDiscoverable
+
+
+class Discoverable(BaseDiscoverable):
+ """Add support for discovering Logitech Media Server."""
+
+ def __init__(self, netdis):
+ """Initialize Logitech Media Server discovery."""
+ self.netdis = netdis
+
+ def get_entries(self):
+ """Get all the Logitech Media Server details."""
+ return self.netdis.lms.entries
diff --git a/netdisco/discoverables/lutron.py b/netdisco/discoverables/lutron.py
new file mode 100644
index 0000000..b0c51d0
--- /dev/null
+++ b/netdisco/discoverables/lutron.py
@@ -0,0 +1,11 @@
+"""Discover Lutron Caseta Smart Bridge and Smart Bridge Pro devices."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Lutron Caseta Smart Bridge
+ and Smart Bridge Pro devices."""
+
+ def __init__(self, nd):
+ """Initialize the Lutron Smart Bridge discovery."""
+ super(Discoverable, self).__init__(nd, '_lutron._tcp.local.')
diff --git a/netdisco/discoverables/nanoleaf_aurora.py b/netdisco/discoverables/nanoleaf_aurora.py
new file mode 100644
index 0000000..135d785
--- /dev/null
+++ b/netdisco/discoverables/nanoleaf_aurora.py
@@ -0,0 +1,9 @@
+"""Discover Nanoleaf Aurora devices."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Nanoleaf Aurora devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_nanoleafapi._tcp.local.')
diff --git a/netdisco/discoverables/netgear_router.py b/netdisco/discoverables/netgear_router.py
new file mode 100644
index 0000000..d4409bd
--- /dev/null
+++ b/netdisco/discoverables/netgear_router.py
@@ -0,0 +1,13 @@
+"""Discover Netgear routers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Netgear routers."""
+
+ def get_entries(self):
+ """Get all the Netgear uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "NETGEAR, Inc.",
+ "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
+ })
diff --git a/netdisco/discoverables/octoprint.py b/netdisco/discoverables/octoprint.py
new file mode 100644
index 0000000..f6d10e0
--- /dev/null
+++ b/netdisco/discoverables/octoprint.py
@@ -0,0 +1,12 @@
+"""Discover OctoPrint Servers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering OctoPrint servers."""
+
+ def get_entries(self):
+ """Get all the OctoPrint uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "The OctoPrint Project"
+ })
diff --git a/netdisco/discoverables/openhome.py b/netdisco/discoverables/openhome.py
new file mode 100644
index 0000000..3cc4314
--- /dev/null
+++ b/netdisco/discoverables/openhome.py
@@ -0,0 +1,10 @@
+"""Discover Openhome devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Openhome compliant devices."""
+
+ def get_entries(self):
+ """Get all the Openhome compliant device uPnP entries."""
+ return self.find_by_st("urn:av-openhome-org:service:Product:2")
diff --git a/netdisco/discoverables/panasonic_viera.py b/netdisco/discoverables/panasonic_viera.py
new file mode 100644
index 0000000..3f90271
--- /dev/null
+++ b/netdisco/discoverables/panasonic_viera.py
@@ -0,0 +1,10 @@
+"""Discover Panasonic Viera TV devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Viera TV devices."""
+
+ def get_entries(self):
+ """Get all the Viera TV device uPnP entries."""
+ return self.find_by_st("urn:panasonic-com:service:p00NetworkControl:1")
diff --git a/netdisco/discoverables/philips_hue.py b/netdisco/discoverables/philips_hue.py
new file mode 100644
index 0000000..582fbc1
--- /dev/null
+++ b/netdisco/discoverables/philips_hue.py
@@ -0,0 +1,14 @@
+"""Discover Philips Hue bridges."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Philips Hue bridges."""
+
+ def get_entries(self):
+ """Get all the Hue bridge uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Royal Philips Electronics",
+ "manufacturerURL": "http://www.philips.com",
+ "modelNumber": ["929000226503", "BSB002"]
+ })
diff --git a/netdisco/discoverables/plex_mediaserver.py b/netdisco/discoverables/plex_mediaserver.py
new file mode 100644
index 0000000..9eb427a
--- /dev/null
+++ b/netdisco/discoverables/plex_mediaserver.py
@@ -0,0 +1,21 @@
+"""Discover PlexMediaServer."""
+from . import GDMDiscoverable
+from ..const import ATTR_NAME, ATTR_HOST, ATTR_PORT, ATTR_URLBASE
+
+
+class Discoverable(GDMDiscoverable):
+ """Add support for discovering Plex Media Server."""
+
+ def info_from_entry(self, entry):
+ """Return most important info from a GDM entry."""
+ return {
+ ATTR_NAME: entry['data']['Name'],
+ ATTR_HOST: entry['from'][0],
+ ATTR_PORT: entry['data']['Port'],
+ ATTR_URLBASE: 'https://%s:%s' % (entry['from'][0],
+ entry['data']['Port'])
+ }
+
+ def get_entries(self):
+ """Return all PMS entries."""
+ return self.find_by_data({'Content-Type': 'plex/media-server'})
diff --git a/netdisco/discoverables/roku.py b/netdisco/discoverables/roku.py
new file mode 100644
index 0000000..2cf7c4e
--- /dev/null
+++ b/netdisco/discoverables/roku.py
@@ -0,0 +1,10 @@
+"""Discover Roku players."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Roku media players."""
+
+ def get_entries(self):
+ """Get all the Roku entries."""
+ return self.find_by_st("roku:ecp")
diff --git a/netdisco/discoverables/sabnzbd.py b/netdisco/discoverables/sabnzbd.py
new file mode 100644
index 0000000..b2cb9e7
--- /dev/null
+++ b/netdisco/discoverables/sabnzbd.py
@@ -0,0 +1,13 @@
+"""Discover SABnzbd servers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering SABnzbd."""
+
+ def __init__(self, nd):
+ """Initialize the SABnzbd discovery."""
+ super(Discoverable, self).__init__(nd, '_http._tcp.local.')
+
+ def get_entries(self):
+ return self.find_by_device_name('SABnzbd on')
diff --git a/netdisco/discoverables/samsung_printer.py b/netdisco/discoverables/samsung_printer.py
new file mode 100644
index 0000000..04eb2c1
--- /dev/null
+++ b/netdisco/discoverables/samsung_printer.py
@@ -0,0 +1,13 @@
+"""Discover Samsung Printers"""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Support for the discovery of Samsung Printers"""
+
+ def get_entries(self):
+ """Get all the Samsung Printer uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Samsung Electronics",
+ "deviceType": "urn:schemas-upnp-org:device:Printer:1"
+ })
diff --git a/netdisco/discoverables/samsung_tv.py b/netdisco/discoverables/samsung_tv.py
new file mode 100644
index 0000000..4ad31bb
--- /dev/null
+++ b/netdisco/discoverables/samsung_tv.py
@@ -0,0 +1,25 @@
+"""Discover Samsung Smart TV services."""
+from . import SSDPDiscoverable
+from ..const import ATTR_NAME
+
+# For some models, Samsung forces a [TV] prefix to the user-specified name.
+FORCED_NAME_PREFIX = '[TV]'
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Samsung Smart TV services."""
+
+ def get_entries(self):
+ """Get all the Samsung RemoteControlReceiver entries."""
+ return self.find_by_st(
+ "urn:samsung.com:device:RemoteControlReceiver:1")
+
+ def info_from_entry(self, entry):
+ """Get most important info, by default the description location."""
+ info = super().info_from_entry(entry)
+
+ # Strip the forced prefix, if present
+ if info[ATTR_NAME].startswith(FORCED_NAME_PREFIX):
+ info[ATTR_NAME] = info[ATTR_NAME][len(FORCED_NAME_PREFIX):].strip()
+
+ return info
diff --git a/netdisco/discoverables/songpal.py b/netdisco/discoverables/songpal.py
new file mode 100644
index 0000000..94d09d6
--- /dev/null
+++ b/netdisco/discoverables/songpal.py
@@ -0,0 +1,50 @@
+"""Discover Songpal devices."""
+import logging
+from . import SSDPDiscoverable
+from . import ATTR_PROPERTIES
+
+
+class Discoverable(SSDPDiscoverable):
+ """Support for Songpal devices.
+ Supported devices: http://vssupport.sony.net/en_ww/device.html."""
+
+ def get_entries(self):
+ """Get all the Songpal devices."""
+ devs = self.find_by_st(
+ "urn:schemas-sony-com:service:ScalarWebAPI:1")
+
+ # At least some Bravia televisions use this API for communication.
+ # Based on some examples they always seem to lack modelNumber,
+ # so we use it here to keep them undiscovered for now.
+ non_bravias = []
+ for dev in devs:
+ if 'device' in dev.description:
+ device = dev.description['device']
+ if 'modelNumber' in device:
+ non_bravias.append(dev)
+
+ return non_bravias
+
+ def info_from_entry(self, entry):
+ """Get information for a device.."""
+ info = super().info_from_entry(entry)
+
+ cached_descs = entry.DESCRIPTION_CACHE[entry.location]
+
+ device_info_element = "X_ScalarWebAPI_DeviceInfo"
+ baseurl_element = "X_ScalarWebAPI_BaseURL"
+ device_element = "device"
+ if device_element in cached_descs and \
+ device_info_element in cached_descs[device_element]:
+ scalarweb = cached_descs[device_element][device_info_element]
+
+ properties = {"scalarwebapi": scalarweb}
+ if baseurl_element in scalarweb:
+ properties["endpoint"] = scalarweb[baseurl_element]
+ else:
+ logging.warning("Unable to find %s", baseurl_element)
+ info[ATTR_PROPERTIES] = properties
+ else:
+ logging.warning("Unable to find ScalarWeb element from desc.")
+
+ return info
diff --git a/netdisco/discoverables/sonos.py b/netdisco/discoverables/sonos.py
new file mode 100644
index 0000000..29c96c0
--- /dev/null
+++ b/netdisco/discoverables/sonos.py
@@ -0,0 +1,10 @@
+"""Discover Sonos devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Sonos devices."""
+
+ def get_entries(self):
+ """Get all the Sonos device uPnP entries."""
+ return self.find_by_st("urn:schemas-upnp-org:device:ZonePlayer:1")
diff --git a/netdisco/discoverables/spotify_connect.py b/netdisco/discoverables/spotify_connect.py
new file mode 100644
index 0000000..6bdb062
--- /dev/null
+++ b/netdisco/discoverables/spotify_connect.py
@@ -0,0 +1,10 @@
+"""Discover devices that implement the Spotify Connect platform."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Spotify Connect service."""
+
+ def __init__(self, nd):
+ """Initialize the Cast discovery."""
+ super(Discoverable, self).__init__(nd, '_spotify-connect._tcp.local.')
diff --git a/netdisco/discoverables/tellstick.py b/netdisco/discoverables/tellstick.py
new file mode 100644
index 0000000..727aa76
--- /dev/null
+++ b/netdisco/discoverables/tellstick.py
@@ -0,0 +1,14 @@
+"""Discover Tellstick devices."""
+from . import BaseDiscoverable
+
+
+class Discoverable(BaseDiscoverable):
+ """Add support for discovering a Tellstick device."""
+
+ def __init__(self, netdis):
+ """Initialize the Tellstick discovery."""
+ self._netdis = netdis
+
+ def get_entries(self):
+ """Get all the Tellstick details."""
+ return self._netdis.tellstick.entries
diff --git a/netdisco/discoverables/tivo_dvr.py b/netdisco/discoverables/tivo_dvr.py
new file mode 100644
index 0000000..454cfe9
--- /dev/null
+++ b/netdisco/discoverables/tivo_dvr.py
@@ -0,0 +1,14 @@
+"""Discover TiVo DVR devices providing the TCP Remote Protocol."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering TiVo Remote Protocol service."""
+
+ def __init__(self, nd):
+ """Initialize the discovery.
+
+ Yields a dictionary with hostname, host and port along with a
+ properties sub-dictionary with some device specific ids.
+ """
+ super(Discoverable, self).__init__(nd, '_tivo-remote._tcp.local.')
diff --git a/netdisco/discoverables/volumio.py b/netdisco/discoverables/volumio.py
new file mode 100644
index 0000000..a08b902
--- /dev/null
+++ b/netdisco/discoverables/volumio.py
@@ -0,0 +1,10 @@
+"""Discover Volumio servers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Volumio."""
+
+ def __init__(self, nd):
+ """Initialize the Volumio discovery."""
+ super(Discoverable, self).__init__(nd, '_Volumio._tcp.local.')
diff --git a/netdisco/discoverables/webos_tv.py b/netdisco/discoverables/webos_tv.py
new file mode 100644
index 0000000..4328c60
--- /dev/null
+++ b/netdisco/discoverables/webos_tv.py
@@ -0,0 +1,15 @@
+"""Discover LG WebOS TV devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering LG WebOS TV devices."""
+
+ def get_entries(self):
+ """Get all the LG WebOS TV device uPnP entries."""
+ return self.find_by_device_description(
+ {
+ "deviceType": "urn:schemas-upnp-org:device:Basic:1",
+ "modelName": "LG Smart TV"
+ }
+ )
diff --git a/netdisco/discoverables/wink.py b/netdisco/discoverables/wink.py
new file mode 100644
index 0000000..9e6833f
--- /dev/null
+++ b/netdisco/discoverables/wink.py
@@ -0,0 +1,14 @@
+"""Discover Wink hub devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Wink hub devices."""
+
+ def get_entries(self):
+ """Return all Wink entries."""
+ results = []
+ results.extend(self.find_by_st('urn:wink-com:device:hub2:2'))
+ results.extend(self.find_by_st('urn:wink-com:device:hub:2'))
+ results.extend(self.find_by_st('urn:wink-com:device:relay:2'))
+ return results
diff --git a/netdisco/discoverables/xbox_smartglass.py b/netdisco/discoverables/xbox_smartglass.py
new file mode 100644
index 0000000..191ee7b
--- /dev/null
+++ b/netdisco/discoverables/xbox_smartglass.py
@@ -0,0 +1,14 @@
+"""Discover Xbox SmartGlass devices."""
+from . import BaseDiscoverable
+
+
+class Discoverable(BaseDiscoverable):
+ """Add support for discovering a Xbox SmartGlass device."""
+
+ def __init__(self, netdis):
+ """Initialize the Xbox SmartGlass discovery."""
+ self._netdis = netdis
+
+ def get_entries(self):
+ """Get all the Xbox SmartGlass details."""
+ return self._netdis.xbox_smartglass.entries
diff --git a/netdisco/discoverables/xiaomi_gw.py b/netdisco/discoverables/xiaomi_gw.py
new file mode 100644
index 0000000..5c9ab0d
--- /dev/null
+++ b/netdisco/discoverables/xiaomi_gw.py
@@ -0,0 +1,33 @@
+"""Discover Xiaomi Mi Home (aka Lumi) Gateways."""
+from . import MDNSDiscoverable
+from ..const import ATTR_MAC_ADDRESS, ATTR_PROPERTIES
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Xiaomi Gateway"""
+
+ def __init__(self, nd):
+ """Initialize the discovery."""
+ super(Discoverable, self).__init__(nd, '_miio._udp.local.')
+
+ def info_from_entry(self, entry):
+ """Return most important info from mDNS entries."""
+ info = super().info_from_entry(entry)
+
+ # Workaround of misparsing of mDNS properties. It's unclear
+ # whether it's bug in zeroconf module or in the Gateway, but
+ # returned properties look like:
+ # {b'poch': b'0:mac=286c07aaaaaa\x00'} instead of expected:
+ # {b'epoch': b'0', b'mac': '286c07aaaaaa'}
+ if "poch" in info[ATTR_PROPERTIES]:
+ misparsed = info[ATTR_PROPERTIES]["poch"]
+ misparsed = misparsed.rstrip("\0")
+ for val in misparsed.split(":"):
+ if val.startswith("mac="):
+ info[ATTR_MAC_ADDRESS] = val[len("mac="):]
+
+ return info
+
+ def get_entries(self):
+ """Return Xiaomi Gateway devices."""
+ return self.find_by_device_name('lumi-gateway-')
diff --git a/netdisco/discoverables/yamaha.py b/netdisco/discoverables/yamaha.py
new file mode 100644
index 0000000..7361818
--- /dev/null
+++ b/netdisco/discoverables/yamaha.py
@@ -0,0 +1,42 @@
+"""Discover Yamaha Receivers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Yamaha Receivers."""
+
+ INCOMPATIBLE_MODELS = set('N301')
+
+ REMOTE_CONTROL_SPEC_TYPE =\
+ 'urn:schemas-yamaha-com:service:X_YamahaRemoteControl:1'
+
+ def info_from_entry(self, entry):
+ """Return the most important info from a uPnP entry."""
+ info = super().info_from_entry(entry)
+
+ yam = entry.description['X_device']
+ services = yam['X_serviceList']['X_service']
+ if isinstance(services, list):
+ service = next(
+ (s for s in services
+ if s['X_specType'] == self.REMOTE_CONTROL_SPEC_TYPE),
+ services[0])
+ else:
+ service = services
+ # do a slice of the second element so we don't have double /
+ info['control_url'] = yam['X_URLBase'] + service['X_controlURL'][1:]
+ info['description_url'] = (yam['X_URLBase'] +
+ service['X_unitDescURL'][1:])
+
+ return info
+
+ def get_entries(self):
+ """Get all the Yamaha uPnP entries."""
+ devices = self.find_by_device_description({
+ "manufacturer": "Yamaha Corporation",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1"
+ })
+
+ return [device for device in devices if
+ device.description['device'].get('modelNumber', '') not in
+ self.INCOMPATIBLE_MODELS]
diff --git a/netdisco/discoverables/yeelight.py b/netdisco/discoverables/yeelight.py
new file mode 100644
index 0000000..9a42c1b
--- /dev/null
+++ b/netdisco/discoverables/yeelight.py
@@ -0,0 +1,27 @@
+"""Discover Yeelight bulbs, based on Kodi discoverable."""
+from . import MDNSDiscoverable
+from ..const import ATTR_DEVICE_TYPE
+
+DEVICE_NAME_PREFIX = 'yeelink-light-'
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Yeelight."""
+
+ def __init__(self, nd):
+ """Initialize the Yeelight discovery."""
+ super(Discoverable, self).__init__(nd, '_miio._udp.local.')
+
+ def info_from_entry(self, entry):
+ """Return most important info from mDNS entries."""
+ info = super().info_from_entry(entry)
+
+ # Example name: yeelink-light-ceiling4_mibt72799069._miio._udp.local.
+ info[ATTR_DEVICE_TYPE] = \
+ entry.name.replace(DEVICE_NAME_PREFIX, '').split('_', 1)[0]
+
+ return info
+
+ def get_entries(self):
+ """ Return yeelight devices. """
+ return self.find_by_device_name(DEVICE_NAME_PREFIX)
diff --git a/netdisco/discoverables/ziggo_mediabox_xl.py b/netdisco/discoverables/ziggo_mediabox_xl.py
new file mode 100644
index 0000000..57fcd50
--- /dev/null
+++ b/netdisco/discoverables/ziggo_mediabox_xl.py
@@ -0,0 +1,12 @@
+"""Discover Ziggo Mediabox XL devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Ziggo Mediabox XL devices."""
+
+ def get_entries(self):
+ """Return all Ziggo (UPC) Mediabox XL entries."""
+ return self.find_by_device_description(
+ {'modelDescription': 'UPC Hzn Gateway',
+ 'deviceType': 'urn:schemas-upnp-org:device:RemoteUIServer:2'})
diff --git a/netdisco/discovery.py b/netdisco/discovery.py
new file mode 100644
index 0000000..27bce19
--- /dev/null
+++ b/netdisco/discovery.py
@@ -0,0 +1,148 @@
+"""Combine all the different protocols into a simple interface."""
+import logging
+import os
+import importlib
+
+from .ssdp import SSDP
+from .mdns import MDNS
+from .gdm import GDM
+from .lms import LMS
+from .tellstick import Tellstick
+from .daikin import Daikin
+from .smartglass import XboxSmartGlass
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class NetworkDiscovery:
+ """Scan the network for devices.
+
+ mDNS scans in a background thread.
+ SSDP scans in the foreground.
+ GDM scans in the foreground.
+ LMS scans in the foreground.
+ Tellstick scans in the foreground
+ Xbox One scans in the foreground
+
+ start: is ready to scan
+ scan: scan the network
+ discover: parse scanned data
+ get_in
+ """
+
+ # pylint: disable=too-many-instance-attributes
+ def __init__(self):
+ """Initialize the discovery."""
+
+ self.mdns = None
+ self.ssdp = None
+ self.gdm = None
+ self.lms = None
+ self.tellstick = None
+ self.daikin = None
+ self.xbox_smartglass = None
+
+ self.is_discovering = False
+ self.discoverables = None
+
+ def scan(self):
+ """Start and tells scanners to scan."""
+ self.is_discovering = True
+
+ self.mdns = MDNS()
+
+ # Needs to be after MDNS init
+ self._load_device_support()
+
+ self.mdns.start()
+
+ self.ssdp = SSDP()
+ self.ssdp.scan()
+
+ self.gdm = GDM()
+ self.gdm.scan()
+
+ self.lms = LMS()
+ self.lms.scan()
+
+ self.tellstick = Tellstick()
+ self.tellstick.scan()
+
+ self.daikin = Daikin()
+ self.daikin.scan()
+
+ self.xbox_smartglass = XboxSmartGlass()
+ self.xbox_smartglass.scan()
+
+ def stop(self):
+ """Turn discovery off."""
+ if not self.is_discovering:
+ return
+
+ self.mdns.stop()
+
+ # Not removing SSDP because it tracks state
+ self.mdns = None
+ self.gdm = None
+ self.lms = None
+ self.tellstick = None
+ self.daikin = None
+ self.xbox_smartglass = None
+ self.discoverables = None
+ self.is_discovering = False
+
+ def discover(self):
+ """Return a list of discovered devices and services."""
+ if not self.is_discovering:
+ raise RuntimeError("Needs to be called after start, before stop")
+
+ return [dis for dis, checker in self.discoverables.items()
+ if checker.is_discovered()]
+
+ def get_info(self, dis):
+ """Get a list with the most important info about discovered type."""
+ return self.discoverables[dis].get_info()
+
+ def get_entries(self, dis):
+ """Get a list with all info about a discovered type."""
+ return self.discoverables[dis].get_entries()
+
+ def _load_device_support(self):
+ """Load the devices and services that can be discovered."""
+ self.discoverables = {}
+
+ discoverables_format = __name__.rsplit('.', 1)[0] + '.discoverables.{}'
+
+ for module_name in os.listdir(os.path.join(os.path.dirname(__file__),
+ 'discoverables')):
+ if module_name[-3:] != '.py' or module_name == '__init__.py':
+ continue
+
+ module_name = module_name[:-3]
+
+ module = importlib.import_module(
+ discoverables_format.format(module_name))
+
+ self.discoverables[module_name] = module.Discoverable(self)
+
+ def print_raw_data(self):
+ """Helper method to show what is discovered in your network."""
+ from pprint import pprint
+
+ print("Zeroconf")
+ pprint(self.mdns.entries)
+ print("")
+ print("SSDP")
+ pprint(self.ssdp.entries)
+ print("")
+ print("GDM")
+ pprint(self.gdm.entries)
+ print("")
+ print("LMS")
+ pprint(self.lms.entries)
+ print("")
+ print("Tellstick")
+ pprint(self.tellstick.entries)
+ print("")
+ print("Xbox SmartGlass")
+ pprint(self.xbox_smartglass.entries)
diff --git a/netdisco/gdm.py b/netdisco/gdm.py
new file mode 100644
index 0000000..ce81ff0
--- /dev/null
+++ b/netdisco/gdm.py
@@ -0,0 +1,110 @@
+"""
+Support for discovery using GDM (Good Day Mate), multicast protocol by Plex.
+
+Inspired by
+ hippojay's plexGDM:
+ https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py
+ iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py
+"""
+import socket
+import struct
+
+
+class GDM:
+ """Base class to discover GDM services."""
+
+ def __init__(self):
+ self.entries = []
+ self.last_scan = None
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Return all found entries.
+
+ Will scan for entries if not scanned recently.
+ """
+ self.scan()
+ return list(self.entries)
+
+ def find_by_content_type(self, value):
+ """Return a list of entries that match the content_type."""
+ self.scan()
+ return [entry for entry in self.entries
+ if value in entry['data']['Content_Type']]
+
+ def find_by_data(self, values):
+ """Return a list of entries that match the search parameters."""
+ self.scan()
+ return [entry for entry in self.entries
+ if all(item in entry['data'].items()
+ for item in values.items())]
+
+ def update(self):
+ """Scan for new GDM services.
+
+ Example of the dict list assigned to self.entries by this function:
+ [{'data': {
+ 'Content-Type': 'plex/media-server',
+ 'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct',
+ 'Name': 'myfirstplexserver',
+ 'Port': '32400',
+ 'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7',
+ 'Updated-At': '1444852697',
+ 'Version': '0.9.12.13.1464-4ccd2ca',
+ },
+ 'from': ('10.10.10.100', 32414)}]
+ """
+
+ gdm_ip = '239.0.0.250' # multicast to PMS
+ gdm_port = 32414
+ gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii')
+ gdm_timeout = 1
+
+ self.entries = []
+
+ # setup socket for discovery -> multicast message
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.settimeout(gdm_timeout)
+
+ # Set the time-to-live for messages for local network
+ sock.setsockopt(socket.IPPROTO_IP,
+ socket.IP_MULTICAST_TTL,
+ struct.pack("B", gdm_timeout))
+
+ try:
+ # Send data to the multicast group
+ sock.sendto(gdm_msg, (gdm_ip, gdm_port))
+
+ # Look for responses from all recipients
+ while True:
+ try:
+ data, server = sock.recvfrom(1024)
+ data = data.decode('utf-8')
+ if '200 OK' in data.splitlines()[0]:
+ data = {k: v.strip() for (k, v) in (
+ line.split(':') for line in
+ data.splitlines() if ':' in line)}
+ self.entries.append({'data': data,
+ 'from': server})
+ except socket.timeout:
+ break
+ finally:
+ sock.close()
+
+
+def main():
+ """Test GDM discovery."""
+ from pprint import pprint
+
+ gdm = GDM()
+
+ pprint("Scanning GDM...")
+ gdm.update()
+ pprint(gdm.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/lms.py b/netdisco/lms.py
new file mode 100644
index 0000000..6026a86
--- /dev/null
+++ b/netdisco/lms.py
@@ -0,0 +1,78 @@
+"""Squeezebox/Logitech Media server discovery."""
+import socket
+
+from .const import ATTR_HOST, ATTR_PORT
+
+DISCOVERY_PORT = 3483
+DEFAULT_DISCOVERY_TIMEOUT = 2
+
+
+class LMS:
+ """Base class to discover Logitech Media servers."""
+
+ def __init__(self):
+ """Initialize the Logitech discovery."""
+ self.entries = []
+ self.last_scan = None
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Scan and return all found entries."""
+ self.scan()
+ return list(self.entries)
+
+ def update(self):
+ """Scan network for Logitech Media Servers."""
+ lms_ip = '<broadcast>'
+ lms_port = DISCOVERY_PORT
+ lms_msg = b"eJSON\0"
+ lms_timeout = DEFAULT_DISCOVERY_TIMEOUT
+
+ entries = []
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.settimeout(lms_timeout)
+ sock.bind(('', 0))
+
+ try:
+ sock.sendto(lms_msg, (lms_ip, lms_port))
+
+ while True:
+ try:
+ data, server = sock.recvfrom(1024)
+ if data.startswith(b'E'):
+ # Full response is EJSON\xYYXXXX
+ # Where YY is length of port string (ie 4)
+ # And XXXX is the web interface port (ie 9000)
+ port = None
+ if data.startswith(b'JSON', 1):
+ length = data[5:6][0]
+ port = int(data[0-length:])
+ entries.append({
+ ATTR_HOST: server[0],
+ ATTR_PORT: port,
+ })
+ except socket.timeout:
+ break
+ finally:
+ sock.close()
+ self.entries = entries
+
+
+def main():
+ """Test LMS discovery."""
+ from pprint import pprint
+
+ lms = LMS()
+
+ pprint("Scanning for Logitech Media Servers...")
+ lms.update()
+ pprint(lms.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/mdns.py b/netdisco/mdns.py
new file mode 100644
index 0000000..8826346
--- /dev/null
+++ b/netdisco/mdns.py
@@ -0,0 +1,45 @@
+"""Add support for discovering mDNS services."""
+import zeroconf
+
+
+class MDNS:
+ """Base class to discover mDNS services."""
+
+ def __init__(self):
+ """Initialize the discovery."""
+ self.zeroconf = None
+ self.services = []
+ self._browsers = []
+
+ def register_service(self, service):
+ """Register a mDNS service."""
+ self.services.append(service)
+
+ def start(self):
+ """Start discovery."""
+ try:
+ self.zeroconf = zeroconf.Zeroconf()
+
+ for service in self.services:
+ self._browsers.append(zeroconf.ServiceBrowser(
+ self.zeroconf, service.typ, service))
+ except Exception: # pylint: disable=broad-except
+ self.stop()
+ raise
+
+ def stop(self):
+ """Stop discovering."""
+ while self._browsers:
+ self._browsers.pop().cancel()
+
+ for service in self.services:
+ service.reset()
+
+ if self.zeroconf:
+ self.zeroconf.close()
+ self.zeroconf = None
+
+ @property
+ def entries(self):
+ """Return all entries in the cache."""
+ return self.zeroconf.cache.entries()
diff --git a/netdisco/service.py b/netdisco/service.py
new file mode 100644
index 0000000..3cfb6b1
--- /dev/null
+++ b/netdisco/service.py
@@ -0,0 +1,88 @@
+"""Provide service that scans the network in intervals."""
+import logging
+import threading
+import time
+from collections import defaultdict
+
+from .discovery import NetworkDiscovery
+
+DEFAULT_INTERVAL = 300 # seconds
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class DiscoveryService(threading.Thread):
+ """Service that will scan the network for devices each `interval` seconds.
+
+ Add listeners to the service to be notified of new services found.
+ """
+
+ def __init__(self, interval=DEFAULT_INTERVAL):
+ """Initialize the discovery."""
+ super(DiscoveryService, self).__init__()
+
+ # Scanning interval
+ self.interval = interval
+
+ # Listeners for new services
+ self.listeners = []
+
+ # To track when we have to stop
+ self._stop = threading.Event()
+
+ # Tell Python not to wait till this thread exits
+ self.daemon = True
+
+ # The discovery object
+ self.discovery = None
+
+ # Dict to keep track of found services. We do not want to
+ # broadcast the same found service twice.
+ self._found = defaultdict(list)
+
+ def add_listener(self, listener):
+ """Add a listener for new services."""
+ self.listeners.append(listener)
+
+ def stop(self):
+ """Stop the service."""
+ self._stop.set()
+
+ def run(self):
+ """Start the discovery service."""
+ self.discovery = NetworkDiscovery()
+
+ while True:
+ self._scan()
+
+ seconds_since_scan = 0
+
+ while seconds_since_scan < self.interval:
+ if self._stop.is_set():
+ return
+
+ time.sleep(1)
+ seconds_since_scan += 1
+
+ def _scan(self):
+ """Scan for new devices."""
+ _LOGGER.info("Scanning")
+ self.discovery.scan()
+
+ for disc in self.discovery.discover():
+ for service in self.discovery.get_info(disc):
+ self._service_found(disc, service)
+
+ self.discovery.stop()
+
+ def _service_found(self, disc, service):
+ """Tell listeners a service was found."""
+ if service not in self._found[disc]:
+ self._found[disc].append(service)
+
+ for listener in self.listeners:
+ try:
+ listener(disc, service)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Error calling listener")
diff --git a/netdisco/smartglass.py b/netdisco/smartglass.py
new file mode 100644
index 0000000..4244a2b
--- /dev/null
+++ b/netdisco/smartglass.py
@@ -0,0 +1,156 @@
+"""Xbox One SmartGlass device discovery."""
+import socket
+import struct
+import binascii
+from datetime import timedelta
+
+
+DISCOVERY_PORT = 5050
+DISCOVERY_ADDRESS_BCAST = '<broadcast>'
+DISCOVERY_ADDRESS_MCAST = '239.255.255.250'
+DISCOVERY_REQUEST = 0xDD00
+DISCOVERY_RESPONSE = 0xDD01
+DISCOVERY_TIMEOUT = timedelta(seconds=2)
+
+"""
+SmartGlass Client type
+XboxOne = 1
+Xbox360 = 2
+WindowsDesktop = 3
+WindowsStore = 4
+WindowsPhone = 5
+iPhone = 6
+iPad = 7
+Android = 8
+"""
+DISCOVERY_CLIENT_TYPE = 4
+
+
+class XboxSmartGlass:
+ """Base class to discover Xbox SmartGlass devices."""
+
+ def __init__(self):
+ """Initialize the Xbox SmartGlass discovery."""
+ self.entries = []
+ self._discovery_payload = self.discovery_packet()
+
+ @staticmethod
+ def discovery_packet():
+ """Assemble discovery payload."""
+ version = 0
+ flags = 0
+ min_version = 0
+ max_version = 2
+
+ payload = struct.pack(
+ '>IHHH',
+ flags, DISCOVERY_CLIENT_TYPE, min_version, max_version
+ )
+ header = struct.pack(
+ '>HHH',
+ DISCOVERY_REQUEST, len(payload), version
+ )
+ return header + payload
+
+ @staticmethod
+ def parse_discovery_response(data):
+ """Parse console's discovery response."""
+ pos = 0
+ # Header
+ # pkt_type, payload_len, version = struct.unpack_from(
+ # '>HHH',
+ # data, pos
+ # )
+ pos += 6
+ # Payload
+ flags, type_, name_len = struct.unpack_from(
+ '>IHH',
+ data, pos
+ )
+ pos += 8
+ name = data[pos:pos + name_len]
+ pos += name_len + 1 # including null terminator
+ uuid_len = struct.unpack_from(
+ '>H',
+ data, pos
+ )[0]
+ pos += 2
+ uuid = data[pos:pos + uuid_len]
+ pos += uuid_len + 1 # including null terminator
+ last_error, cert_len = struct.unpack_from(
+ '>IH',
+ data, pos
+ )
+ pos += 6
+ cert = data[pos:pos + cert_len]
+
+ return {
+ 'device_type': type_,
+ 'flags': flags,
+ 'name': name.decode('utf-8'),
+ 'uuid': uuid.decode('utf-8'),
+ 'last_error': last_error,
+ 'certificate': binascii.hexlify(cert).decode('utf-8')
+ }
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Scan and return all found entries."""
+ self.scan()
+ return self.entries
+
+ @staticmethod
+ def verify_packet(data):
+ """Parse packet if it has correct magic"""
+ if len(data) < 2:
+ return None
+
+ pkt_type = struct.unpack_from('>H', data)[0]
+ if pkt_type != DISCOVERY_RESPONSE:
+ return None
+
+ return XboxSmartGlass.parse_discovery_response(data)
+
+ def update(self):
+ """Scan network for Xbox SmartGlass devices."""
+ entries = []
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.settimeout(DISCOVERY_TIMEOUT.seconds)
+ sock.sendto(self._discovery_payload,
+ (DISCOVERY_ADDRESS_BCAST, DISCOVERY_PORT))
+ sock.sendto(self._discovery_payload,
+ (DISCOVERY_ADDRESS_MCAST, DISCOVERY_PORT))
+
+ while True:
+ try:
+ data, (address, _) = sock.recvfrom(1024)
+
+ response = self.verify_packet(data)
+ if response:
+ entries.append((address, response))
+
+ except socket.timeout:
+ break
+
+ self.entries = entries
+
+ sock.close()
+
+
+def main():
+ """Test XboxOne discovery."""
+ from pprint import pprint
+ xbsmartglass = XboxSmartGlass()
+ pprint("Scanning for Xbox One SmartGlass consoles devices..")
+ xbsmartglass.update()
+ pprint(xbsmartglass.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/ssdp.py b/netdisco/ssdp.py
new file mode 100644
index 0000000..55cb08b
--- /dev/null
+++ b/netdisco/ssdp.py
@@ -0,0 +1,290 @@
+"""Module that implements SSDP protocol."""
+import re
+import select
+import socket
+import logging
+from datetime import datetime, timedelta
+from xml.etree import ElementTree
+
+import requests
+import zeroconf
+
+from netdisco.util import etree_to_dict
+
+DISCOVER_TIMEOUT = 2
+# MX is a suggested random wait time for a device to reply, so should be
+# bound by our discovery timeout.
+SSDP_MX = DISCOVER_TIMEOUT
+SSDP_TARGET = ("239.255.255.250", 1900)
+
+RESPONSE_REGEX = re.compile(r'\n(.*?)\: *(.*)\r')
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=59)
+
+# Devices and services
+ST_ALL = "ssdp:all"
+
+# Devices only, some devices will only respond to this query
+ST_ROOTDEVICE = "upnp:rootdevice"
+
+
+class SSDP:
+ """Control the scanning of uPnP devices and services and caches output."""
+
+ def __init__(self):
+ """Initialize the discovery."""
+ self.entries = []
+ self.last_scan = None
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Return all found entries.
+
+ Will scan for entries if not scanned recently.
+ """
+ self.update()
+
+ return list(self.entries)
+
+ # pylint: disable=invalid-name
+ def find_by_st(self, st):
+ """Return a list of entries that match the ST."""
+ self.update()
+
+ return [entry for entry in self.entries
+ if entry.st == st]
+
+ def find_by_device_description(self, values):
+ """Return a list of entries that match the description.
+
+ Pass in a dict with values to match against the device tag in the
+ description.
+ """
+ self.update()
+
+ seen = set()
+ results = []
+
+ # Make unique based on the location since we don't care about ST here
+ for entry in self.entries:
+ location = entry.location
+
+ if location not in seen and entry.match_device_description(values):
+ results.append(entry)
+ seen.add(location)
+
+ return results
+
+ def update(self, force_update=False):
+ """Scan for new uPnP devices and services."""
+ if self.last_scan is None or force_update or \
+ datetime.now()-self.last_scan > MIN_TIME_BETWEEN_SCANS:
+
+ self.remove_expired()
+
+ self.entries.extend(
+ entry for entry in scan()
+ if entry not in self.entries)
+
+ self.last_scan = datetime.now()
+
+ def remove_expired(self):
+ """Filter out expired entries."""
+ self.entries = [entry for entry in self.entries
+ if not entry.is_expired]
+
+
+class UPNPEntry:
+ """Found uPnP entry."""
+
+ DESCRIPTION_CACHE = {'_NO_LOCATION': {}}
+
+ def __init__(self, values):
+ """Initialize the discovery."""
+ self.values = values
+ self.created = datetime.now()
+
+ if 'cache-control' in self.values:
+ cache_directive = self.values['cache-control']
+ max_age = re.findall(r'max-age *= *\d+', cache_directive)
+ if max_age:
+ cache_seconds = int(max_age[0].split('=')[1])
+ self.expires = self.created + timedelta(seconds=cache_seconds)
+ else:
+ self.expires = None
+ else:
+ self.expires = None
+
+ @property
+ def is_expired(self):
+ """Return if the entry is expired or not."""
+ return self.expires is not None and datetime.now() > self.expires
+
+ # pylint: disable=invalid-name
+ @property
+ def st(self):
+ """Return ST value."""
+ return self.values.get('st')
+
+ @property
+ def location(self):
+ """Return Location value."""
+ return self.values.get('location')
+
+ @property
+ def description(self):
+ """Return the description from the uPnP entry."""
+ url = self.values.get('location', '_NO_LOCATION')
+
+ if url not in UPNPEntry.DESCRIPTION_CACHE:
+ try:
+ xml = requests.get(url, timeout=5).text
+ if not xml:
+ # Samsung Smart TV sometimes returns an empty document the
+ # first time. Retry once.
+ xml = requests.get(url, timeout=5).text
+
+ tree = ElementTree.fromstring(xml)
+
+ UPNPEntry.DESCRIPTION_CACHE[url] = \
+ etree_to_dict(tree).get('root', {})
+ except requests.RequestException:
+ logging.getLogger(__name__).debug(
+ "Error fetching description at %s", url)
+
+ UPNPEntry.DESCRIPTION_CACHE[url] = {}
+
+ except ElementTree.ParseError:
+ logging.getLogger(__name__).debug(
+ "Found malformed XML at %s: %s", url, xml)
+
+ UPNPEntry.DESCRIPTION_CACHE[url] = {}
+
+ return UPNPEntry.DESCRIPTION_CACHE[url]
+
+ def match_device_description(self, values):
+ """Fetch description and matches against it.
+
+ Values should only contain lowercase keys.
+ """
+ device = self.description.get('device')
+
+ if device is None:
+ return False
+
+ return all(device.get(key) in val
+ if isinstance(val, list)
+ else val == device.get(key)
+ for key, val in values.items())
+
+ @classmethod
+ def from_response(cls, response):
+ """Create a uPnP entry from a response."""
+ return UPNPEntry({key.lower(): item for key, item
+ in RESPONSE_REGEX.findall(response)})
+
+ def __eq__(self, other):
+ """Return the comparison."""
+ return (self.__class__ == other.__class__ and
+ self.values == other.values)
+
+ def __repr__(self):
+ """Return the entry."""
+ return "<UPNPEntry {} - {}>".format(self.location or '', self.st or '')
+
+
+def ssdp_request(ssdp_st, ssdp_mx=SSDP_MX):
+ """Return request bytes for given st and mx."""
+ return "\r\n".join([
+ 'M-SEARCH * HTTP/1.1',
+ 'ST: {}'.format(ssdp_st),
+ 'MX: {:d}'.format(ssdp_mx),
+ 'MAN: "ssdp:discover"',
+ 'HOST: {}:{}'.format(*SSDP_TARGET),
+ '', '']).encode('utf-8')
+
+
+# pylint: disable=invalid-name,too-many-locals,too-many-branches
+def scan(timeout=DISCOVER_TIMEOUT):
+ """Send a message over the network to discover uPnP devices.
+
+ Inspired by Crimsdings
+ https://github.com/crimsdings/ChromeCast/blob/master/cc_discovery.py
+
+ Protocol explanation:
+ https://embeddedinn.wordpress.com/tutorials/upnp-device-architecture/
+ """
+ ssdp_requests = ssdp_request(ST_ALL), ssdp_request(ST_ROOTDEVICE)
+
+ stop_wait = datetime.now() + timedelta(seconds=timeout)
+
+ sockets = []
+ for addr in zeroconf.get_all_addresses():
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+ # Set the time-to-live for messages for local network
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL,
+ SSDP_MX)
+ sock.bind((addr, 0))
+ sockets.append(sock)
+ except socket.error:
+ pass
+
+ entries = {}
+ for sock in [s for s in sockets]:
+ try:
+ for req in ssdp_requests:
+ sock.sendto(req, SSDP_TARGET)
+ sock.setblocking(False)
+ except socket.error:
+ sockets.remove(sock)
+ sock.close()
+
+ try:
+ while sockets:
+ time_diff = stop_wait - datetime.now()
+ seconds_left = time_diff.total_seconds()
+ if seconds_left <= 0:
+ break
+
+ ready = select.select(sockets, [], [], seconds_left)[0]
+
+ for sock in ready:
+ try:
+ data, address = sock.recvfrom(1024)
+ response = data.decode("utf-8")
+ except UnicodeDecodeError:
+ logging.getLogger(__name__).debug(
+ 'Ignoring invalid unicode response from %s', address)
+ continue
+ except socket.error:
+ logging.getLogger(__name__).exception(
+ "Socket error while discovering SSDP devices")
+ sockets.remove(sock)
+ sock.close()
+ continue
+
+ entry = UPNPEntry.from_response(response)
+ entries[(entry.st, entry.location)] = entry
+
+ finally:
+ for s in sockets:
+ s.close()
+
+ return sorted(entries.values(), key=lambda entry: entry.location or '')
+
+
+def main():
+ """Test SSDP discovery."""
+ from pprint import pprint
+
+ print("Scanning SSDP..")
+ pprint(scan())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/tellstick.py b/netdisco/tellstick.py
new file mode 100644
index 0000000..24cf243
--- /dev/null
+++ b/netdisco/tellstick.py
@@ -0,0 +1,72 @@
+"""Tellstick device discovery."""
+import socket
+from datetime import timedelta
+import logging
+
+
+DISCOVERY_PORT = 30303
+DISCOVERY_ADDRESS = '<broadcast>'
+DISCOVERY_PAYLOAD = b"D"
+DISCOVERY_TIMEOUT = timedelta(seconds=2)
+
+
+class Tellstick:
+ """Base class to discover Tellstick devices."""
+
+ def __init__(self):
+ """Initialize the Tellstick discovery."""
+ self.entries = []
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Scan and return all found entries."""
+ self.scan()
+ return self.entries
+
+ def update(self):
+ """Scan network for Tellstick devices."""
+ entries = []
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.settimeout(DISCOVERY_TIMEOUT.seconds)
+ sock.sendto(DISCOVERY_PAYLOAD, (DISCOVERY_ADDRESS, DISCOVERY_PORT))
+
+ while True:
+ try:
+ data, (address, _) = sock.recvfrom(1024)
+ entry = data.decode("ascii").split(":")
+ # expecting product, mac, activation code, version
+ if len(entry) != 4:
+ continue
+ entry = (address,) + tuple(entry)
+ entries.append(entry)
+
+ except socket.timeout:
+ break
+ except UnicodeDecodeError:
+ # Catch invalid responses
+ logging.getLogger(__name__).debug(
+ 'Ignoring invalid unicode response from %s', address)
+ continue
+
+ self.entries = entries
+
+ sock.close()
+
+
+def main():
+ """Test Tellstick discovery."""
+ from pprint import pprint
+ tellstick = Tellstick()
+ pprint("Scanning for Tellstick devices..")
+ tellstick.update()
+ pprint(tellstick.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/util.py b/netdisco/util.py
new file mode 100644
index 0000000..65475b4
--- /dev/null
+++ b/netdisco/util.py
@@ -0,0 +1,29 @@
+"""Util functions used by Netdisco."""
+from collections import defaultdict
+
+
+# Taken from http://stackoverflow.com/a/10077069
+# pylint: disable=invalid-name
+def etree_to_dict(t):
+ """Convert an ETree object to a dict."""
+ # strip namespace
+ tag_name = t.tag[t.tag.find("}")+1:]
+
+ d = {tag_name: {} if t.attrib else None}
+ children = list(t)
+ if children:
+ dd = defaultdict(list)
+ for dc in map(etree_to_dict, children):
+ for k, v in dc.items():
+ dd[k].append(v)
+ d = {tag_name: {k: v[0] if len(v) == 1 else v for k, v in dd.items()}}
+ if t.attrib:
+ d[tag_name].update(('@' + k, v) for k, v in t.attrib.items())
+ if t.text:
+ text = t.text.strip()
+ if children or t.attrib:
+ if text:
+ d[tag_name]['#text'] = text
+ else:
+ d[tag_name] = text
+ return d
diff --git a/pylintrc b/pylintrc
new file mode 100644
index 0000000..d3aeaf1
--- /dev/null
+++ b/pylintrc
@@ -0,0 +1,2 @@
+[MASTER]
+disable=duplicate-code
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..9ad458c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+zeroconf>=0.21.0
+requests>=2.0
diff --git a/script/release b/script/release
new file mode 100755
index 0000000..d6d1e30
--- /dev/null
+++ b/script/release
@@ -0,0 +1,16 @@
+#!/bin/sh
+# Pushes a new version to PyPi.
+
+cd "$(dirname "$0")/.."
+
+CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD`
+
+if [ "$CURRENT_BRANCH" != "master" ]
+then
+ echo "You have to be on the master to release."
+ exit 1
+fi
+
+rm -rf dist
+python3 setup.py sdist bdist_wheel
+python3 -m twine upload dist/* --skip-existing
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..e48ddf6
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,6 @@
+[bdist_wheel]
+universal = 1
+
+[tool:pytest]
+testpaths = tests
+norecursedirs = .git
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..aaf08bb
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,13 @@
+from setuptools import setup, find_packages
+
+setup(name='netdisco',
+ version='2.2.0',
+ description='Discover devices on your local network',
+ url='https://github.com/home-assistant/netdisco',
+ author='Paulus Schoutsen',
+ author_email='Paulus@PaulusSchoutsen.nl',
+ license='Apache License 2.0',
+ install_requires=['requests>=2.0', 'zeroconf>=0.21.0'],
+ python_requires='>=3',
+ packages=find_packages(exclude=['tests', 'tests.*']),
+ zip_safe=False)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..05fea2a
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Tests for NetDisco."""
diff --git a/tests/discoverables/test_yamaha.py b/tests/discoverables/test_yamaha.py
new file mode 100644
index 0000000..6b51b96
--- /dev/null
+++ b/tests/discoverables/test_yamaha.py
@@ -0,0 +1,126 @@
+"""The tests for discovering Yamaha Receivers."""
+import unittest
+from unittest.mock import MagicMock
+import xml.etree.ElementTree as ElementTree
+
+from netdisco.discoverables.yamaha import Discoverable
+from netdisco.util import etree_to_dict
+
+LOCATION = 'http://192.168.XXX.XXX:80/desc.xml'
+
+
+class MockUPNPEntry(object):
+ """UPNPEntry backed by a description file."""
+
+ location = LOCATION
+
+ def __init__(self, name):
+ """Read and parse a MockUPNPEntry from a file."""
+ with open('tests/discoverables/yamaha_files/%s' % name,
+ encoding='utf-8') as content:
+ self.description = etree_to_dict(
+ ElementTree.fromstring(content.read())).get('root', {})
+
+
+class TestYamaha(unittest.TestCase):
+ """Test the Yamaha Discoverable."""
+
+ def test_info_from_entry_rx_v481(self):
+ self.assertEqual(
+ Discoverable(None).info_from_entry(
+ MockUPNPEntry("desc_RX-V481.xml")),
+ {
+ 'control_url':
+ 'http://192.168.XXX.XXX:80/YamahaRemoteControl/ctrl',
+ 'description_url':
+ 'http://192.168.XXX.XXX:80/YamahaRemoteControl/desc.xml',
+ 'host': '192.168.xxx.xxx',
+ 'model_name': 'RX-V481',
+ 'model_number': 'V481',
+ 'manufacturer': 'Yamaha Corporation',
+ 'name': 'RX-V481 XXXXXX',
+ 'port': 80,
+ 'serial': 'XXXXXXXX',
+ 'ssdp_description': 'http://192.168.XXX.XXX:80/desc.xml',
+ 'udn': 'uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX',
+ 'upnp_device_type':
+ 'urn:schemas-upnp-org:device:MediaRenderer:1',
+ })
+
+ def test_info_from_entry_single_service(self):
+ self.assertEqual(
+ Discoverable(None).info_from_entry(
+ MockUPNPEntry("desc_single_service.xml")),
+ {
+ 'control_url':
+ 'http://192.168.1.2:80/YamahaRemoteControl/single_ctrl',
+ 'description_url':
+ 'http://192.168.1.2:80/YamahaRemoteControl/single_desc.xml',
+ 'host': '192.168.xxx.xxx',
+ 'model_name': 'single service model name',
+ 'model_number': None,
+ 'manufacturer': None,
+ 'name': 'single service friendly name',
+ 'port': 80,
+ 'serial': None,
+ 'ssdp_description': 'http://192.168.XXX.XXX:80/desc.xml',
+ 'udn': None,
+ 'upnp_device_type': None,
+ })
+
+ def test_info_from_entry_multiple_services_remote_control_last(self):
+ self.assertEqual(
+ Discoverable(None).info_from_entry(MockUPNPEntry(
+ "desc_multiple_services_remote_control_last.xml")),
+ {
+ 'control_url':
+ 'http://192.168.1.2:80/YamahaRemoteControl/multi_ctrl',
+ 'description_url':
+ 'http://192.168.1.2:80/YamahaRemoteControl/multi_desc.xml',
+ 'host': '192.168.xxx.xxx',
+ 'model_name': 'multi service model name',
+ 'model_number': None,
+ 'manufacturer': None,
+ 'name': 'multi service friendly name',
+ 'port': 80,
+ 'serial': None,
+ 'ssdp_description': 'http://192.168.XXX.XXX:80/desc.xml',
+ 'udn': None,
+ 'upnp_device_type': None,
+ })
+
+ def test_info_from_entry_multiple_services_no_remote_control(self):
+ self.assertEqual(
+ Discoverable(None).info_from_entry(MockUPNPEntry(
+ "desc_multiple_services_no_remote_control.xml")),
+ {
+ 'control_url':
+ 'http://192.168.1.2:80/YamahaNewControl/ctrl',
+ 'description_url':
+ 'http://192.168.1.2:80/YamahaNewControl/desc.xml',
+ 'host': '192.168.xxx.xxx',
+ 'model_name': 'multi service model name',
+ 'model_number': None,
+ 'manufacturer': None,
+ 'name': 'multi service friendly name',
+ 'port': 80,
+ 'serial': None,
+ 'ssdp_description': 'http://192.168.XXX.XXX:80/desc.xml',
+ 'udn': None,
+ 'upnp_device_type': None,
+ })
+
+ def test_get_entries_incompatible_models(self):
+ supported_model = MockUPNPEntry(
+ "desc_multiple_services_no_remote_control.xml")
+ devices = [
+ supported_model,
+ MockUPNPEntry("desc_incompatible_device.xml")
+ ]
+
+ discoverable = Discoverable(None)
+ discoverable.INCOMPATIBLE_MODELS = ["aaa"]
+ discoverable.find_by_device_description = MagicMock(
+ return_value=devices)
+
+ self.assertEqual(discoverable.get_entries(), [supported_model])
diff --git a/tests/discoverables/yamaha_files/desc_RX-V481.xml b/tests/discoverables/yamaha_files/desc_RX-V481.xml
new file mode 100644
index 0000000..1919945
--- /dev/null
+++ b/tests/discoverables/yamaha_files/desc_RX-V481.xml
@@ -0,0 +1,92 @@
+<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:yamaha="urn:schemas-yamaha-com:device-1-0">
+<specVersion>
+<major>1</major>
+<minor>0</minor>
+</specVersion>
+<device>
+<dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMR-1.50</dlna:X_DLNADOC>
+<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
+<friendlyName>RX-V481 XXXXXX</friendlyName>
+<manufacturer>Yamaha Corporation</manufacturer>
+<manufacturerURL>http://www.yamaha.com/</manufacturerURL>
+<modelDescription>AV Receiver</modelDescription>
+<modelName>RX-V481</modelName>
+<modelNumber>V481</modelNumber>
+<modelURL>http://www.yamaha.com/</modelURL>
+<serialNumber>XXXXXXXX</serialNumber>
+<UDN>uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</UDN>
+<iconList>
+<icon>
+<mimetype>image/jpeg</mimetype>
+<width>48</width>
+<height>48</height>
+<depth>24</depth>
+<url>/Icons/48x48.jpg</url>
+</icon>
+<icon>
+<mimetype>image/jpeg</mimetype>
+<width>120</width>
+<height>120</height>
+<depth>24</depth>
+<url>/Icons/120x120.jpg</url>
+</icon>
+<icon>
+<mimetype>image/png</mimetype>
+<width>48</width>
+<height>48</height>
+<depth>24</depth>
+<url>/Icons/48x48.png</url>
+</icon>
+<icon>
+<mimetype>image/png</mimetype>
+<width>120</width>
+<height>120</height>
+<depth>24</depth>
+<url>/Icons/120x120.png</url>
+</icon>
+</iconList>
+<serviceList>
+<service>
+<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
+<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
+<SCPDURL>/AVTransport/desc.xml</SCPDURL>
+<controlURL>/AVTransport/ctrl</controlURL>
+<eventSubURL>/AVTransport/event</eventSubURL>
+</service>
+<service>
+<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
+<serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
+<SCPDURL>/RenderingControl/desc.xml</SCPDURL>
+<controlURL>/RenderingControl/ctrl</controlURL>
+<eventSubURL>/RenderingControl/event</eventSubURL>
+</service>
+<service>
+<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
+<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
+<SCPDURL>/ConnectionManager/desc.xml</SCPDURL>
+<controlURL>/ConnectionManager/ctrl</controlURL>
+<eventSubURL>/ConnectionManager/event</eventSubURL>
+</service>
+</serviceList>
+<presentationURL>http://192.168.XXX.XXX/</presentationURL>
+</device>
+<yamaha:X_device>
+<yamaha:X_URLBase>http://192.168.XXX.XXX:80/</yamaha:X_URLBase>
+<yamaha:X_serviceList>
+<yamaha:X_service>
+<yamaha:X_specType>
+urn:schemas-yamaha-com:service:X_YamahaRemoteControl:1
+</yamaha:X_specType>
+<yamaha:X_controlURL>/YamahaRemoteControl/ctrl</yamaha:X_controlURL>
+<yamaha:X_unitDescURL>/YamahaRemoteControl/desc.xml</yamaha:X_unitDescURL>
+</yamaha:X_service>
+<yamaha:X_service>
+<yamaha:X_specType>
+urn:schemas-yamaha-com:service:X_YamahaExtendedControl:1
+</yamaha:X_specType>
+<yamaha:X_yxcControlURL>/YamahaExtendedControl/v1/</yamaha:X_yxcControlURL>
+<yamaha:X_yxcVersion>1131</yamaha:X_yxcVersion>
+</yamaha:X_service>
+</yamaha:X_serviceList>
+</yamaha:X_device>
+</root> \ No newline at end of file
diff --git a/tests/discoverables/yamaha_files/desc_incompatible_device.xml b/tests/discoverables/yamaha_files/desc_incompatible_device.xml
new file mode 100644
index 0000000..53127b7
--- /dev/null
+++ b/tests/discoverables/yamaha_files/desc_incompatible_device.xml
@@ -0,0 +1,26 @@
+<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:yamaha="urn:schemas-yamaha-com:device-1-0">
+<device>
+<friendlyName>multi service friendly name</friendlyName>
+<modelName>multi service model name</modelName>
+<modelNumber>aaa</modelNumber>
+</device>
+<yamaha:X_device>
+<yamaha:X_URLBase>http://192.168.1.2:80/</yamaha:X_URLBase>
+<yamaha:X_serviceList>
+<yamaha:X_service>
+<yamaha:X_specType>
+urn:schemas-yamaha-com:service:X_YamahaNewControl:1
+</yamaha:X_specType>
+<yamaha:X_controlURL>/YamahaNewControl/ctrl</yamaha:X_controlURL>
+<yamaha:X_unitDescURL>/YamahaNewControl/desc.xml</yamaha:X_unitDescURL>
+</yamaha:X_service>
+<yamaha:X_service>
+<yamaha:X_specType>
+urn:schemas-yamaha-com:service:X_YamahaExtendedControl:1
+</yamaha:X_specType>
+<yamaha:X_yxcControlURL>/YamahaExtendedControl/v1/</yamaha:X_yxcControlURL>
+<yamaha:X_yxcVersion>1131</yamaha:X_yxcVersion>
+</yamaha:X_service>
+</yamaha:X_serviceList>
+</yamaha:X_device>
+</root>
diff --git a/tests/discoverables/yamaha_files/desc_multiple_services_no_remote_control.xml b/tests/discoverables/yamaha_files/desc_multiple_services_no_remote_control.xml
new file mode 100644
index 0000000..7b7873b
--- /dev/null
+++ b/tests/discoverables/yamaha_files/desc_multiple_services_no_remote_control.xml
@@ -0,0 +1,25 @@
+<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:yamaha="urn:schemas-yamaha-com:device-1-0">
+<device>
+<friendlyName>multi service friendly name</friendlyName>
+<modelName>multi service model name</modelName>
+</device>
+<yamaha:X_device>
+<yamaha:X_URLBase>http://192.168.1.2:80/</yamaha:X_URLBase>
+<yamaha:X_serviceList>
+<yamaha:X_service>
+<yamaha:X_specType>
+urn:schemas-yamaha-com:service:X_YamahaNewControl:1
+</yamaha:X_specType>
+<yamaha:X_controlURL>/YamahaNewControl/ctrl</yamaha:X_controlURL>
+<yamaha:X_unitDescURL>/YamahaNewControl/desc.xml</yamaha:X_unitDescURL>
+</yamaha:X_service>
+<yamaha:X_service>
+<yamaha:X_specType>
+urn:schemas-yamaha-com:service:X_YamahaExtendedControl:1
+</yamaha:X_specType>
+<yamaha:X_yxcControlURL>/YamahaExtendedControl/v1/</yamaha:X_yxcControlURL>
+<yamaha:X_yxcVersion>1131</yamaha:X_yxcVersion>
+</yamaha:X_service>
+</yamaha:X_serviceList>
+</yamaha:X_device>
+</root> \ No newline at end of file
diff --git a/tests/discoverables/yamaha_files/desc_multiple_services_remote_control_last.xml b/tests/discoverables/yamaha_files/desc_multiple_services_remote_control_last.xml
new file mode 100644
index 0000000..0dc46b0
--- /dev/null
+++ b/tests/discoverables/yamaha_files/desc_multiple_services_remote_control_last.xml
@@ -0,0 +1,25 @@
+<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:yamaha="urn:schemas-yamaha-com:device-1-0">
+<device>
+<friendlyName>multi service friendly name</friendlyName>
+<modelName>multi service model name</modelName>
+</device>
+<yamaha:X_device>
+<yamaha:X_URLBase>http://192.168.1.2:80/</yamaha:X_URLBase>
+<yamaha:X_serviceList>
+<yamaha:X_service>
+<yamaha:X_specType>
+urn:schemas-yamaha-com:service:X_YamahaExtendedControl:1
+</yamaha:X_specType>
+<yamaha:X_yxcControlURL>/YamahaExtendedControl/v1/</yamaha:X_yxcControlURL>
+<yamaha:X_yxcVersion>1131</yamaha:X_yxcVersion>
+</yamaha:X_service>
+<yamaha:X_service>
+<yamaha:X_specType>
+urn:schemas-yamaha-com:service:X_YamahaRemoteControl:1
+</yamaha:X_specType>
+<yamaha:X_controlURL>/YamahaRemoteControl/multi_ctrl</yamaha:X_controlURL>
+<yamaha:X_unitDescURL>/YamahaRemoteControl/multi_desc.xml</yamaha:X_unitDescURL>
+</yamaha:X_service>
+</yamaha:X_serviceList>
+</yamaha:X_device>
+</root> \ No newline at end of file
diff --git a/tests/discoverables/yamaha_files/desc_single_service.xml b/tests/discoverables/yamaha_files/desc_single_service.xml
new file mode 100644
index 0000000..e0e8109
--- /dev/null
+++ b/tests/discoverables/yamaha_files/desc_single_service.xml
@@ -0,0 +1,22 @@
+<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:yamaha="urn:schemas-yamaha-com:device-1-0">
+<specVersion>
+<major>1</major>
+<minor>0</minor>
+</specVersion>
+<device>
+<friendlyName>single service friendly name</friendlyName>
+<modelName>single service model name</modelName>
+</device>
+<yamaha:X_device>
+<yamaha:X_URLBase>http://192.168.1.2:80/</yamaha:X_URLBase>
+<yamaha:X_serviceList>
+<yamaha:X_service>
+<yamaha:X_specType>
+urn:schemas-yamaha-com:service:X_YamahaRemoteControl:1
+</yamaha:X_specType>
+<yamaha:X_controlURL>/YamahaRemoteControl/single_ctrl</yamaha:X_controlURL>
+<yamaha:X_unitDescURL>/YamahaRemoteControl/single_desc.xml</yamaha:X_unitDescURL>
+</yamaha:X_service>
+</yamaha:X_serviceList>
+</yamaha:X_device>
+</root> \ No newline at end of file
diff --git a/tests/test_xboxone.py b/tests/test_xboxone.py
new file mode 100644
index 0000000..d2aeb3c
--- /dev/null
+++ b/tests/test_xboxone.py
@@ -0,0 +1,60 @@
+"""The tests for discovering Xbox gaming consoles via SmartGlass protocol."""
+import unittest
+from binascii import unhexlify
+
+from netdisco.smartglass import XboxSmartGlass
+
+
+class TestXboxOne(unittest.TestCase):
+ """Test the Xbox One Discoverable."""
+ def setUp(self):
+ """
+ Setup test class
+ """
+ with open('tests/xboxone_files/discovery_response', 'rb') as content:
+ packet = content.read()
+
+ if not packet:
+ raise Exception('Failed to read test data')
+
+ self.discovery_response = packet
+
+ def test_assemble_request(self):
+ """
+ Test discovery request assembly
+ """
+ packet = XboxSmartGlass.discovery_packet()
+
+ self.assertEqual(
+ packet,
+ unhexlify(b'dd00000a000000000000000400000002')
+ )
+
+ def test_parse_response(self):
+ """
+ Test discovery response parsing
+ """
+ response = XboxSmartGlass.parse_discovery_response(
+ self.discovery_response)
+
+ self.assertEqual(response['device_type'], 1)
+ self.assertEqual(response['flags'], 2)
+ self.assertEqual(response['name'], 'XboxOne')
+ self.assertEqual(response['uuid'],
+ 'DE305D54-75B4-431B-ADB2-EB6B9E546014')
+ self.assertEqual(response['last_error'], 0)
+ self.assertEqual(response['certificate'][:8], '30820203')
+ self.assertEqual(len(unhexlify(response['certificate'])), 519)
+
+ def test_verify_response(self):
+ """
+ Test discovery response verification
+ """
+ valid_parse = XboxSmartGlass.verify_packet(self.discovery_response)
+ invalid_length = XboxSmartGlass.verify_packet(unhexlify(b'41'))
+ invalid_magic = XboxSmartGlass.verify_packet(
+ unhexlify(b'aabbccddeeff00'))
+
+ self.assertIsNotNone(valid_parse)
+ self.assertIsNone(invalid_length)
+ self.assertIsNone(invalid_magic)
diff --git a/tests/xboxone_files/discovery_response b/tests/xboxone_files/discovery_response
new file mode 100644
index 0000000..2f5cf46
--- /dev/null
+++ b/tests/xboxone_files/discovery_response
Binary files differ
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..03232c8
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,20 @@
+[tox]
+envlist = py34, py35, py36, py37, lint
+skip_missing_interpreters = True
+
+[testenv]
+setenv =
+ PYTHONPATH = {toxinidir}:{toxinidir}/netdisco
+deps =
+ -r{toxinidir}/requirements.txt
+ pytest
+commands =
+ py.test
+
+[testenv:lint]
+deps =
+ flake8
+ pylint
+commands =
+ flake8 netdisco tests setup.py example_service.py
+ pylint netdisco tests