#!/usr/bin/env python3.6

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you 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.

from struct import pack, unpack
import time
import datetime
import bluepy.btle as ble
import binascii
import math
from collections import namedtuple

Fanspeeds = namedtuple('Fanspeeds', 'Humidity Light Trickle')
Fanspeeds.__new__.__defaults__ = (2250, 1625, 1000)
Time = namedtuple('Time', 'DayOfWeek Hour Minute Second')
Sensitivity = namedtuple('Sensitivity', 'HumidityOn Humidity LightOn Light')
LightSensorSettings = namedtuple('LightSensorSettings', 'DelayedStart RunningTime')
HeatDistributorSettings = namedtuple('HeatDistributorSettings', 'TemperatureLimit FanSpeedBelow FanSpeedAbove')
SilentHours = namedtuple('SilentHours', 'On StartingHour StartingMinute EndingHour EndingMinute')
TrickleDays = namedtuple('TrickleDays', 'Weekdays Weekends')
BoostMode = namedtuple('BoostMode', 'OnOff Speed Seconds')

FanState = namedtuple('FanState', 'Humidity Temp Light RPM Mode')

# Stolen defines for each characteristic (taken from a decompiled Android App)
CHARACTERISTIC_APPEARANCE = "00002a01-0000-1000-8000-00805f9b34fb"
CHARACTERISTIC_AUTOMATIC_CYCLES = "f508408a-508b-41c6-aa57-61d1fd0d5c39"
CHARACTERISTIC_BASIC_VENTILATION = "faa49e09-a79c-4725-b197-bdc57c67dc32"
CHARACTERISTIC_BOOST = "118c949c-28c8-4139-b0b3-36657fd055a9"
CHARACTERISTIC_CLOCK = "6dec478e-ae0b-4186-9d82-13dda03c0682"
CHARACTERISTIC_DEVICE_NAME = "00002a00-0000-1000-8000-00805f9b34fb"
CHARACTERISTIC_FACTORY_SETTINGS_CHANGED = "63b04af9-24c0-4e5d-a69c-94eb9c5707b4"
CHARACTERISTIC_FAN_DESCRIPTION = "b85fa07a-9382-4838-871c-81d045dcc2ff"
CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb"
CHARACTERISTIC_HARDWARE_REVISION = "00002a27-0000-1000-8000-00805f9b34fb"
CHARACTERISTIC_LED = "8b850c04-dc18-44d2-9501-7662d65ba36e"
CHARACTERISTIC_LEVEL_OF_FAN_SPEED = "1488a757-35bc-4ec8-9a6b-9ecf1502778e"
CHARACTERISTIC_MANUFACTURER_NAME = "00002a29-0000-1000-8000-00805f9b34fb"
CHARACTERISTIC_MODE = "90cabcd1-bcda-4167-85d8-16dcd8ab6a6b"
CHARACTERISTIC_MODEL_NUMBER = "00002a24-0000-1000-8000-00805f9b34fb"
CHARACTERISTIC_NIGHT_MODE = "b5836b55-57bd-433e-8480-46e4993c5ac0"
CHARACTERISTIC_PIN_CODE = "4cad343a-209a-40b7-b911-4d9b3df569b2"
CHARACTERISTIC_PIN_CONFIRMATION = "d1ae6b70-ee12-4f6d-b166-d2063dcaffe1"
CHARACTERISTIC_RESET = "ff5f7c4f-2606-4c69-b360-15aaea58ad5f"
CHARACTERISTIC_SENSITIVITY = "e782e131-6ce1-4191-a8db-f4304d7610f1"
CHARACTERISTIC_SENSOR_DATA = "528b80e8-c47a-4c0a-bdf1-916a7748f412"
CHARACTERISTIC_SERIAL_NUMBER = "00002a25-0000-1000-8000-00805f9b34fb"
CHARACTERISTIC_SOFTWARE_REVISION = "00002a28-0000-1000-8000-00805f9b34fb"
CHARACTERISTIC_STATUS = "25a824ad-3021-4de9-9f2f-60cf8d17bded"
CHARACTERISTIC_TEMP_HEAT_DISTRIBUTOR = "a22eae12-dba8-49f3-9c69-1721dcff1d96"
CHARACTERISTIC_TIME_FUNCTIONS = "49c616de-02b1-4b67-b237-90f66793a6f2"

def FindCalimas():
    scanner = ble.Scanner()
    devices = scanner.scan()
    calimas = filter(lambda dev: dev.addr[0:8] == "58:2b:db", devices)
    return tuple(map(lambda dev: dev.addr, calimas))

