""" 
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""

import asyncio as aio
import math
from time import time_ns
from collections import defaultdict
from bleak import BleakGATTCharacteristic, BleakClient
from inspect import iscoroutinefunction
from warnings import warn


class BatteryLevel:
    """ Access battery characteristic; the read method returns battery 
    charge status as a percentage """
    CHARACTERISTIC="00002a19-0000-1000-8000-00805f9b34fb"

    def __init__(self, client: BleakClient):
        """ Init the BatteryLevel object. 
        Args: client - the BleakClient connection object for the BLE device
        """
        self.client=client

    async def read(self):
        """ Reads the batter level characteristic. Returns the battery level
        as a percentage of full charge """
        data=await self.client.read_gatt_char(BatteryLevel.CHARACTERISTIC)
        return data[0]



class HeartRate:
    """ Access heart rate service as specified by the BLE SIG - this
    should work with all devices following the specification. Frames 
    are written to an asyncio queue or passed to a callback. Heart rate
    can be as returned by the device (typically averaged) or the instant
    rate, if RR intervals are supported. Heart rate data can be passed to
    the queue/callback as received (a frame may contain data for more than
    one heartbeat) or unpacked as individual heartbeats.

    If unpacking is not required, data is pushed to the queue/passed to 
    the callback in the following format:
        ('HR', tstamp, (avghr, rrlist), energy)
    where 'HR' is a constant string, tstamp is the (client) time stamp in ns, 
    avghr the average heart rate as detected by the sensor, rrlist is a list
    of RR values in the frame (in ms, if supported), and energy is the energy 
    expenditure (in kJoule, if supported).

    If unpacking is required, individual rr interval information is 
    separated and data is formatted as follows:
        ('HR', t_est, (hr, rr), energy)
    where t_est is the estimated time stamp of the individual heartbeat, and
    hr can be either the average heart rate returned by the sensor or the 
    instant heart rate as computed from the specific RR interval.
    """
    
    CHARACTERISTIC="00002a37-0000-1000-8000-00805f9b34fb"

    def __init__(self, client: BleakClient, queue: aio.Queue=None,
                 callback=None, contact_callback=None,
                 contact_lost_callback=None,
                 instant_rate=False, unpack=True):
        """
        Init the HeartRate object.

        Args:

        client: the BleakClient connection object for the BLE device
        queue:  an asyncio queue onto which heart rate data is pushed;
                alternatively, you can specify a callback
        callback: a function to which heart rate data is passed. If queue
                is specified, this parameter is ignored.
        contact_callback: a function/coroutine that is called or awaited
                when the sensor skin contact bit goes from 0 to 1 (if
                supported). Also see the good_contact attribute
        contact_lost_callback: a function/coroutine that is called or 
                awaited when the sensor skin contact bit goes from 1 to 0
                (if supported). Also see the lost_contact attribute
        instant_rate: if True, the average heart rate returned by the sensor
                is replaced by the instant rate computed from individual
                RR intervals. Only works if unpack it True
        unpack: if True, data in sensor frames is  unpacked and processed as 
                individual heartbeats. Only works if RR intervals are 
                supported

        Attributes:

        contact_detection: True if the sensor supports skin contact detection,
                False otherwise. This attribute is None until the first
                frame is received.
        good_contact: an awaitable asyncio Event that is set when the sensor
                reports good skin contact (if supported) and cleared when it
                reports poor contact.
        lost_contact: an awaitable asyncio Event that is set when the sensor
                reports poor skin contact (if supported) and cleared when it
                reports good contact.
        """
        if unpack==False and instant_rate==True:
            raise RuntimeError("instant_rate only supported when unpack==True")
        self.client=client
        self.instant_rate=instant_rate
        self.unpack=unpack
        # must have callback or queue for hr signal. callback ignoed
        # if queue is specified
        if queue==None and callback==None:
            raise RuntimeError("No queue or callback given for HR signal")
        # _callback is passed sensor frames by handler
        self._callback=queue.put_nowait if queue!=None else callback
        self._callback_is_coro=iscoroutinefunction(self._callback)
        # contact detection
        self.good_contact=aio.Event()
        self.lost_contact=aio.Event()
        self.contact_detection=None
        self.filter_nocontact=False
        # contact established callback
        self._contact_callback=contact_callback
        self._contact_callback_is_coro=(iscoroutinefunction(contact_callback)
                                        if contact_callback!=None else None)
        # contact lost callback
        self._lost_callback=contact_lost_callback
        self._lost_callback_is_coro=(iscoroutinefunction(self._lost_callback)
                                     if self._lost_callback!=None else None)


    def _decode(self, data: bytearray):
        """
        See www.bluetooth.com/specifications/specs/heart-rate-service-1-0/ 
        for the structure of the frame.
        NOTE: Polar H10 does not support contact bit or energy expenditure,
        so these features are untested
        """
        # the first byte contains flags
        flags = data[0]
        payload={}

        # format for hr: 1 or 2 bits, little endian
        uint8_format = (flags & 1) == 0  # bit 0, can change
        energy_expenditure = (flags & 8)>0 # bit 3, can change
        rr_intervals = (flags & 16) >0  # bit 4, can change
        
        self.contact_detection= (flags & 4) > 0 # bit 2, static
        if self.contact_detection:
            # good contact if bit is set
            payload['contact']= (flags & 2) >0 # bit 1, can change
            
        if uint8_format:
            payload['hr'] = data[1]
            offset=2
        else:
            payload['hr'] = int.from_bytes(data[1:3], 'little', signed=False)
            offset=3
        if energy_expenditure:
            nrg = int.from_bytes(data[offset:offset+2], 'little', signed=False)
            offset += 2
            payload['nrg']=nrg

        if rr_intervals:
            payload['rr']=[]
            for i in range(offset, len(data), 2):
                rr = int.from_bytes(data[i:i+2],
                                    'little', signed=False)
                # Polar H7, H9, and H10 record RR intervals
                # in 1024-th parts of a second. Convert this
                # to milliseconds.
                rr = round(rr * 1000 / 1024)
                payload['rr'].append(rr)
        return payload

    
    async def _handler(self, characteristic: BleakGATTCharacteristic,
                       data: bytearray):
        """ Callback handler for notifications """
        tstamp=time_ns()
        payload=self._decode(data)
        # contact detection supported
        if self.contact_detection:
            if payload['contact'] and not self.good_contact.is_set():
                self.lost_contact.clear()
                self.good_contact.set()
                if self._contact_callback!=None:
                    if self._contact_callback_is_coro:
                        await self._contact_callback()
                    else:
                        self._contact_callback()
            if not payload['contact'] and not self.lost_contact.is_set():
                self.good_contact.clear()
                self.lost_contact.set()
                if self._lost_callback!=None:
                    if self._lost_callback_is_coro:
                        await self._lost_callback()
                    else:
                        self._lost_callback()
            if not payload['contact'] and self.filter_nocontact:
                return

        avghr=payload['hr']
        rrlist=payload.get('rr', [])
        energy=payload.get('ee', None)
        if not self.unpack:
            if self._callback_is_coro:
                await self._callback(('HR', tstamp, (avghr, rrlist), energy))
            else:
                self._callback(('HR', tstamp, (avghr, rrlist), energy))
        else:
            # unpack each individual heartbeat
            if len(rrlist)==0:
                return
            t_est=tstamp-sum(rrlist)*1000000 # nanoseconds
            for rr in rrlist:
                t_est+=rr * 1000000 # nanoseconds
                hr=round(60000.0/rr) if self.instant_rate else avghr
                if self._callback_is_coro:
                    await self._callback(('HR', t_est, (hr, rr), energy))
                else:
                    self._callback(('HR', t_est, (hr, rr), energy))


    async def start_notify(self, filter_nocontact=False):
        """ Start heart rate notifications; this will start writing
        data to the queue or passing it to the callback as it is
        received from the sensor. If filter_nocontact is True, 
        frames received without the 'contact' bit set are discarded 
        (the contact bit must be supported by the sensor)
        """
        await self.client.start_notify(HeartRate.CHARACTERISTIC,
                                        self._handler)
        self.good_contact.clear()
        self.lost_contact.clear()
        self.filter_nocontact=filter_nocontact

    async def stop_notify(self):
        """ Stop heart rate notifications """
        await self.client.stop_notify(HeartRate.CHARACTERISTIC)
        self.good_contact.clear()
        self.lost_contact.clear()

        
class PolarMeasurementData:
    """ Access measurements provided through the Polar Measurement Data
    interface: Electrocardiogram, Acceleration, Photoplethysmography, 
    Peak-to-Peak Interval, Gyroscope, Magnetometer.  Data is pushed to an 
    asynchronous queue or passed to a callback as requested, in the format 
    (DTYPE, tstamp, payload). Here DTYPE is a string from measurement_types, 
    tstamp is the sensor time stamp in ns, and payload is the requested 
    measurement data. The time stamp is converted to standard epoch time 
    using a single offset computed at the time the first data frame is 
    received. ECG, acceleration data (as supported by Polar H10) and
    photoplethysmography (Verity) are decoded; other measurement data
    are streamed, but they are returned as raw bytearrays.

    Acceleration samples are returned as tuples of values along the three
    axes (x,y,z), in milliG; ECG is returned as a list of integer samples in 
    microVolt (on the H10, ECG  sampling frequency is 130Hz and encoding 
    is 14 bit). In either case, the time stamp refers to the last sample 
    of the list constituting the payload. PPG is returned as tuples of 4
    samples (3 PPG channels plus an ambient light measurement).
    """
    # BLE characteristics
    PMDCTRLPOINT="FB005C81-02E7-F387-1CAD-8ACD2D8DF0C8"
    PMDDATAMTU="FB005C82-02E7-F387-1CAD-8ACD2D8DF0C8"
    
    # electrocardiogram, photoplethysmography, acceleration,
    # peak to peak interval, gyroscope, magnetometer. SDK enters
    # the SDK mode on the Verity (confirmed by led flashing R,G,B).
    measurement_types=['ECG', 'PPG', 'ACC', 'PPI', 'rfu', 'GYRO', 'MAG',
                       'rfu', 'rfu', 'SDK']
    # 'rfu' = reserved for future use. Use list.index() to get the code
    # for each string.
    op_codes={'GET':0x01, 'START': 0x02, 'STOP': 0x03}
    # All of these are encoded over 2 bytes except CHANNELS (1 byte)
    settings=['SAMPLE_RATE', 'RESOLUTION', 'RANGE', 'rfu', 'CHANNELS']
    # Choice of default settings for measurements supported by Polar H10.
    # Sampling rate is in Hz, Resolution in bit, Range in multiples of g.
    default_settings={'ECG': {'SAMPLE_RATE': 130, 'RESOLUTION': 14},
                      'ACC': {'SAMPLE_RATE': 200, 'RESOLUTION': 16,
                              'RANGE': 2 },
                      'PPG': {'SAMPLE_RATE':  55, 'RESOLUTION': 22,
                              'CHANNELS': 4}}
    # these are Polar sensor errors; bleakheart errors will use negative
    # error codes
    error_msgs=['SUCCESS', 'INVALID OP CODE', 'INVALID MEASUREMENT TYPE',
                'NOT SUPPORTED', 'INVALID LENGTH', 'INVALID PARAMETER',
                'ALREADY IN STATE', 'INVALID RESOLUTION',
                'INVALID SAMPLE RATE', 'INVALID RANGE',
                'INVALID MTU', 'INVALID NUMBER OF CHANNELS',
                'INVALID STATE', 'DEVICE IN CHARGER']

    def __init__(self, client: BleakClient,
                 ecg_queue:aio.Queue=None, acc_queue:aio.Queue=None,
                 ppg_queue:aio.Queue=None, raw_queue:aio.Queue=None,
                 callback=None):
        """" Init the PolarMeasurementData object.

        Args:

        client:    the BleakClient connection object for the BLE device
        ecg_queue: an asyncio queue onto which decoded ECG data is pushed;
                   if not specified, data will be sent to the callback
        acc_queue: an asyncio queue onto which decoded acceleration data is 
                   pushed; if unspecified, data will be passed to callback
        ppg_queue: an asyncio queue for decoded photoplethysmography data; 
                   if unspecified, data will be passed to the callback
        raw_queue: an asyncio queue onto which all other measurement data is 
                   pushed in raw format; if not specified, data will be 
                   passed to the callback
        callback:  a function or coroutine function to which all measurement
                   data not pushed onto a queue is passed. 
        """
        self.client=client
        self.ecg_queue=ecg_queue
        self.acc_queue=acc_queue
        self.ppg_queue=ppg_queue
        self.raw_queue=raw_queue
        if callback==None:
            callback=self._no_callback
        self._ecg_callback=ecg_queue.put_nowait if ecg_queue!=None else callback
        self._acc_callback=acc_queue.put_nowait if acc_queue!=None else callback
        self._ppg_callback=ppg_queue.put_nowait if ppg_queue!=None else callback
        self._raw_callback=raw_queue.put_nowait if raw_queue!=None else callback
        self._ecg_callback_is_coro=iscoroutinefunction(self._ecg_callback)
        self._acc_callback_is_coro=iscoroutinefunction(self._acc_callback)
        self._ppg_callback_is_coro=iscoroutinefunction(self._ppg_callback)
        self._raw_callback_is_coro=iscoroutinefunction(self._raw_callback)
        self._ctrl_lock=aio.Lock()
        self._ctrl_recv=aio.Event() # ctrl response ready
        self._ctrl_response=None
        self._notifications_started=False
        self._time_offset=None

    def _no_callback(self, payload):
        """ Used to raise an error if no queue or callback has been 
        specified for the type of frame """
        raise RuntimeError(f"No callback for {payload[0]} frames "+
                           "(try passing queue or callback)")

    async def _start_notifications(self):
        """ Start notifications from the control point and data
        characteristic """
        # handle responses from control point
        await self.client.start_notify(self.PMDCTRLPOINT,
                                       self._pmd_ctrl_handler)
        # handle responses form data point
        await self.client.start_notify(self.PMDDATAMTU,
                                       self._pmd_data_handler)
        self._notifications_started=True


    async def _pmd_ctrl_handler(self, characteristic: BleakGATTCharacteristic,
                                data: bytearray):
        """ Handler for control point responses. Stores the response in 
        self._ctrl_response and notifies the method that sent the control 
        query """
        if data[0]!=0xF0:
            raise RuntimeError(f"Invalid response from PMD control point")
        self._ctrl_response=data
        # method that sent the ctlr query is awaiting this 
        self._ctrl_recv.set()
        
    async def _pmd_ctrl_request(self, request: bytearray):
        """ Sends a control request to the PMD control point. Awaits the
        response with a timeout of 10 seconds. """
        # time to start notifications if not done yet
        if not self._notifications_started:
            await self._start_notifications()
        # make sure no other request can be made to ctrl point until
        # it  has responded. 
        async with self._ctrl_lock:
            self._ctrl_recv.clear()
            await self.client.write_gatt_char(self.PMDCTRLPOINT, request)
            # wait for response handler to set the event
            # can hang if response is lost, hence the timeout
            # async with aio.timeout(10): is not available in Python ver 3.8
            # using wait_for on instead
            await aio.wait_for(self._ctrl_recv.wait(), timeout=10)
            # grab response before releasing lock
            response=self._ctrl_response
        return response
        
    async def _pmd_data_handler(self, characteristic: BleakGATTCharacteristic,
                                data: bytearray):
        """ Receives responses from the PMD data point and delegates to
        the appropriate function for decoding. Pushes results to the 
        relevant queue or calls/awaits the callback. Data are formatted
        as tuples: (MEASR, timestamp, payload) where MEASR is a string
        identifying the measurement, followed by the sensor timestamp and 
        the list of samples (for measurements other than ECG or ACC, the 
        raw dataframe is returned as the payload).
        """
        meas=self.measurement_types[data[0]]
        timestamp=int.from_bytes(data[1:9], 'little', signed=False)
        frametype=data[9]
        try:
            timestamp+=self._time_offset
        except TypeError:
            self._time_offset=time_ns()-timestamp
            timestamp+=self._time_offset
        
        if meas=='ECG':
            payload=self._decode_ecg_data(data)
            if self._ecg_callback_is_coro:
                await self._ecg_callback(('ECG', timestamp, payload))
            else:
                self._ecg_callback(('ECG', timestamp, payload))
        elif (meas=='ACC') and (frametype==1):
            payload=self._decode_acc_data(data)
            if self._acc_callback_is_coro:
                await self._acc_callback(('ACC', timestamp, payload))
            else:
                self._acc_callback(('ACC', timestamp, payload))
        elif (meas=='PPG') and (frametype==128):
            payload=self._decode_ppg_data(data)
            if self._ppg_callback_is_coro:
                    await self._ppg_callback(('PPG',timestamp,payload))
            else:
                self._ppg_callback(('PPG',timestamp,payload))
        else:
            # send raw data to queue or callback
            if self._raw_callback_is_coro:
                await self._raw_callback((meas, timestamp, data))
            else:
                self._raw_callback((meas, timestamp, data))
        
    
    def _decode_ecg_data(self, data):
        """ Decodes ECG data frames from the device.

        Args: 
            data: the raw ECG frame from the device. This is an array of 
                  3-byte little-endian signed integers
        Returns:
            A list of ECG values in microvolt, as integers
        """
        if data[9]!=0x00:
            raise ValueError("Invalid ECG frame type")
        if (len(data)-10)%3!=0:
            raise ValueError("Bad ECG data frame length")
        microvolt=[]
        for offset in range(10, len(data), 3):
            muv=int.from_bytes(data[offset:offset+3],
                               'little',
                               signed=True)
            microvolt.append(muv)
        return microvolt

    def _decode_acc_data(self, data):
        """ Decode acceleration data frame type 0x01 (x,y,z, 16 bit signed 
        int, units: mg); this is the type of frame returned by the H10 strap

        Args:
            data: the raw ACC frame from the device. Only frame type 0x01 is 
            supported
        Returns:
            A list of tuples of the form (x,y,z) where x, y, and z are 
            integers measuring the acceleration along the three axes
            in milli-g.
        """
        if data[9]!=0x01:
            raise ValueError(f"Unsupported ACC frame type {data[9]:02x}")
        if (len(data)-10)%6!=0:
            raise ValueError("Bad ACC data frame length")
        milli_g=[]
        for offset in range(10, len(data), 6):
            x=int.from_bytes(data[offset  :offset+2], 'little', signed=True)
            y=int.from_bytes(data[offset+2:offset+4], 'little', signed=True)
            z=int.from_bytes(data[offset+4:offset+6], 'little', signed=True)
            milli_g.append((x,y,z))
        return milli_g
    
    def _parse_signed_int_from_bits(self,bit_str):
        """ Convert bit string of any length to integer,
        interpreting as signed. Used for decoding compressed
        (delta) frames """
        val = int(bit_str, 2)
        if bit_str[0] == '1':  # if sign bit is 1
            val -= 1 << len(bit_str)
        return val
    
    def _decode_ppg_data(self,data):
        """
        Sample dataframe:
        01 2d 3b ba ac ab 31 18 0b 80 bb 1a f8 7d 9b f8 94 b9 f8 df 20
        f6 08 2a cb ea d5 e2 00 d2 de 2c cc da b6 ee f5 d5 f9 11 17 fa 
        f1 ef bf 99 ca a7 ec 05 e2 4c e2 df e3 fe 19 31 66 36 e8 0e d6
        c2 39 cb f6 27 da 2f 2c 11 24 ce d8 e2 ec 3e 0d 21 1e 0f f1 dd 
        d3 8e 19 2d f9 3f be a3 07 08 f3 30 11 ee 1e 32 cd 06 11 d8 11
        12 e0 14 19 c9 05 00 1d 39 f8 e9 97 8f bb ea 14 f9 db 0e 1c 25 
        28 fe c7 cb d5 00 34 fc 11 fe ee 16 ff 18 de ec d0 07 e3 db ed
        17 5a 3d 66 db c5 dc 06 f1 30 1a 9b 1a 03 34 5f 1c eb f0 fb c9 
        0f 16 fc 3b 18 3b da 1c 1f d5 19 d1 11 11 de 08 f5 29 2a fa fc
        0f 20 e3 0a 07 03 74 af 41 01 f2 f7 d0 bf fc 35 10 e0 c3 fc 2b 
        88 31 00 03 0b 5c bf c2 01 2b 20 41 80 04 0d 38 50 c3 fc

        Reference: https://github.com/polarofficial/polar-ble-sdk/blob/
        master/technical_documentation/online_measurement.pdf

        Header: 0:01 1:2d 2:3b 3:ba 4:ac 5:ab 6:31 7:18 8:0b 9:80
        index   |   Type                |      data
        0:      |   Measurement Type    |      0x01 (PPG)
        1-8:    |   64bit timestamp     |      0x2d 0x3b 0xba 0xac 0xab
                |                       |      0x31 0x18 0x0b
                |                       |      (1746128349663096880)
        9:      |   Frame Type          |      0x80 (Compressed)

        Data:
        10-21:    Reference sample ([0xbb 0x1a 0xf8] [0x7d 0x9b 0xf8]
        [0x94 0xb9 0xf8] [0xdf 0x20 0xf6]) (ppg0,ppg1,ppg2,ambient)
        Sample 0 (aka. reference sample):
        Sample 0 - channel 0: bb 1a f8 => 0xf81abb => -517445
        (note:little endian means the bit order is read right to left
        then to convert the 3B to a signed 32B prepend 0xff if the
        highest byte >= 0x80 meaning 0xfffd664b => -517445)
        Sample 0 - channel 1: 7d 9b f8 => 0xf89b76 => -484490
        Sample 0 - channel 2: 94 b9 f8 => 0xf8b994 => -476780
        Sample 0 - channel 4: df 20 f6 => 0xf620df => -646945

        Delta data: 
        22-23: [08] [2a] [cb ea d5 e2 00 d2 ...]
        Delta package 1 size in bits (1B): 0x08 => 8 (size of 8 bits)
        Delta package 1 samples count (1B): 0x2a => 42 (contains 42 samples)
        .
        .
        .
        """
        if not isinstance(data, (bytes, bytearray)):
            raise TypeError("Expected a bytes or bytearray object")
        
        # Hardcoded values for the Verity Sense
        resolution_bits = 22
        num_channels = 4
        header_length = 10
        # Each sample's size in bytes based on resolution
        bytes_per_sample = math.ceil(resolution_bits / 8)
        # Total bytes needed for the reference frame
        ref_frame_len = num_channels * bytes_per_sample

        # Ensure the stream is long enough for the reference frame and delta
        # header (2 bytes)
        if len(data) < header_length + ref_frame_len + 2:
            raise ValueError("Byte stream too short for reference frame")

        # Slice out the reference frame section
        ref_start = header_length
        ref_end = ref_start + ref_frame_len
        reference_frame = data[ref_start:ref_end]

        # Decode each channel's initial reference value
        channel_values = []
        for i in range(num_channels):
            start = i * bytes_per_sample
            end = start + bytes_per_sample
            sample_bytes = reference_frame[start:end]
            # Interpret as signed little-endian integer
            value = int.from_bytes(sample_bytes, byteorder='little',signed=True)
            channel_values.append(value)

        offset = ref_end
        delta_frames_info = [] # Store (bit width, sample count) for each frame
        all_delta_samples = [] # Store the decoded deltas grouped by sample
        # print(len(channel_values))
        decoded_ppg = [channel_values]
        # print(decoded_ppg[0][1])

        # Loop through all delta frames in the byte stream
        while offset + 2 <= len(data):
            bit_width = data[offset]       # Byte 22: bit width per delta value
            sample_count = data[offset + 1]  # Byte 23: number of samples
            offset += 2

            total_bits = bit_width * num_channels * sample_count
            total_bytes = math.ceil(total_bits / 8)

            # Ensure there are enough bytes left in the stream
            if offset + total_bytes > len(data):
                warn("PPG: Incomplete delta frame data; "
                      "skipping remaining.", BytesWarning)
                break

            # Read the raw delta bytes and convert them to a bit string
            data_bytes = data[offset:offset + total_bytes]
            offset += total_bytes
            bit_string = ''.join(f'{byte:08b}' for byte in data_bytes)

            frame_deltas = []

            # Decode each sample (consisting of multiple channel deltas)
            for sample_index in range(sample_count):
                sample_channels = []
                sample = []
                # print(sample_index)
                for channel_index in range(num_channels):
                    i = sample_index * num_channels + channel_index
                    start_bit = i * bit_width
                    end_bit = start_bit + bit_width
                    bits = bit_string[start_bit:end_bit]

                    # If bits are incomplete, skip this sample
                    if len(bits) < bit_width:
                        warn("PPG: Incomplete sample data; "
                             "skipping sample.", BytesWarning)
                        break
                    val = self._parse_signed_int_from_bits(bits)
                    # print(type(bits))
                    # print(decoded_ppg[0][1])
                    ppg_val = decoded_ppg[-1][channel_index] + val
                    sample_channels.append(val)
                    sample.append(ppg_val)

                frame_deltas.append(sample_channels)
                decoded_ppg.append(sample)

            # Store metadata and deltas for this frame
            delta_frames_info.append((bit_width, sample_count))
            all_delta_samples.append(frame_deltas)

        return decoded_ppg
    


    async def available_measurements(self):
        """ Reads the PMD Control Point to obtain the available
        measurements. 

        Returns:
            A list of available measurements; for the H10 strap this should 
            be ['ECG', 'ACC'], for the Verity ['PPG', 'ACC', 'PPI', 'GYRO',
            'MAG']

        Raises:
            A RuntimeError is raised in case of invalid read response from
            the CTRL Point
        """
        data=await self.client.read_gatt_char(self.PMDCTRLPOINT)
        if data[0]!=0x0F:
            raise RuntimeError(f"Invalid read response from PMD control point")
        flags=data[1]
        measr=[ meas for i, meas in enumerate(self.measurement_types)
                if  flags & (1<<i) > 0 ]
        return measr
    
        
    async def available_settings(self, measurement):
        """ Requests allowed parameters for the given measurement from 
        the device.

        Args:
            measurement: one of the strings in self.measurement_types

        Returns:
            A dictionary with the available settings for the measurement
            as keys. For each key, the value is a list of allowed values
            for that setting. 

        Errors:
            A dictionary with an error code of -1 and an error message is
            returned if the CTRL Point response times out.

        Raises:
            A RuntimeError is raised if the CTRL Point response has more
            than one frame or an illegal length.
        """
        try:
            mtype=self.measurement_types.index(measurement)
        except ValueError as e:
            e.args=(f'Unknown measurement type: {measurement}',)
            raise e
        cmd=self.op_codes['GET']
        try:
            data=await self._pmd_ctrl_request(bytearray([cmd, mtype]))
        except aio.TimeoutError:
            return {'error_code': -1,
                    'error_msg': 'PMD CTLR Point response timeout'}
        if data[1]!=cmd or data[2]!=mtype:
            raise RuntimeError(f"Invalid response from PMD control point")
        params=defaultdict(list)
        params['error_code']=data[3]
        params['error_msg']=self.error_msgs[data[3]]
        if data[3]>0: # an error occurred
            return params
        if data[4]!=0x00:
            raise RuntimeError("Multiple frames in PMD ctrl response "
                               "not supported")
        offset=5
        try:
            while offset< len(data):
                # decode the response here and return the dictionary
                parname=self.settings[data[offset]]
                howmany=data[offset+1]
                offset+=2
                wlen=2 if parname!='CHANNELS' else 1
                for i in range(howmany):
                    val=int.from_bytes(data[offset:offset+wlen],
                                       'little', signed=False)
                    params[parname].append(val)
                    offset+=wlen
        except IndexError:
            raise RuntimeError("PMD response has wrong length")
        return params

    
    async def start_streaming(self, measurement, **settings):
        """ Starts streaming the specified measurement,  checks ctrl point
        response for errors. Note: passing 'SDK' as the measurement switches
        the Verity sensor to SDK mode (onboard led will blink R,G,B).
        
        Returns: a tuple with the error code (0 for success), error
        message, and the raw response so that unsupported parameters
        can be handled by the caller.

        Raises: ValueError if an invalid measurement is requested.
        """
        try:
            meas_type=self.measurement_types.index(measurement)
        except ValueError:
            return  (-3, f"Unknown measurement type: {measurement}", None)

        # default settings if present
        if measurement in self.default_settings:
            params=self.default_settings[measurement].copy()
        else:
            params={}
        # allow giving settings in lowercase
        for s,v in settings.items():
            params[s.upper()]=v
        cmd=self.op_codes['START']
        req=bytearray([cmd, meas_type])
        for s,v in params.items():
            req.extend([self.settings.index(s),
                        0x01]) # array length
            wlen=2 if s!='CHANNELS' else 1
            req.extend(v.to_bytes(wlen, 'little', signed=False))
        try:
            response=await self._pmd_ctrl_request(req)
        except aio.TimeoutError:
            return (-1, 'PMD CTLR Point response timeout', None)
        if response[1]!=cmd or response[2]!=meas_type:
            return (-2, 'Invalid CTRL point response', None)
        err_code=response[3]
        err_msg=self.error_msgs[err_code]
        # Verity ACC reponse has FACTOR parameter, not handled here
        return (err_code, err_msg, response)


    async def stop_streaming(self, measurement):
        """ Stop streaming, check ctrl point response for errors.
        Return a tuple with the error code (0 for success) and error
        message. """
        try:
            meas_type=self.measurement_types.index(measurement)
        except ValueError:
            return  (-3, f"Unknown measurement type: {measurement}")

        cmd=self.op_codes['STOP']
        req=bytearray([cmd, meas_type])
        try:
            response=await self._pmd_ctrl_request(req)
        except aio.TimeoutError:
            return (-1, 'PMD CTLR Point response timeout')
        if response[1]!=cmd or response[2]!=meas_type:
            return (-2, 'Invalid CTRL point response')
        err_code=response[3]
        err_msg=self.error_msgs[err_code]
        return (err_code, err_msg)
