zigpy / zha-device-handlers

ZHA device handlers bridge the functionality gap created when manufacturers deviate from the ZCL specification, handling deviations and exceptions by parsing custom messages to and from Zigbee devices.
Apache License 2.0
752 stars 689 forks source link

[Device Support Request] Livolo Switch (1 button) #596

Closed jonathan-gatard closed 10 months ago

jonathan-gatard commented 3 years ago

Hello !

Describe the solution you'd like I see Livolo switch are not implemented, somebody could do it :) ?

Additional context I have a lot of informations about it !

This switch works only on channel 26, but i found a tips ! With a software (TestGUI_ZiGate_NXP-4 for me because i use ZiGate) i changed the channel to 26, then i paired the device on ZHA and finally i backed to channel 11 (but i don't have home assistant log because i pair the device on my Windows) I found this tips here: https://zigate.fr/documentation/changer-le-canal-zigbee-pour-les-appareils-livolo/

There is also informations for DomoticZ here: https://github.com/pipiche38/Domoticz-Zigate-Wiki/blob/master/en-eng/Livolo-corner.md

Device signature - this can be acquired by removing the device from ZHA and pairing it again from the add devices screen. Be sure to add the entire content of the log panel after pairing the device to a code block below this line.

Informations about switch: Zigbee info:

IEEE: 00:12:4b:00:21:74:d7:08
Nwk: 0x6cc0
Device Type: EndDevice
LQI: Inconnu
RSSI: Inconnu
Dernière vue: 2020-11-22T22:00:31
Source d'énergie: Battery or Unknown
{
  "node_descriptor": "NodeDescriptor(byte1=2, byte2=64, mac_capability_flags=128, manufacturer_code=0, maximum_buffer_size=80, maximum_incoming_transfer_size=160, server_mask=0, maximum_outgoing_transfer_size=160, descriptor_capability_field=0)",
  "endpoints": {
    "6": {
      "profile_id": 260,
      "device_type": "0x0000",
      "in_clusters": [
        "0x0000",
        "0x0003"
      ],
      "out_clusters": [
        "0x0006"
      ]
    }
  },
  "manufacturer": "LIVOLO",
  "model": "TI0001          ",
  "class": "zigpy.device.Device"
}

And this is the log when i pair the switch :

Device 0xc360 (00:12:4b:00:21:74:d7:08) joined the network
[0xc360] Requesting 'Node Descriptor'
Tries remaining: 2
[0xc360] Extending timeout for 0x24 request
[0xc360:zdo] ZDO request ZDOCmd.Device_annce: [0xC360, 00:12:4b:00:21:74:d7:08, 128]
[0xc360] Node Descriptor: NodeDescriptor(byte1=2, byte2=64, mac_capability_flags=128, manufacturer_code=0, maximum_buffer_size=80, maximum_incoming_transfer_size=160, server_mask=0, maximum_outgoing_transfer_size=160, descriptor_capability_field=0)
[0xc360] Discovering endpoints
Tries remaining: 3
[0xc360] Extending timeout for 0x26 request
[0xc360] Discovered endpoints: [6]
[0xc360:6] Discovering endpoint information
Tries remaining: 3
[0xc360] Extending timeout for 0x28 request
[0xc360:6] Discovered endpoint information: SizePrefixedSimpleDescriptor(endpoint=6, profile=260, device_type=0, device_version=0, input_clusters=[0, 3], output_clusters=[6])
[0xc360] Extending timeout for 0x2a request
[0xc360:6:0x0000] ZCL deserialize: <ZCLHeader frame_control=<FrameControl frame_type=GLOBAL_COMMAND manufacturer_specific=False is_reply=True disable_default_response=True> manufacturer=None tsn=42 command_id=Command.Read_Attributes_rsp>
[0xc360:6] Manufacturer: LIVOLO
[0xc360:6] Model: TI0001          
Checking quirks for LIVOLO TI0001           (00:12:4b:00:21:74:d7:08)
Considering <class 'bellows.zigbee.application.EZSPCoordinator'>
Fail because endpoint list mismatch: {1} {6}
device - 0xC360:00:12:4b:00:21:74:d7:08 entering async_device_initialized - is_new_join: True
device - 0xC360:00:12:4b:00:21:74:d7:08 has joined the ZHA zigbee network
[0xC360](TI0001          ): started configuration
[0xC360:ZDO](TI0001          ): 'async_configure' stage succeeded
[0xc360:6:0x0000] ZCL deserialize: <ZCLHeader frame_control=<FrameControl frame_type=GLOBAL_COMMAND manufacturer_specific=False is_reply=True disable_default_response=True> manufacturer=None tsn=42 command_id=Command.Read_Attributes_rsp>
[0xc360:6:0x0000] ZCL request 0x0001: [[ReadAttributeRecord(attrid=4, status=<Status.SUCCESS: 0>, value=<TypeValue type=CharacterString, value=LIVOLO>), ReadAttributeRecord(attrid=5, status=<Status.SUCCESS: 0>, value=<TypeValue type=CharacterString, value=TI0001          >)]]
[0xc360:6:0x0000] ZCL deserialize: <ZCLHeader frame_control=<FrameControl frame_type=GLOBAL_COMMAND manufacturer_specific=False is_reply=True disable_default_response=True> manufacturer=None tsn=42 command_id=Command.Read_Attributes_rsp>
[0xc360:6:0x0000] ZCL request 0x0001: [[ReadAttributeRecord(attrid=4, status=<Status.SUCCESS: 0>, value=<TypeValue type=CharacterString, value=LIVOLO>), ReadAttributeRecord(attrid=5, status=<Status.SUCCESS: 0>, value=<TypeValue type=CharacterString, value=TI0001          >)]]
[0xc360] Extending timeout for 0x2c request
[0xc360] Extending timeout for 0x2e request
[0xC360:6:0x0006]: bound 'on_off' cluster: Status.SUCCESS
[0xC360:6:0x0006]: finished channel configuration
[0xc360] Delivery error for seq # 0x2c, on endpoint id 0 cluster 0x0021: ZiGate doesn't answer to command
[0xC360:6:0x0000]: Failed to bind 'basic' cluster: [0xc360:0:0x0021]: Message send failure
[0xC360:6:0x0000]: finished channel configuration
[0xc360] Extending timeout for 0x30 request
[0xc360:6:0x0000] ZCL deserialize: <ZCLHeader frame_control=<FrameControl frame_type=GLOBAL_COMMAND manufacturer_specific=False is_reply=True disable_default_response=True> manufacturer=None tsn=48 command_id=Command.Read_Attributes_rsp>
[0xC360:6:0x0000]: initializing channel: from_cache: False
[0xc360:6:0x0000] ZCL deserialize: <ZCLHeader frame_control=<FrameControl frame_type=GLOBAL_COMMAND manufacturer_specific=False is_reply=True disable_default_response=True> manufacturer=None tsn=48 command_id=Command.Read_Attributes_rsp>
[0xc360:6:0x0000] ZCL request 0x0001: [[ReadAttributeRecord(attrid=7, status=<Status.SUCCESS: 0>, value=<TypeValue type=uint8_t, value=1>)]]
[0xC360:6:0x0000]: 'async_configure' stage succeeded
[0xC360:6:0x0006]: 'async_configure' stage succeeded
[0xC360](TI0001          ): completed configuration
[0xC360](TI0001          ): stored in registry: ZhaDeviceEntry(name='LIVOLO TI0001          ', ieee='00:12:4b:00:21:74:d7:08', last_seen=1606513955.4084084)
[0xc360] Extending timeout for 0x32 request
[0xc360:6:0x0003] ZCL deserialize: <ZCLHeader frame_control=<FrameControl frame_type=GLOBAL_COMMAND manufacturer_specific=False is_reply=True disable_default_response=True> manufacturer=None tsn=50 command_id=Command.Default_Response>
[0xC360:6:0x0003]: executed 'trigger_effect' command with args: '(2, 0)' kwargs: '{}' result: [64, <Status.SUCCESS: 0>]
[0xC360](TI0001          ): started initialization
[0xC360:ZDO](TI0001          ): 'async_initialize' stage succeeded
[0xc360] Extending timeout for 0x34 request
[0xC360:6:0x0006]: initializing channel: from_cache: False
[0xc360:6:0x0000] ZCL deserialize: <ZCLHeader frame_control=<FrameControl frame_type=GLOBAL_COMMAND manufacturer_specific=False is_reply=True disable_default_response=True> manufacturer=None tsn=52 command_id=Command.Read_Attributes_rsp>
[0xC360:6:0x0000]: initializing channel: from_cache: False
[0xc360:6:0x0000] ZCL deserialize: <ZCLHeader frame_control=<FrameControl frame_type=GLOBAL_COMMAND manufacturer_specific=False is_reply=True disable_default_response=True> manufacturer=None tsn=52 command_id=Command.Read_Attributes_rsp>
[0xc360:6:0x0000] ZCL request 0x0001: [[ReadAttributeRecord(attrid=7, status=<Status.SUCCESS: 0>, value=<TypeValue type=uint8_t, value=1>)]]
[0xC360:6:0x0000]: 'async_initialize' stage succeeded
[0xC360:6:0x0006]: 'async_initialize' stage succeeded
[0xC360](TI0001          ): power source: Battery or Unknown
[0xC360](TI0001          ): completed initialization

I think the problem is cluster 0x0006, because i see on the domoticz github that Livolo switch uses 0x0008 ! image

If you have question don't hesitate !

Thanks !

jonathan-gatard commented 3 years ago

Hi,

I tried to write a quirk: image But when i pair my switch, it doesn't use my quirk, dou you know why ?

2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Checking quirks for LIVOLO TI0001           (00:12:4b:00:21:74:d7:08)
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'bellows.zigbee.application.EZSPCoordinator'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {1} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.gledopto.soposhgu10.SoposhGU10'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {11, 13} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.lutron.lzl4bwhl01remote.LutronLZL4BWHL01Remote2'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {1} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.netvox.z308e3ed.Z308E3ED'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {1} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.osram.a19twhite.A19TunableWhite'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {3} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.philips.rom001.PhilipsROM001'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {1} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.philips.rwl020.PhilipsRWL020'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {1, 2} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.philips.rwl021.PhilipsRWL021'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {1, 2} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.smartthings.multi.SmartthingsMultiPurposeSensor'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {1} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.smartthings.tag_v4.SmartThingsTagV4'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {1} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.xbee.xbee3_io.XBee3Sensor'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {232, 230} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.xbee.xbee_io.XBeeSensor'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {232, 230} {6}
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Considering <class 'zhaquirks.xiaomi.mija.smoke.MijiaHoneywellSmokeDetectorSensor'>
2020-11-28 01:11:02 DEBUG (MainThread) [zigpy.quirks.registry] Fail because endpoint list mismatch: {1} {6}

I read during 2/3 hours the doc about zigbee cluster, and i find "DeviceType.DIMMER_SWITCH" and "LevelControl.cluster_id", I think i'm on the right path

nofateg commented 3 years ago

channels: [26] extended_pan_id: "21:75:8D:19:00:4B:12:00"

livolo.py:

import copy
import time
import binascii
import asyncio

import zigpy.types as t
import zigpy.zdo.types as zdo_t

from zigpy.profiles import zha
from zigpy.quirks import CustomDevice
from zigpy.zcl.clusters.general import Basic, Identify, OnOff, LevelControl
from zigpy.zcl import foundation

from typing import Optional, Union

from zhaquirks.const import (
    CLUSTER_ID,
    COMMAND,
    COMMAND_OFF,
    COMMAND_ON,
    DEVICE_TYPE,
    ENDPOINT_ID,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
    SHORT_PRESS,
    TURN_OFF,
    TURN_ON,
)

from . import LIVOLO

LIVOLO_CLUSTER_ID = 0x0001
APS_REPLY_TIMEOUT = 5

LEVELCONTROL_COMMAND_MOVE_TO_LEVEL_WITH_ON_OFF = 0x0004
ON_OFF_COMMAND_TOGGLE = 0x0002
LEVELCONTROL_ATTRIBUTE_CURRENT_LEVEL = 0x0000
LEVELCONTROL_ATTRIBUTE_ON_OFF_TRANSITION_TIME = 0x0010

class LivoloSwitch (CustomDevice) :
    """TI0001 device."""

    def __init__(self, application, ieee, nwk, replaces):
        super().__init__(application, ieee, nwk, replaces)
        self.debug("LivoloSwitch#init: ieee: %s, nwk: %s, replaces: %s, application: %s", 
            ieee, nwk, replaces, application)

    def make_attribute(self, attrid, value):
        attr = foundation.Attribute()
        attr.attrid = attrid
        attr.value = foundation.TypeValue()
        attr.value.value = value
        return attr

    def to_status(self, val):
        if val is None:
            return "Unknown"
        if val > 0:
            return "On"
        return "Off"

    def handle_message(
        self,
        profile: int,
        cluster: int,
        src_ep: int,
        dst_ep: int,
        message: bytes,
        *,
        dst_addressing: Optional[
            Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK]
        ] = None,
    ):
        self.debug("LivoloSwitch#handle_message profile: %s, cluster: %s, src_ep: %s, dst_ep: %s, message: %s, dst_addressing: %s", 
            profile, cluster, src_ep, dst_ep, binascii.hexlify(message), dst_addressing)

        if profile == 260 and src_ep == 6 and cluster == LIVOLO_CLUSTER_ID:
            self.last_seen = time.time()

            left_status = message[-1] & 1
            right_status = message[-1] & 2

            left_endpoint = self.endpoints[6]
            right_endpoint = self.endpoints[7]

            left_cluster = left_endpoint.in_clusters[OnOff.cluster_id]
            right_cluster = right_endpoint.in_clusters[OnOff.cluster_id]

            left_old_status = left_cluster.get("on_off")
            right_old_status = right_cluster.get("on_off")

            self.debug("from out: left: %s -> %s, right: %s -> %s", 
                self.to_status(left_old_status), self.to_status(left_status), 
                self.to_status(right_old_status), self.to_status(right_status))

            if left_old_status != left_status:
                frc = foundation.FrameControl(foundation.FrameType.GLOBAL_COMMAND)
                tsn = left_endpoint.device.application.get_sequence()
                hdr = foundation.ZCLHeader(frc, tsn=tsn, command_id=foundation.Command.Report_Attributes)
                hdr.frame_control.disable_default_response = True
                attrs = []
                if left_status > 0:
                    attrs = [self.make_attribute(1, 0), self.make_attribute(0, 1), self.make_attribute(2, 2)] 
                else: 
                    attrs = [self.make_attribute(1, 1), self.make_attribute(0, 0), self.make_attribute(2, 2)]

                self.debug("send to ep6 -> %s, attrs = %s", self.to_status(left_status), [attrs])

                left_endpoint.handle_message(
                    profile, OnOff.cluster_id, hdr, [attrs], dst_addressing=dst_addressing
                )
            if right_old_status != right_status:
                frc = foundation.FrameControl(foundation.FrameType.GLOBAL_COMMAND)
                tsn = right_endpoint.device.application.get_sequence()
                hdr = foundation.ZCLHeader(frc, tsn=tsn, command_id=foundation.Command.Report_Attributes)
                hdr.frame_control.disable_default_response = True
                attrs = []
                if right_status > 0:
                    attrs = [self.make_attribute(1, 0), self.make_attribute(0, 1), self.make_attribute(2, 2)] 
                else: 
                    attrs = [self.make_attribute(1, 1), self.make_attribute(0, 0), self.make_attribute(2, 2)]

                self.debug("send to ep7 -> %s, attrs = %s", self.to_status(right_status), [attrs])

                right_endpoint.handle_message(
                    profile, OnOff.cluster_id, hdr, [attrs], dst_addressing=dst_addressing
                )
                return
        elif cluster == zdo_t.ZDOCmd.Device_annce and dst_ep == 0:
            self.debug("LivoloSwitch#handle_message poll nwk(OnOff#toggle): %s", self.nwk)

            hdr = foundation.ZCLHeader.cluster(tsn=0, command_id=ON_OFF_COMMAND_TOGGLE)
            # hdr.frame_control.disable_default_response = False

            endpoint = self.endpoints[6]
            cluster = endpoint.out_clusters[OnOff.cluster_id]
            schema = cluster.server_commands[ON_OFF_COMMAND_TOGGLE][1]
            data = hdr.serialize() + t.serialize([], schema)

            try:
                loop = asyncio.get_running_loop()
            except RuntimeError:
                loop = None

            if loop and loop.is_running():
                tsk = loop.create_task(super().request(zha.PROFILE_ID, OnOff.cluster_id, 6, 6, 0, data))
            else:
                asyncio.run(super().request(zha.PROFILE_ID, OnOff.cluster_id, 6, 6, 0, data))
        else:
            return super().handle_message(profile, cluster, src_ep, dst_ep, message, dst_addressing=dst_addressing)

    async def request(
        self,
        profile,
        cluster,
        src_ep,
        dst_ep,
        sequence,
        data,
        expect_reply=True,
        timeout=APS_REPLY_TIMEOUT,
        use_ieee=False,
    ):
        self.debug("LivoloSwitch#request profile: %s, cluster: %s, src_ep: %s, dst_ep: %s, sequence: %s, data %s, expect_reply: %s, timeout: %s, use_ieee: %s",
            profile, cluster, src_ep, dst_ep, sequence, binascii.hexlify(data), expect_reply, timeout, use_ieee)

        if profile == 260 and OnOff.cluster_id == cluster:
            hdr, cdata = foundation.ZCLHeader.deserialize(data)

            self.debug("LivoloSwitch#request hdr: command_id: %s, is_reply: %s, manufacturer: %s, tsn: %s",
                hdr.command_id, hdr.is_reply, hdr.manufacturer, hdr.tsn)

            endpoint = self.endpoints[6]
            cluster = endpoint.out_clusters[LevelControl.cluster_id]
            schema = cluster.server_commands[LEVELCONTROL_COMMAND_MOVE_TO_LEVEL_WITH_ON_OFF][1]

            channel = 1
            channel_name = "left"
            if src_ep == 7:
                channel = 2
                channel_name = "right"

            level = 1
            if data[-1] == 1:
                level = 108

            self.debug("send: livolo on: %s/%s, channel: %s/%s", data[-1] == 1, level, channel_name, channel)

            hdr.command_id = LEVELCONTROL_COMMAND_MOVE_TO_LEVEL_WITH_ON_OFF
            new_data = hdr.serialize() + t.serialize([level, channel], schema)

            return await super().request(profile, cluster.cluster_id, 6, 6, sequence, new_data, 
                expect_reply = expect_reply, timeout=timeout, use_ieee=use_ieee)

        return await super().request(profile, cluster, src_ep, dst_ep, sequence, data, 
            expect_reply=expect_reply, timeout = timeout, use_ieee = use_ieee)

    class LivoloOnOff (OnOff):
        cluster_id = LIVOLO_CLUSTER_ID

    signature = {
        # <SimpleDescriptor endpoint=6 profile=260 device_type=0 device _version=1 input_clusters=[0, 6] output_clusters=[6]>
        MODELS_INFO: [(LIVOLO, "TI0001          ")],
        ENDPOINTS: {
            6: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Identify.cluster_id
                ],
                OUTPUT_CLUSTERS: [
                    OnOff.cluster_id
                ],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            6: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
                INPUT_CLUSTERS: [
                    Basic,
                    Identify,
                    OnOff,
                    LivoloOnOff,
                ],
                OUTPUT_CLUSTERS: [
                    LevelControl,
                    OnOff
                ],
            },
            7: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
                INPUT_CLUSTERS: [
                    Basic,
                    Identify,
                    OnOff,
                    LivoloOnOff,
                ],
                OUTPUT_CLUSTERS: [
                    LevelControl
                ],
            }
        },
    }
