diff --git a/Classes/PluginConf.py b/Classes/PluginConf.py index 8c9bfac2b..c64b462c8 100644 --- a/Classes/PluginConf.py +++ b/Classes/PluginConf.py @@ -202,6 +202,7 @@ "PluginConfiguration": { "Order": 12, "param": { + "MatomoOptIn": {"type": "bool","default": 1,"current": None,"restart": 0,"hidden": False,"Advanced": True,}, "PosixPathUpdate": {"type": "bool","default": 1,"current": None,"restart": 0,"hidden": True,"Advanced": True,}, "storeDomoticzDatabase": {"type": "bool","default": 0,"current": None,"restart": 0,"hidden": False,"Advanced": True,}, "useDomoticzDatabase": {"type": "bool","default": 0,"current": None,"restart": 0,"hidden": False,"Advanced": True,}, @@ -254,6 +255,7 @@ "Livolo": { "type": "bool", "default": 0, "current": None, "restart": 0, "hidden": False, "Advanced": True }, "Lumi": { "type": "bool", "default": 0, "current": None, "restart": 0, "hidden": False, "Advanced": True }, "MatchingNwkId": { "type": "str", "default": "ffff", "current": None, "restart": 0, "hidden": False, "Advanced": False }, + "Matomo": { "type": "bool", "default": 0, "current": None, "restart": 0, "hidden": False, "Advanced": True }, "Electric": { "type": "bool", "default": 0, "current": None, "restart": 0, "hidden": False, "Advanced": True }, "NXPExtendedErrorCode": { "type": "bool", "default": 1, "current": None, "restart": 0, "hidden": False, "Advanced": True }, "NetworkEnergy": { "type": "bool", "default": 0, "current": None, "restart": 0, "hidden": False, "Advanced": True }, @@ -592,6 +594,7 @@ def _load_Settings(self): # Force to 0 as this parameter is only relevant to Zigpy self.pluginConf["ZigpyTopologyReport"] = False + def _load_oldfashon(self, homedir, hardwareid): # Import PluginConf.txt # Migration diff --git a/Classes/WebServer/WebServer.py b/Classes/WebServer/WebServer.py index 31a92848a..1f931fb61 100644 --- a/Classes/WebServer/WebServer.py +++ b/Classes/WebServer/WebServer.py @@ -14,6 +14,7 @@ import mimetypes import os import os.path +import platform import time from Classes.PluginConf import SETTINGS @@ -30,6 +31,8 @@ domoticz_error_api, domoticz_log_api, domoticz_status_api) +from Modules.matomo_request import (matomo_opt_in_action, + matomo_opt_out_action) from Modules.sendZigateCommand import sendZigateCmd from Modules.tools import get_device_nickname, is_hex from Modules.txPower import set_TxPower @@ -678,6 +681,15 @@ def rest_Settings(self, verb, data, parameters, sendDebug=False): domoticz_error_api("Unknown Certification code %s (allow are CE and FCC)" % (setting_lst[setting]["current"])) continue + elif param == "MatomoOptIn" and self.pluginconf.pluginConf[param] != setting_lst[setting]["current"]: + self.pluginconf.pluginConf[param] = setting_lst[setting]["current"] + if self.pluginconf.pluginConf[param]: + # Opt-In (we move from Out to In ) + matomo_opt_in_action(self) + else: + # Opt-Out (we move from In to Out) + matomo_opt_out_action(self) + elif param == "blueLedOnOff": if self.pluginconf.pluginConf[param] != setting_lst[setting]["current"]: self.pluginconf.pluginConf[param] = setting_lst[setting]["current"] @@ -1620,4 +1632,28 @@ def get_plugin_parameters(self, filter=False): keys_to_remove = ["Mode5", "Username", "Password"] for key in keys_to_remove: plugin_parameters.pop(key, None) + + plugin_parameters["DistributionInfos"] = get_os_info() return plugin_parameters + + +def get_os_info(): + os_name = platform.system() + if os_name == "Linux": + try: + with open("/etc/os-release") as f: + lines = f.readlines() + os_info = {line.split('=')[0]: line.split('=')[1].strip().strip('"') for line in lines if '=' in line} + return os_info.get("NAME", "Unknown"), os_info.get("VERSION", "Unknown") + + except Exception as e: + return "Linux", "Unknown" + + elif os_name == "Windows": + return "Windows", platform.version() + + elif os_name == "Darwin": + return "macOS", platform.mac_ver()[0] + + else: + return os_name, "Unknown" diff --git a/Classes/WebServer/rest_PluginUpgrade.py b/Classes/WebServer/rest_PluginUpgrade.py index a73d4cbd9..bdd16d390 100644 --- a/Classes/WebServer/rest_PluginUpgrade.py +++ b/Classes/WebServer/rest_PluginUpgrade.py @@ -21,6 +21,8 @@ from Classes.WebServer.headerResponse import (prepResponseMessage, setupHeadersResponse) from Modules.database import import_local_device_conf +from Modules.matomo_request import matomo_plugin_update + PLUGIN_UPGRADE_SCRIPT = "Tools/plugin-auto-upgrade.sh" @@ -55,6 +57,10 @@ def rest_plugin_upgrade(self, verb, data, parameters): self.logging( Logging_mode, "%s" %(line)) _response["Data"] = json.dumps(result) + + if self.pluginconf.pluginConf["MatomoOptIn"]: + matomo_plugin_update(self, Logging_mode != "Error") + return _response def rest_reload_device_conf(self, verb, data, parameters): diff --git a/Modules/matomo_request.py b/Modules/matomo_request.py new file mode 100644 index 000000000..dfc827556 --- /dev/null +++ b/Modules/matomo_request.py @@ -0,0 +1,325 @@ +import hashlib +import json +import os +import platform +import re +import sys +import time + +import distro +import requests + +# Matomo endpoint details +MATOMO_URL = "https://z4d.pipiche.net/matomo.php" +APIV = 1 # API Version +SITE_ID = 9 # 7 for Production +ACTION_NAME = "PluginInstanceInfos" + +RONELABS_MODEL_INFO = "/etc/modelinfo" +DEVICE_TREE_CONFIGURATION = "/proc/device-tree/model" + + +def get_clientid(self): + """ + Reterieve the MacAddress that will be used a Client Id + + Ensure compliance with privacy laws like GDPR or CCPA when using MAC addresses or other personal identifiers. + anonymize or hash the MAC address before sending it to Matomo. + """ + mac_address = self.ListOfDevices.get('0000', {}).get('IEEE', None) + if mac_address: + return hashlib.sha256(mac_address.encode()).hexdigest() + + return None + + +def populate_custom_dimmensions(self): + + _custom_dimensions = { } + + # Domoticz version + _domo = self.pluginParameters.get("DomoticzVersion") + if _domo: + _custom_dimensions[ "dimension1"] = clean_custom_dimension_value( _domo) + + # Coordinator Model + _coordinator_model = self.pluginParameters.get("CoordinatorModel") + if _coordinator_model: + _custom_dimensions[ "dimension2"] = clean_custom_dimension_value( _coordinator_model) + + # Plugin Version + _plugin_version = self.pluginParameters.get("PluginVersion") + if _plugin_version: + _custom_dimensions[ "dimension3"] = clean_custom_dimension_value( _plugin_version) + + # Coordinator Firmware Version + _coordinator_version = self.pluginParameters.get("DisplayFirmwareVersion") + if _coordinator_version: + _custom_dimensions[ "dimension4"] = clean_custom_dimension_value( _coordinator_version) + + # Network Size + total, router, end_devices = get_network_size_items(self.pluginParameters.get("NetworkSize")) + if total: + _custom_dimensions[ "dimension5"] = clean_custom_dimension_value(total) + + # Certified Db Version + certified_db_version = self.pluginParameters.get("CertifiedDbVersion") + if certified_db_version: + _custom_dimensions[ "dimension6"] = clean_custom_dimension_value( certified_db_version) + + # OS Distribution + _distribution = get_distribution(self) + if _distribution: + _custom_dimensions[ "dimension7"] = clean_custom_dimension_value( _distribution) + + # Platform Architecture + _archi = get_architecture_model(self) + if _archi: + _custom_dimensions[ "dimension8"] = clean_custom_dimension_value( _archi) + + # Uptime + _uptime = get_uptime_category(self.statistics._start) + if _uptime: + _custom_dimensions[ "dimension9"] = clean_custom_dimension_value( _uptime) + + # Ronelab model + ronelab_model = get_ronelabs_model_custom_definition() + if ronelab_model: + _custom_dimensions[ "dimension10"] = clean_custom_dimension_value(ronelab_model) + + # Platform Id ( Pi Model ) + pi_model = get_raspberry_pi_model() + if pi_model: + _custom_dimensions[ "dimension11"] = clean_custom_dimension_value(pi_model) + + return _custom_dimensions + + +def matomo_plugin_analytics_infos(self): + send_matomo_request(self, "Z4DPluginInfos", None, populate_custom_dimmensions(self)) + + +def matomo_opt_out_action(self): + """ Tracks a user's opt-out action in Matomo. """ + send_matomo_request( self, action_name="Opt-Out Action", event_category="Privacy", event_action="Opt-Out", event_name="User Opted Out" ) + + +def matomo_opt_in_action(self): + """ Tracks a user's opt-oin action in Matomo. """ + send_matomo_request( self, action_name="Opt-In Action", event_category="Privacy", event_action="Opt-In", event_name="User Opted In" ) + + +def matomo_coordinator_initialisation(self): + send_matomo_request( self, action_name="Coordinator Action", event_category="Coordinator", event_action="NewNetwork", event_name="Coordinator Formed new network" ) + + +def matomo_plugin_shutdown(self): + send_matomo_request( self, action_name="Plugin Action", event_category="Plugin", event_action="Shutdown", event_name="Plugin Shutdown" ) + + +def matomo_plugin_restart(self): + send_matomo_request( self, action_name="Plugin Action", event_category="Plugin", event_action="Restart", event_name="Plugin Restart" ) + + +def matomo_plugin_started(self): + send_matomo_request( self, action_name="Plugin Action", event_category="Plugin", event_action="Started", event_name="Plugin Started" ) + + +def matomo_plugin_update(self, status): + if status: + send_matomo_request( self, action_name="Plugin Action", event_category="Plugin", event_action="SuccessfullUpdate", event_name="Plugin Update Successfully" ) + else: + send_matomo_request( self, action_name="Plugin Action", event_category="Plugin", event_action="ErrorUpdate", event_name="Plugin Update with error" ) + + +def send_matomo_request(self, action_name, custom_variable=None, custom_dimension=None, event_category=None, event_action=None, event_name=None): + """ + Sends a tracking request to Matomo with optional custom variables, dimensions, and events. + + Args: + action_name (str): Name of the action being tracked. + custom_variable (dict, optional): Custom variables to include. + custom_dimension (dict, optional): Custom dimensions to include. + event_category (str, optional): Category for the event (e.g., "Privacy"). + event_action (str, optional): Action for the event (e.g., "Opt-Out"). + event_name (str, optional): Name of the event (e.g., "User Opted Out"). + """ + + client_id = get_clientid(self) + self.log.logging( "Matomo", "Debug", f"send_matomo_request - Clien_id {client_id}") + if client_id is None: + self.log.logging( "Matomo", "Error", "Noting reported as MacAddress is None!") + return + + # Construct the payload + payload = { + "idsite": SITE_ID, + "rec": 1, + "apiv": APIV, + "action_name": action_name, + "uid": client_id, + } + + # Add custom variables if provided + if custom_variable: + try: + payload["cvar"] = json.dumps(custom_variable) + except TypeError as e: + self.log.logging("Matomo", "Error", f"Failed to serialize custom_variable: {e}") + return + + # Add custom dimensions if provided + if custom_dimension: + payload.update(custom_dimension) + + # Add event-specific parameters if provided + if event_category and event_action: + payload["e_c"] = event_category # Event category + payload["e_a"] = event_action # Event action + if event_name: + payload["e_n"] = event_name # Event name (optional) + + self.log.logging( "Matomo", "Debug", f"send_matomo_request - payload {payload}") + # Send the request + response = fetch_data_with_timeout(self, MATOMO_URL, payload) + + +def fetch_data_with_timeout(self, url, params, connect_timeout=3, read_timeout=5): + try: + response = requests.get(url, params=params, timeout=(connect_timeout, read_timeout)) + response.raise_for_status() # Raise HTTPError for bad responses (4xx and 5xx) + + if response.status_code == 200: + self.log.logging( "Matomo", "Debug", f"send_matomo_request - Request sent successfully! {response}") + + else: + self.log.logging( "Matomo", "Error", f"send_matomo_request - Failed to send request. Status code: {response.status_code}") + self.log.logging( "Matomo", "Error", "send_matomo_request - Response content:", response.content) + + except requests.exceptions.Timeout: + self.log.logging( "Matomo", "Error",f"Timeout after {connect_timeout}s connect / {read_timeout}s read.") + + except requests.exceptions.RequestException as e: + self.log.logging( "Matomo", "Error",f"Request failed: {e}") + + +def get_architecture_model(self): + """ + Retrieve the architecture model of the current Python runtime and system. + + Returns: + str: A string containing architecture information. + """ + try: + return f"python: {platform.python_version()} arch: {platform.architecture()[0]} machine: {platform.machine()} processor:{platform.processor()}" + except Exception as e: + self.log.logging( "Matomo", "Error", f"get_architecture_model error {e}") + return None + + +def get_ronelabs_model_custom_definition(): + if os.path.exists( RONELABS_MODEL_INFO ): + with open(RONELABS_MODEL_INFO) as f: + return f.readline().strip() + return None + + +def classify_uptime(uptime_seconds): + # Define thresholds in seconds for each category + thresholds = [ + (1 * 86400, "1 day"), + (2 * 86400, "2 days"), + (3 * 86400, "3 days"), + (4 * 86400, "4 days"), + (5 * 86400, "5 days"), + (7 * 86400, "1 week"), + (14 * 86400, "2 weeks"), + (21 * 86400, "3 weeks"), + (28 * 86400, "4 weeks"), + (30 * 86400, "1 month"), + (60 * 86400, "2 months"), + (90 * 86400, "3 months"), + (120 * 86400, "4 months"), + (150 * 86400, "5 months"), + (180 * 86400, "6 months"), + ] + + return next( + ( + label + for threshold, label in thresholds + if uptime_seconds <= threshold + ), + "Beyond 6 months", + ) + + +def get_uptime_category(start_time): + # Calculate uptime in seconds + uptime_seconds = time.time() - start_time + return classify_uptime(uptime_seconds) + + +def get_network_size_items(networksize): + + if not networksize: + return None, None, None + + # Split the string by " | " to separate each category + parts = networksize.split(" | ") + + # Extract individual values using string splitting + try: + networkTotalsize = int(parts[0].split(":")[1].strip()) # Extract the number after "Total: " + networkRoutersize = int(parts[1].split(":")[1].strip()) # Extract the number after "Routers: " + networkEndDevicesize = int(parts[2].split(":")[1].strip()) # Extract the number after "End Devices: " + + except IndexError: + print("Error: The string format doesn't match the expected pattern.") + return None, None, None + + return classify_nwk_size(networkTotalsize), classify_nwk_size(networkRoutersize), classify_nwk_size(networkEndDevicesize) + + +def classify_nwk_size(value): + if value < 5: + return "Micro" + elif 5 <= value < 10: + return "Small" + elif 10 <= value < 25: + return "Medium" + elif 25 <= value < 50: + return "Large" + elif 50 <= value < 75: + return "Very Large" + else: + return "Xtra Large" + + +def get_distribution(self): + try: + return f"{distro.name()} {distro.version()}" + except Exception as e: + self.log.logging( "Matomo", "Error", f"get_distribution error {e}") + return None + + +def clean_custom_dimension_value(value: str) -> str: + # Define a regex pattern for allowed characters: alphanumeric, spaces, underscores, hyphens, and periods + allowed_pattern = re.compile(r'[^a-zA-Z0-9 _.-]') + + # Replace disallowed characters with a space + cleaned_value = re.sub(allowed_pattern, ' ', value) + + # Collapse multiple spaces into a single space + cleaned_value = re.sub(r'\s{2,}', ' ', cleaned_value) + + # Optionally, strip leading and trailing spaces + return cleaned_value.strip() + + +def get_raspberry_pi_model(): + if os.path.exists( DEVICE_TREE_CONFIGURATION ): + with open( DEVICE_TREE_CONFIGURATION , 'r') as f: + return f.read().strip() + return None \ No newline at end of file diff --git a/plugin.py b/plugin.py index 730357a56..07672fd14 100644 --- a/plugin.py +++ b/plugin.py @@ -144,6 +144,11 @@ load_list_of_domoticz_widget) from Modules.heartbeat import processListOfDevices from Modules.input import zigbee_receive_message +from Modules.matomo_request import (matomo_coordinator_initialisation, + matomo_plugin_analytics_infos, + matomo_plugin_restart, + matomo_plugin_shutdown, + matomo_plugin_started) from Modules.paramDevice import initialize_device_settings from Modules.piZigate import switchPiZigate_mode from Modules.pluginHelpers import (check_firmware_level, @@ -582,6 +587,8 @@ def onStart(self): usage_percentage = round(((255 - free_slots) / 255) * 100, 1) self.log.logging("Plugin", "Status", f"Z4D Widgets usage is at {usage_percentage}% ({free_slots} units free)") + if self.internet_available and self.pluginconf.pluginConf["MatomoOptIn"]: + matomo_plugin_analytics_infos(self) self.log.logging("Plugin", "Status", f"Z4D started with {framework_status}") self.busy = False @@ -597,7 +604,11 @@ def onStop(self): None """ Domoticz.Log("onStop()") - + + if self.internet_available and self.pluginconf.pluginConf["MatomoOptIn"]: + matomo_plugin_shutdown(self) + matomo_plugin_analytics_infos(self) + # Flush ListOfDevices if self.log: self.log.logging("Plugin", "Log", "Flushing plugin database onto disk") @@ -787,6 +798,9 @@ def restart_plugin(self): error_message = "Connection lost with coordinator, restarting plugin" self.log.logging("Plugin", "Error", error_message) self.adminWidgets.updateNotificationWidget(Devices, error_message) + if self.internet_available and self.pluginconf.pluginConf["MatomoOptIn"]: + matomo_plugin_restart(self) + restartPluginViaDomoticzJsonApi(self, stop=False, url_base_api=Parameters["Mode5"]) #def onCommand(self, DeviceID, Unit, Command, Level, Color): @@ -881,6 +895,9 @@ def onHeartbeat(self): if self.internet_available: _check_plugin_version( self ) + if self.pluginconf.pluginConf["MatomoOptIn"] and self.HeartbeatCount % ( (9 * 3600) // HEARTBEAT) == 0: + matomo_plugin_analytics_infos(self) + if self.transport == "None": return @@ -935,6 +952,7 @@ def onHeartbeat(self): self.busy = _check_if_busy(self) return True + def _onConnect_status_error(self, Status, Description): self.log.logging("Plugin", "Error", "Failed to connect (" + str(Status) + ")") self.log.logging("Plugin", "Debug", "Failed to connect (" + str(Status) + ") with error: " + Description) @@ -1135,6 +1153,8 @@ def zigateInit_Phase1(self): # Check if we have to Erase PDM. if self.zigbee_communication == "native" and Parameters["Mode3"] == "True" and not self.ErasePDMDone and not self.ErasePDMinProgress: # Erase PDM zigate_erase_eeprom(self) + if self.internet_available and self.pluginconf.pluginConf["MatomoOptIn"]: + matomo_coordinator_initialisation(self) self.log.logging("Plugin", "Status", "Z4D has erase the Zigate PDM") #sendZigateCmd(self, "0012", "") self.PDMready = False @@ -1144,6 +1164,9 @@ def zigateInit_Phase1(self): return elif self.zigbee_communication == "zigpy" and Parameters["Mode3"] == "True" and not self.ErasePDMDone and not self.ErasePDMinProgress: self.log.logging("Plugin", "Status", "Z4D requests to form a new network") + if self.internet_available and self.pluginconf.pluginConf["MatomoOptIn"]: + matomo_coordinator_initialisation(self) + self.ErasePDMinProgress = True update_DB_device_status_to_reinit( self ) return @@ -1320,6 +1343,9 @@ def zigateInit_Phase3(self): if self.iaszonemgt and self.ControllerIEEE: self.iaszonemgt.setZigateIEEE(self.ControllerIEEE) + + if self.internet_available and self.pluginconf.pluginConf["MatomoOptIn"]: + matomo_plugin_started(self) def start_GrpManagement(self, homefolder): diff --git a/www/z4d/index.html b/www/z4d/index.html index b7e3c1790..485038ad6 100644 --- a/www/z4d/index.html +++ b/www/z4d/index.html @@ -24,5 +24,5 @@
This page requires JavaScript to work properly. Please enable JavaScript in your browser.