Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Read all data from the device, disable double-encoding, implement more APIs, refactor querying, update README #11

Merged
merged 11 commits into from
Dec 12, 2016

Conversation

rytilahti
Copy link
Collaborator

The commit messages are pretty self-explanatory, so here's just some extra info:

  • My HS110 doesn't seem to send all the data for requests, causing incomplete JSON as response.
DEBUG:pyHS100.pyHS100:> (56) {"emeter": {"get_daystat": {"month": 11, "year": 2016}}}
DEBUG:pyHS100.pyHS100:Got chunk 1448
DEBUG:pyHS100.pyHS100:Got chunk 161
DEBUG:pyHS100.pyHS100:Got chunk 0
DEBUG:pyHS100.pyHS100:< (1605) {"emeter":{"get_daystat":{"day_list":[{"year":2016,"month":11,"day":1,"energy":0.404000},
  • Casting month & year to strings the following, could this be device specific? The older version used by Home Assistant seems to work fine on this case.
DEBUG:pyHS100.pyHS100:> (60) {"emeter": {"get_daystat": {"month": "12", "year": "2016"}}}
DEBUG:pyHS100.pyHS100:< (71) {"emeter":{"get_daystat":{"err_code":-3,"err_msg":"invalid argument"}}}

@kirichkov
Copy link
Collaborator

Can you give us a dump of the system info for your plug? You could be having different firmware version than mine which leads to those differences.

@rytilahti
Copy link
Collaborator Author

rytilahti commented Dec 1, 2016

Threre you go, I masked some information with XXXXXes.

DEBUG:pyHS100.pyHS100:< (580) {"system":
{"get_sysinfo":{"err_code":0,
"sw_ver":"1.0.8 Build 151101 Rel.24452",
"hw_ver":"1.0",
"type":"smartplug",
"model":"HS110(EU)"
,"mac":"50:C7:BF:XXXXXX",
"deviceId":"8006588E50AD38930XXXXXXXXXXXXXX",
"hwId":"45E29DA8382494D2EXXXXXXXX",
"fwId":"3A1C9C60B93A090DF074A74960BA60C0",
"oemId":"3D341ECE302C0642C99E31CEXXXXXXXXXXX",
"alias":"Media",
"dev_name":"Wi-Fi Smart Plug With Energy Monitoring",
"icon_hash":"",
"relay_state":1,
"on_time":9921,
"active_mode":"schedule",
"feature":"TIM:ENE",
"updating":0,
"rssi":-51,
"led_off":0,"latitude":XXXXX,"longitude":XXXXXX}}}

edit: made it readable
edit: to add, the responses are not always chunked, it just happens now and then (although more often than not it seems).

@kirichkov
Copy link
Collaborator

kirichkov commented Dec 2, 2016

I did a quick test (using the cli.py file) and your code seems to be working on both python 2 and 3. I think it'd be also good to bump the version to 0.2.2 in /setup.py and merge this.

EDIT: I'm not sure the cli.py should be part of the module, it does help, but it might be better used as an example in the README, rather than a standalone python script.

Thanks for sharing the model info. My plug appears to be the exact same hardware and software version like yours.
I haven't noticed the issue that you are describing with the chunked data, but I always suspected that socket read/writes are somehow related to the issue me and mweinelt are discussing in #3 .

@rytilahti
Copy link
Collaborator Author

I agree about cli.py, although maybe it could be placed into a separate dir like examples, and include a shorter usage example in README.md?

Unfortunately I don't have any historical data left as I moved my plug and cleared the device, but if you create a loop for daily you'd probably see the device delivering incomplete packets sometimes.

Unfortunately I have no insight what may cause the behavior in #3, the sockets seem to be closed properly, but in case it's not also done on the plug's end, maybe it runs out of file descriptors? One could test that by doing lots of queries and see if it stops being responsive.

@rytilahti
Copy link
Collaborator Author

rytilahti commented Dec 2, 2016

Uhuh, I may have made a mess by working on the same initial branch for new set of patches. If that's okay then fine, if not, I'll have to figure out how to rebase and split these changes. :)

edit: looks like I messed up some changes from others... Will have to check that out when I get some time for that. For now this can be put in pending mode.

@rytilahti rytilahti changed the title Read all data from the device, disable double-encoding Read all data from the device, disable double-encoding, implement more APIs, refactor querying, update README Dec 2, 2016
@mweinelt
Copy link
Collaborator

mweinelt commented Dec 2, 2016

Can you please rebase your commits onto the current state of the master branch?

chunk = sock.recv(4096)
buffer += chunk
#_LOGGER.debug("Got chunk %s" % len(chunk))
if len(chunk) == 0:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if not chuck:

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, didn't recall for sure whether empty b'' would've matched that, but it does.

@@ -318,12 +318,20 @@ def query(host, request, port=9999):

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
_LOGGER.debug("> (%i) %s" % (len(request), request))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String interpolation is deprecated, use str.format() instead.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

sock.shutdown(socket.SHUT_RDWR)
sock.close()

response = TPLinkSmartHomeProtocol.decrypt(buffer)
response = TPLinkSmartHomeProtocol.decrypt(buffer[4:])
_LOGGER.debug("< (%i) %s" % (len(response), response))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interpolation here as well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

logging.info("Sysinfo: %s" % hs.get_sysinfo())
has_emeter = hs.has_emeter
if has_emeter:
logging.info("== Emeter ==")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please indent with 4 spaces.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

logging.basicConfig(level=logging.DEBUG)

if len(sys.argv) < 2:
print("%s <ip>" % sys.argv[0])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please indent with 4 spaces.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

:return:
"""
keys = ["sw_ver", "hw_ver", "mac", "hwId", "fwId", "oemId", "dev_name"]
return {key:self.sys_info[key] for key in keys}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a whitespace after the colon.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Location of the device, as read from sysinfo
:return:
"""

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the empty line

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Returns WiFi signal strenth (rssi)
:return: rssi
"""

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the empty line

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Sets new mac address
:param mac: mac in hexadecimal with colons, e.g. 01:23:45:67:89:ab
"""

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the empty line

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Content for hash and icon are unknown.
:param icon:
"""

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the empty line

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@rytilahti rytilahti force-pushed the master branch 2 times, most recently from 2b6efd8 to 639d9fa Compare December 2, 2016 19:49
@rytilahti
Copy link
Collaborator Author

Thanks for the review and insightful comments :) I hope I managed to clean-up the mess I made by merging through github in my repo..

@mweinelt
Copy link
Collaborator

mweinelt commented Dec 2, 2016 via email

@rytilahti
Copy link
Collaborator Author

Agreed. The newest commit simplifies state handling by converting from strings to bools, e.g.
plug.state = "ON" is now plug.state = True

A couple of questions:

  • As all "active" actions do throw an exception in case of failure, it's probably a good idea to add :raises SmartPlugException to those for clients?
  • In case of description containing already what it returns, is it necessary to have that type of boilerplate there in :returns too?

@@ -23,8 +23,6 @@
_LOGGER = logging.getLogger(__name__)

# switch states
SWITCH_STATE_ON = 'on'
SWITCH_STATE_OFF = 'off'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep those and let people refer to them, just in case we decide to change the representation again.

else:
_LOGGER.warning("Unknown state %s returned.", relay_state)
_LOGGER.warning("Unknown state %s returned.".formaat(relay_state))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formaat

@kirichkov
Copy link
Collaborator

The reason why "ON" and "OFF" are used instead of Booleans is because the module is modeled after the D-Link module handling D-Link's smart plugs.

Also from readability stand point if we are to replace ON/OFF with True/False the name of state should be changed to something like is_on. I'm fine with keeping the internal representation like a Boolean, but the state property should still return "ON" / "OFF" first for backwards compatibility and second for readability. Two new methods can be added is_on and is_off Which return True/False.

@mweinelt
Copy link
Collaborator

mweinelt commented Dec 5, 2016

Yes, we should document raises: where necessary, so developers quickly see what they have to expect.

Regarding returns:, I think it should be added everywhere, where return is not None. The reasoning is consistency.

HS110 sends sometimes datagrams in chunks especially for get_daystat,
this patch makes it to read until there is no more data to be read.

As json.dumps() does JSON encoding already, there's no need to str()
the year or month either.
This commit adds access to new properties, both read & write,  while keeping the old one (mostly) intact.
Querying is refactored to be done inside _query_helper() method,
which unwraps results automatically and rises SmartPlugException() in case of errors.
Errors are to be handled by clients.

New features:
* Setting device alias (plug.alias = "name")
* led read & write
* icon read (doesn't seem to return anything without cloud support at least), write API is not known, throws an exception currently
* time read (returns datetime), time write implemented, but not working even when no error is returned from the device
* timezone read
* mac read & write, writing is untested for now.

Properties for easier access:
* hw_info: return hw-specific elements from sysinfo
* on_since: pretty-printed from sysinfo
* location: latitude and longitued from sysinfo
* rssi: rssi from sysinfo
Following issues are addressed by this commit:
* All API is more or less commented (including return types, exceptions, ..)
* Converted state to use
* Added properties is_on, is_off for those who don't want to check against strings.
* Handled most issues reported by pylint.
* Adjusted _query_helper() to strip off err_code from the result object.
* Fixed broken format() syntax for string formattings.
while True:
chunk = sock.recv(4096)
buffer += chunk
#_LOGGER.debug("Got chunk %s".format(len(chunk)))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

block comment should start with '# '

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really? Can fix though, I don't see it helping much in case of such debug messages, which may sometimes be useful to have. Better strip it off completely?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, better drop it.

:rtype: dict
"""

return {"latitude": self.sys_info["latitude"], "longitude": self.sys_info["longitude"]}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (95 > 79 characters)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.


if response['err_code'] != 0:
return False
response = self._query_helper("emeter", "get_monthstat", {'year': year})

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (80 > 79 characters)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


if response['err_code'] != 0:
return False
response = self._query_helper("emeter", "get_daystat", {'month': month, 'year': year})

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (94 > 79 characters)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


result = response[target][cmd]
if result["err_code"] != 0:
raise SmartPlugException("Error on {}.{}: {}".format(target, cmd, result))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (86 > 79 characters)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to keep this again for the sake of readability. Moving the call to format to a new line or splitting up the parameter list doesn't really improve reaadability.


:param target: Target system {system, time, emeter, ..}
:param cmd: Command to execute
:param arg: JSON object passed as parameter to the command, defualts to {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (82 > 79 characters)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also typo "defualts"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed "defualts".

@@ -22,6 +22,11 @@

_LOGGER = logging.getLogger(__name__)

class SmartPlugException(Exception):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected 2 blank lines, found 1

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -1 +1 @@

from pyHS100.pyHS100 import SmartPlug

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'SmartPlug' imported but unused

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False alarm. import pyHS100 allows using pyHS100.SmartPlug as well as from pyHS100 import SmartPlug for library users. Can be reverted though, if from pyHS100.pyHS100 import SmartPlug is more wanted.

def _query_helper(self, target, cmd, arg={}):
"""
Query helper, returns unwrapped result object.
Raises SmartPlugException in case of error reported by the plug.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use :raises and move after last param for consistency.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's later on there. This is just the descriptive string for it.

@rytilahti
Copy link
Collaborator Author

Fixed quite a few things, as earlier, commit message contains the changes. Btw, pylint complains that pyHS100 is not a valid name :-)

:rtype: datetime
"""
return datetime.datetime.now() - \
datetime.timedelta(seconds=self.sys_info["on_time"])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

continuation line over-indented for visual indent

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again for readability's sake. If wanted, we could import datetime & timedelta directly and avoid typing datetime twice/at all, if wanted.

"""
response = self._query_helper("time", "get_time")
return datetime.datetime(response["year"], response["month"], response["mday"],
response["hour"], response["min"], response["sec"])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (84 > 79 characters)

:raises SmartPlugException: on error
"""
response = self._query_helper("time", "get_time")
return datetime.datetime(response["year"], response["month"], response["mday"],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (87 > 79 characters)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, it's already splitted. Changing response to rsp or r doesn't improve readability.

"""
Returns device icon

Note: this doesn't seem to work (at least) when not using the cloud service.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (84 > 79 characters)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better remove this altogether, if this is an issue. It's for there for devels to see/note that there may be an issue there..


:param target: Target system {system, time, emeter, ..}
:param cmd: Command to execute
:param arg: JSON object passed as parameter to the command, defaults to {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (82 > 79 characters)

@mweinelt
Copy link
Collaborator

mweinelt commented Dec 6, 2016

Yes, the name should be PyHS100 instead - or at least start with a captical character. But let's keep that for another PR, this one's large enough already.

@rytilahti
Copy link
Collaborator Author

rytilahti commented Dec 6, 2016

Yeah, it's my failure to mess up that much with using proper feature branches, wasn't thinking it would gain so many commits in the end.
edit: btw, it'd make sense to have unit tests for the state machine & error handling functions, although I wouldn't like to have that either in this commit series, but do it separately.

@mweinelt
Copy link
Collaborator

mweinelt commented Dec 6, 2016

Agreed, unit testing makes total sense.

@rytilahti
Copy link
Collaborator Author

On related note, perhaps LED api should also follow the "legacy" ON/OFF for state values, even if I don't really like strings as return values/parameters?

@mweinelt
Copy link
Collaborator

mweinelt commented Dec 6, 2016

I agree True/False should be used, not strings.

:raises SmartPlugException: on error
"""
raise NotImplementedError("Fails with err_code == 0 with HS110.")
""" here just for the sake of completeness / if someone figures out why it doesn't work.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (88 > 79 characters)

return response['err_code'] == 0
self.initialize()

# As query_helper raises exception in case of failure, we have succeeded when we are this far.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (102 > 79 characters)


:param target: Target system {system, time, emeter, ..}
:param cmd: Command to execute
:param arg: JSON object passed as parameter to the command, defaults to {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (82 > 79 characters)

@rytilahti
Copy link
Collaborator Author

Adjusted led setter to use True/False instead of ints, fix some styling issues (keep whitespaces in docstrings between headline & other elements. Outed initialization out from init() and force-calling it after all API calls changing the state of the device.

I don't really like the initialize solution, maybe sysinfo content shouldbe kept locally and just updated based on responses from the plug. Opinions?

@mweinelt
Copy link
Collaborator

@GadgetReactor Can we get this merged?

@GadgetReactor GadgetReactor merged commit 05a6bbb into GadgetReactor:master Dec 12, 2016
@GadgetReactor
Copy link
Owner

thanks!

@rytilahti
Copy link
Collaborator Author

Thanks for merging, next step is to clean the code base a bit & prepare for easier testing :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants