Skip to content

Commit

Permalink
Update POW, TH attrs and Zeroconf
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexxIT committed Aug 2, 2020
1 parent 5d068fa commit 65f5b02
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 83 deletions.
30 changes: 17 additions & 13 deletions custom_components/sonoff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@

from . import utils
from .sonoff_camera import EWeLinkCameras
from .sonoff_cloud import ConsumptionHelper
from .sonoff_main import EWeLinkRegistry, get_attrs
from .sonoff_cloud import fix_attrs, CloudPowHelper
from .sonoff_main import EWeLinkRegistry

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -160,7 +160,7 @@ def add_device(deviceid: str, state: dict, *args):
_LOGGER.debug(f"{deviceid} == Init | {info}")

# fix cloud attrs like currentTemperature and currentHumidity
get_attrs(state)
fix_attrs(deviceid, state)

# set device force_update if needed
if force_update and force_update & state.keys():
Expand Down Expand Up @@ -216,15 +216,6 @@ async def send_command(call: ServiceCall):

hass.services.async_register(DOMAIN, 'send_command', send_command)

async def update_consumption(call: ServiceCall):
if not hasattr(registry, 'consumption'):
_LOGGER.debug("Create ConsumptionHelper")
registry.consumption = ConsumptionHelper(registry.cloud)
await registry.consumption.update()

hass.services.async_register(DOMAIN, 'update_consumption',
update_consumption)

if CONF_SCAN_INTERVAL in config:
global SCAN_INTERVAL
SCAN_INTERVAL = config[CONF_SCAN_INTERVAL]
Expand All @@ -242,9 +233,22 @@ async def update_consumption(call: ServiceCall):

await registry.cloud_start()

pow_helper = CloudPowHelper(registry.cloud)
if pow_helper.devices:
# backward compatibility for manual update consumption
async def update_consumption(call: ServiceCall):
await pow_helper.update_consumption()

hass.services.async_register(DOMAIN, 'update_consumption',
update_consumption)

if mode in ('auto', 'local'):
# add devices only on first discovery
await registry.local_start([add_device])
zeroconf = await utils.get_zeroconf_singleton(hass)
await registry.local_start([add_device], zeroconf)

if mode == 'auto':
registry.local.sync_temperature = True

# cameras starts only on first command to it
cameras = EWeLinkCameras()
Expand Down
4 changes: 3 additions & 1 deletion custom_components/sonoff/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"domain": "sonoff",
"name": "Sonoff",
"documentation": "https://github.com/AlexxIT/SonoffLAN",
"dependencies": [],
"dependencies": [
"zeroconf"
],
"codeowners": [
"AlexxIT"
],
Expand Down
128 changes: 106 additions & 22 deletions custom_components/sonoff/sonoff_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,34 @@
"Read more: https://github.com/AlexxIT/SonoffLAN#config-examples")


def fix_attrs(deviceid: str, state: dict):
"""
- Sonoff TH `currentTemperature: "24.7"`
- Sonoff TH `currentTemperature: "unavailable"`
- Sonoff ZigBee: `temperature: "2096"`
- Sonoff SC: `temperature: 23`
- Sonoff POW: `power: "12.78"`
"""
try:
# https://github.com/AlexxIT/SonoffLAN/issues/110
if 'currentTemperature' in state:
state['temperature'] = float(state['currentTemperature'])
if 'currentHumidity' in state:
state['humidity'] = float(state['currentHumidity'])

for k in ('power', 'voltage', 'current'):
if k in state:
state[k] = float(state[k])

# zigbee device
if deviceid.startswith('a4'):
for k in ('temperature', 'humidity'):
if k in state:
state[k] = int(state[k]) / 100.0
except:
pass


class ResponseWaiter:
"""Class wait right sequences in response messages."""
_waiters = {}
Expand Down Expand Up @@ -57,7 +85,18 @@ async def _wait_response(self, sequence: str, timeout: int = 5):
return self._waiters.pop(sequence).result()


class EWeLinkCloud(ResponseWaiter):
class EWeLinkApp:
# App v3
appid = 'oeVkj2lYFGnJu5XUtWisfW4utiN4u9Mq'
appsecret = '6Nz4n0xA8s8qdxQf2GqurZj2Fs55FUvM'

def init_app_v4(self):
_LOGGER.debug("Init app v4")
self.appid = 'Uw83EKZFxdif7XFXEsrpduz5YyjP7nTl'
self.appsecret = 'mXLOjea0woSMvK9gw7Fjsy7YlFO4iSu6'