class Calima:

    def __init__(self, addr, pin):
        # Set debug to true if you want more verbose output
        self._debug = False
        self.conn = ble.Peripheral(deviceAddr=addr)
        self.setAuth(pin)

    def __del__(self):
        self.conn.disconnect()

    def disconnect(self):
        self.conn.disconnect()

    def _bToStr(self, val):
        return binascii.b2a_hex(val).decode('utf-8')

    def _readUUID(self, uuid):
        val = self.conn.getCharacteristics(uuid=uuid)[0].read()
        if (self._debug):
            print("[Calima] [R] %s = %s" % (uuid, self._bToStr(val)))

        return val

    def _readHandle(self, handle):
        val = self.conn.readCharacteristic(handle)
        if (self._debug):
            print("[Calima] [R] %s = %s" % (hex(handle), self._bToStr(val)))
        return val

    def _writeUUID(self, uuid, val):
        if (self._debug):
            print("[Calima] [W] %s = %s" % (uuid, self._bToStr(val)))

        self.conn.getCharacteristics(uuid=uuid)[0].write(val, withResponse=True)

    def scanCharacteristics(self):
        val = self.conn.getCharacteristics()
        for ch in val:
            if (ch.supportsRead()):
                rd = ch.read()
                print("[%s] %s (%s) = (%d) %s" % (hex(ch.getHandle()), ch.uuid.getCommonName(), ch.propertiesToString(), len(rd), self._bToStr(rd)))
            else:
                print("[%s] %s (%s)" % (hex(ch.getHandle()), ch.uuid.getCommonName(), ch.propertiesToString()))

    # --- Generic GATT Characteristics

    def getDeviceName(self):
        return self._readHandle(0x3).decode('ascii')

    def getModelNumber(self):
        return self._readHandle(0xd).decode('ascii')

    def getSerialNumber(self):
        return self._readHandle(0xb).decode('ascii')

    def getHardwareRevision(self):
        return self._readHandle(0xf).decode('ascii')

    def getFirmwareRevision(self):
        return self._readHandle(0x11).decode('ascii')

    def getSoftwareRevision(self):
        return self._readHandle(0x13).decode('ascii')

    def getManufacturer(self):
        return self._readHandle(0x15).decode('ascii')

    # --- Onwards to PAX characteristics

    def setAuth(self, pin):
        self._writeUUID(CHARACTERISTIC_PIN_CODE, pack("<I", int(pin)))

    def checkAuth(self):
        return bool(unpack('<I', self._readUUID(CHARACTERISTIC_PIN_CONFIRMATION)))

    def setAlias(self, name):
        self._writeUUID(CHARACTERISTIC_FAN_DESCRIPTION, pack('20s', bytearray(name, 'utf-8')))

    def getAlias(self):
        return self._readUUID(CHARACTERISTIC_FAN_DESCRIPTION).decode('utf-8')

    def getIsClockSet(self):
        return self._bToStr(self._readUUID(CHARACTERISTIC_STATUS))

    def getState(self):
        # Short Short Short Short    Byte Short Byte
        # Hum   Temp  Light FanSpeed Mode Tbd   Tbd
        v = unpack('<4HBHB', self._readUUID(CHARACTERISTIC_SENSOR_DATA))
        trigger = "No trigger"
        if ((v[4] >> 4) & 1) == 1:
            trigger = "Boost"
        elif (v[4] & 3) == 1:
            trigger = "Trickle ventilation"
        elif (v[4] & 3) == 2:
            trigger = "Light ventilation"
        elif (v[4] & 3) == 3: # Note that the trigger might be active, but mode must be enabled to be activated
            trigger = "Humidity ventilation"
        return FanState(round(math.log2(v[0])*10, 2) if v[0] > 0 else 0, v[1]/4, v[2], v[3], trigger)

    def getFactorySettingsChanged(self):
        return unpack('<?', self._readUUID(CHARACTERISTIC_FACTORY_SETTINGS_CHANGED))

    def getMode(self):
        v = unpack('<B', self._readUUID(CHARACTERISTIC_MODE))
        if v == 0:
            return "MultiMode"
        elif v == 1:
            return "DraftShutterMode"
        elif v == 2:
            return "WallSwitchExtendedRuntimeMode"
        elif v == 3:
            return "WallSwitchNoExtendedRuntimeMode"
        elif v == 4:
            return "HeatDistributionMode"

    def setFanSpeedSettings(self, humidity=2250, light=1625, trickle=1000):
        for val in (humidity, light, trickle):
            if (val % 25 != 0):
                raise ValueError("Speeds should be multiples of 25")
            if (val > 2500 or val < 0):
                raise ValueError("Speeds must be between 0 and 2500 rpm")

        self._writeUUID(CHARACTERISTIC_LEVEL_OF_FAN_SPEED, pack('<HHH', humidity, light, trickle))

    def getFanSpeedSettings(self):
        return Fanspeeds._make(unpack('<HHH', self._readUUID(CHARACTERISTIC_LEVEL_OF_FAN_SPEED)))

    def setSensorsSensitivity(self, humidity, light):
        if humidity > 3 or humidity < 0:
            raise ValueError("Humidity sensitivity must be between 0-3")
        if light > 3 or light < 0:
            raise ValueError("Light sensitivity must be between 0-3")

        value = pack('<4B', bool(humidity), humidity, bool(light), light)
        self._writeUUID(CHARACTERISTIC_SENSITIVITY, value)

    def getSensorsSensitivity(self):
        # Hum Active | Hum Sensitivity | Light Active | Light Sensitivity
        return Sensitivity._make(unpack('<4B', self._readUUID(CHARACTERISTIC_SENSITIVITY)))

    def setLightSensorSettings(self, delayed, running):
        if delayed not in (0, 5, 10):
            raise ValueError("Delayed must be 0, 5 or 10 minutes")
        if running not in (5, 10, 15, 30, 60):
            raise ValueError("Running time must be 5, 10, 15, 30 or 60 minutes")

        self._writeUUID(CHARACTERISTIC_TIME_FUNCTIONS, pack('<2B', delayed, running))

    def getLightSensorSettings(self):
        return LightSensorSettings._make(unpack('<2B', self._readUUID(CHARACTERISTIC_TIME_FUNCTIONS)))

    def getHeatDistributor(self):
        return HeatDistributorSettings._make(unpack('<BHH', self._readUUID(CHARACTERISTIC_TEMP_HEAT_DISTRIBUTOR)))

    def setBoostMode(self, on, speed, seconds):
        if speed % 25:
            raise ValueError("Speed must be a multiple of 25")
        if not on:
            speed = 0
            seconds = 0

        self._writeUUID(CHARACTERISTIC_BOOST, pack('<BHH', on, speed, seconds))

    def getBoostMode(self):
        return BoostMode._make(unpack('<BHH', self._readUUID(CHARACTERISTIC_BOOST)))

    def getLed(self):
        return self._bToStr(self._readUUID(CHARACTERISTIC_LED))

    def setAutomaticCycles(self, setting):
        if setting < 0 or setting > 3:
            raise ValueError("Setting must be between 0-3")

        self._writeUUID(CHARACTERISTIC_AUTOMATIC_CYCLES, pack('<B', setting))

    def getAutomaticCycles(self):
        return unpack('<B', self._readUUID(CHARACTERISTIC_AUTOMATIC_CYCLES))[0]

    def setTime(self, dayofweek, hour, minute, second):
        self._writeUUID(CHARACTERISTIC_CLOCK, pack('<4B', dayofweek, hour, minute, second))

    def getTime(self):
        return Time._make(unpack('<BBBB', self._readUUID(CHARACTERISTIC_CLOCK)))

    def setTimeToNow(self):
        now = datetime.datetime.now()
        self.setTime(now.isoweekday(), now.hour, now.minute, now.second)

    def setSilentHours(self, on, startingHours, startingMinutes, endingHours, endingMinutes):
        if startingHours < 0 or startingHours > 23:
            raise ValueError("Starting hour is an invalid number")
        if endingHours < 0 or endingHours > 23:
            raise ValueError("Ending hour is an invalid number")
        if startingMinutes < 0 or startingMinutes > 59:
            raise ValueError("Starting minute is an invalid number")
        if endingMinutes < 0 or endingMinutes > 59:
            raise ValueError("Ending minute is an invalid number")

        value = pack('<5B', int(on),
                     startingHours, startingMinutes,
                     endingHours, endingMinutes)
        self._writeUUID(CHARACTERISTIC_NIGHT_MODE, value)

    def getSilentHours(self):
        return SilentHours._make(unpack('<5B', self._readUUID(CHARACTERISTIC_NIGHT_MODE)))

    def setTrickleDays(self, weekdays, weekends):
        self._writeUUID(CHARACTERISTIC_BASIC_VENTILATION, pack('<2B', weekdays, weekends))

    def getTrickleDays(self):
        return TrickleDays._make(unpack('<2B', self._readUUID(CHARACTERISTIC_BASIC_VENTILATION)))

    def getReset(self): # Should be write
        return self._readUUID(CHARACTERISTIC_RESET)

    def resetDevice(self): # Dangerous
        self._writeUUID(CHARACTERISTIC_RESET, pack('<I', 120))

    def resetValues(self): # Danguerous
        self._writeUUID(CHARACTERISTIC_RESET, pack('<I', 85))