github-actions[bot] commented 3 years ago

There hasn't been any activity on this issue recently. Due to the high number of incoming GitHub notifications, we have to clean some of the old issues, as many of them have already been resolved with the latest updates. Please make sure to update to the latest version and check if that solves the issue. Let us know if that works for you by adding a comment 👍 This issue has now been marked as stale and will be closed if no further activity occurs. Thank you for your contributions.

MiguelAngelLV commented 3 years ago

Livolo devices not yet.

ZHA find the device but not get any entity

MiguelAngelLV commented 3 years ago

@nofateg How I can use you script? I use Home Assistant OS.

Any way to install "easily" and permanente between updates?

nofategm commented 3 years ago

@nofateg How I can use you script? I use Home Assistant OS.

Any way to install "easily" and permanente between updates? 1: "easy" no permanent: bash script:


container_id=$(docker ps | grep raspberrypi4-homeassistant | awk '{print $1}')
echo "container_id = ${container_id}"
docker cp livolo ${container_id}:/usr/local/lib/python3.9/site-packages/zhaquirks

2. automation:

automation:

nofategm commented 3 years ago

livolo directory: init.py:

"""Module for LIVOLO devices."""

LIVOLO = "LIVOLO"

livolo.py: Githubissues.

  • Githubissues is a development platform for aggregating issues.