class EWeLinkCloud(ResponseWaiter, EWeLinkApp):
devices: dict = None
_handlers = None
_ws: Optional[ClientWebSocketResponse] = None
Expand All @@ -80,7 +119,7 @@ async def _api(self, mode: str, api: str, payload: dict) \
"""
ts = int(time.time())
payload.update({
'appid': 'oeVkj2lYFGnJu5XUtWisfW4utiN4u9Mq',
'appid': self.appid,
'nonce': str(ts), # 8-digit random alphanumeric characters
'ts': ts, # 10-digit standard timestamp
'version': 8
Expand All @@ -95,7 +134,7 @@ async def _api(self, mode: str, api: str, payload: dict) \
coro = self.session.get(self._baseurl + api, params=payload,
headers={'Authorization': auth})
elif mode == 'login':
hex_dig = hmac.new(b'6Nz4n0xA8s8qdxQf2GqurZj2Fs55FUvM',
hex_dig = hmac.new(self.appsecret.encode(),
json.dumps(payload).encode(),
digestmod=hashlib.sha256).digest()
auth = "Sign " + base64.b64encode(hex_dig).decode()
Expand Down Expand Up @@ -134,12 +173,7 @@ async def _process_ws_msg(self, data: dict):
device['online'] = True
state['cloud'] = 'online'

# TODO: fix when Sonoff TH arrives to me
# https://github.com/AlexxIT/SonoffLAN/issues/110
if state.get('currentTemperature') == 'unavailable':
del state['currentTemperature']
if state.get('currentHumidity') == 'unavailable':
del state['currentHumidity']
fix_attrs(deviceid, state)

for handler in self._handlers:
handler(deviceid, state, data.get('seq'))
Expand Down Expand Up @@ -173,15 +207,15 @@ async def _connect(self, fails: int = 0):
try:
url = f"wss://{resp['IP']}:{resp['port']}/api/ws"
self._ws = await self.session.ws_connect(
url, heartbeat=55, ssl=False)
url, heartbeat=145, ssl=False)

ts = time.time()
payload = {
'action': 'userOnline',
'at': self._token,
'apikey': self._apikey,
'userAgent': 'app',
'appid': 'oeVkj2lYFGnJu5XUtWisfW4utiN4u9Mq',
'appid': self.appid,
'nonce': str(int(ts / 100)),
'ts': int(ts),
'version': 8,
Expand All @@ -192,9 +226,9 @@ async def _connect(self, fails: int = 0):
msg: WSMessage = await self._ws.receive()
_LOGGER.debug(f"Cloud init: {json.loads(msg.data)}")

async for msg in self._ws:
fails = 0
fails = 0

async for msg in self._ws:
if msg.type == WSMsgType.TEXT:
resp = json.loads(msg.data)
await self._process_ws_msg(resp)
Expand Down Expand Up @@ -245,6 +279,10 @@ async def login(self, username: str, password: str) -> bool:
payload = {pname: username, 'password': password}
resp = await self._api('login', 'api/user/login', payload)

if resp.get('error') == 406:
self.init_app_v4()
resp = await self._api('login', 'api/user/login', payload)

if resp is None or 'region' not in resp:
_LOGGER.error(f"Login error: {resp}")
return False
Expand Down Expand Up @@ -289,7 +327,23 @@ async def send(self, deviceid: str, data: dict, sequence: str):
:param data: example `{'switch': 'on'}`
:param sequence: 13-digit standard timestamp, to verify uniqueness
"""

# protect cloud from DDoS (it can break connection)
while sequence in self._waiters or sequence is None:
await asyncio.sleep(0.1)
sequence = str(int(time.time() * 1000))
self._waiters[sequence] = None

payload = {
'action': 'query',
'apikey': self.devices[deviceid]['apikey'],
'selfApikey': self._apikey,
'deviceid': deviceid,
'params': [],
'userAgent': 'app',
'sequence': sequence,
'ts': 0
} if '_query' in data else {
'action': 'update',
# device apikey for shared devices
'apikey': self.devices[deviceid]['apikey'],
Expand All @@ -307,14 +361,32 @@ async def send(self, deviceid: str, data: dict, sequence: str):
return await self._wait_response(sequence)


class ConsumptionHelper:
class CloudPowHelper:
def __init__(self, cloud: EWeLinkCloud):
# search pow devices
self.devices = [
device for device in cloud.devices.values()
if 'params' in device and 'uiActive' in device['params']]
if not self.devices:
return

self.cloud = cloud

_LOGGER.debug(f"Start refresh task for {len(self.devices)} POW")

# noinspection PyProtectedMember
self._cloud_process_ws_msg = cloud._process_ws_msg
cloud._process_ws_msg = self._process_ws_msg

asyncio.create_task(self.update())

async def _process_ws_msg(self, data: dict):
if 'config' in data and 'hundredDaysKwhData' in data['config']:
if 'params' in data and data['params'].get('uiActive') == 60:
deviceid = data['deviceid']
device = self.cloud.devices[deviceid]
device['powActiveTime'] = 0

elif 'config' in data and 'hundredDaysKwhData' in data['config']:
# 000002 000207 000003 000002 000201 000008 000003 000006...
kwh = data['config'].pop('hundredDaysKwhData')
kwh = [round(int(kwh[i:i + 2], 16) +
Expand All @@ -325,11 +397,23 @@ async def _process_ws_msg(self, data: dict):
await self._cloud_process_ws_msg(data)

async def update(self):
if not self.cloud.started:
return

for device in self.cloud.devices.values():
if 'params' in device and 'hundredDaysKwh' in device['params']:
sequence = str(int(time.time() * 1000))
if self.cloud.started:
t = time.time()
for device in self.devices:
if t - device.get('powActiveTime', 0) > 3600:
device['powActiveTime'] = t
# set pow active status for 2 hours
await self.cloud.send(device['deviceid'], {
'uiActive': 7200}, None)

# sleep for 1 minute
await asyncio.sleep(60)

asyncio.create_task(self.update())

async def update_consumption(self):
if self.cloud.started:
_LOGGER.debug("Update consumption for all devices")
for device in self.devices:
await self.cloud.send(device['deviceid'], {
'hundredDaysKwh': 'get'}, sequence)
'hundredDaysKwh': 'get'}, None)
28 changes: 20 additions & 8 deletions custom_components/sonoff/sonoff_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,27 +114,30 @@ def ifan02to03(payload: dict) -> dict:
class EWeLinkLocal:
_devices: dict = None
_handlers = None
_zeroconf = None
browser = None

# cut temperature for sync to cloud API
sync_temperature = False

def __init__(self, session: ClientSession):
self.session = session
self.loop = asyncio.get_event_loop()

@property
def started(self) -> bool:
return self._zeroconf is not None
return self.browser is not None

def start(self, handlers: List[Callable], devices: dict = None):
def start(self, handlers: List[Callable], devices: dict, zeroconf):
self._handlers = handlers
self._devices = devices
self._zeroconf = Zeroconf()
browser = ServiceBrowser(self._zeroconf, '_ewelink._tcp.local.',
handlers=[self._zeroconf_handler])
self.browser = ServiceBrowser(zeroconf, '_ewelink._tcp.local.',
handlers=[self._zeroconf_handler])
# for beautiful logs
browser.name = 'Sonoff_LAN'
self.browser.name = 'Sonoff_LAN'

def stop(self, *args):
self._zeroconf.close()
self.browser.cancel()
self.browser.zc.close()

def _zeroconf_handler(self, zeroconf: Zeroconf, service_type: str,
name: str, state_change: ServiceStateChange):
Expand Down Expand Up @@ -187,6 +190,10 @@ def _zeroconf_handler(self, zeroconf: Zeroconf, service_type: str,
if state.get('temperature') == 0 and state.get('humidity') == 0:
del state['temperature'], state['humidity']

elif 'temperature' in state and self.sync_temperature:
# cloud API send only one decimal (not round)
state['temperature'] = int(state['temperature'] * 10) / 10.0

if properties['type'] == 'fan_light':
state = ifan03to02(state)
device['uiid'] = 'fan_light'
Expand Down Expand Up @@ -246,6 +253,11 @@ async def check_offline(self, deviceid: str):
async def send(self, deviceid: str, data: dict, sequence: str, timeout=5):
device: dict = self._devices[deviceid]

if '_query' in data:
data = {'cmd': 'signal_strength'} \
if data['_query'] is None else \
{'sledonline': data['_query']}

if device['uiid'] == 'fan_light' and 'switches' in data:
data = ifan02to03(data)

Expand Down
Loading

0 comments on commit 65f5b02

Please sign in to comment.