diff --git a/LoRaWAN_Payload_Description.md b/LoRaWAN_Payload_Description.md index 3f9dee7..caabb8f 100644 --- a/LoRaWAN_Payload_Description.md +++ b/LoRaWAN_Payload_Description.md @@ -3,61 +3,83 @@ ## Uplink Payload (Device → Network Server) **Port:** 2 -**Length:** 7 bytes +**Length:** 7 Bytes (ohne Energietracking) oder 15 Bytes (mit Energietracking) **Format:** Binary -| Byte | Field | Type | Unit | Description | -|------|-------|------|------|-------------| -| 0-1 | Room Temperature | int16_t | 0.01°C | SHT4x sensor reading (big-endian) | -| 2-3 | Floor Temperature | int16_t | 0.01°C | MLX90614 IR sensor reading (big-endian) * | -| 4-5 | Humidity | int16_t | 0.01% | SHT4x sensor reading (big-endian) | -| 6 | Relay State | uint8_t | - | Current relay status (0=OFF, 1=ON) | +| Byte | Field | Type | Unit | Description | +|-------|------------------|---------|----------|-------------| +| 0-1 | Room Temperature | int16 | 0.01°C | SHT4x sensor (big-endian) | +| 2-3 | Floor Temperature| int16 | 0.01°C | MLX90614 IR sensor (big-endian) * | +| 4-5 | Humidity | int16 | 0.01% | SHT4x sensor (big-endian) | +| 6 | Relay State | uint8 | - | 0=OFF, 1=ON | +| **7-9** | **Relay ON-Time** | **uint24** | **Sekunden** | **Kumulative Einschaltdauer seit Boot** | +| **10-12** | **Uptime** | **uint24** | **Sekunden** | **Gerätelaufzeit seit Boot** | +| **13-14** | **Load** | **uint16** | **Watt** | **Konfigurierte Heizlast** | -**Example Uplink:** -- `08 34 0A 28 15 E0 01` = 21.00°C room, 26.00°C floor, 56.00% humidity, relay ON +**Hinweis zur Abwärtskompatibilität:** Geräte ohne Energietracking senden 7 Bytes. Der Decoder erkennt die Version anhand von `bytes.length`. + +**Beispiel (15 Bytes):** +- `08 34 0A 28 15 E0 01 00 00 F0 00 01 2C 01 90` + = 21.00°C Raum, 26.00°C Boden, 56.00% Feuchte, Relay ON, 240s ON-Zeit, 300s Uptime, 400W Last ## Downlink Payload (Network Server → Device) **Port:** Any **Format:** Binary -### Combined Configuration Packet +### Kombiniertes Konfigurationspaket -**Length:** 3 bytes +**Length:** 3 Bytes **Format:** `[heating_enable][room_temp_threshold][floor_temp_threshold]` -| Byte | Field | Type | Range | Description | -|------|-------|------|-------|-------------| -| 0 | Heating Enable | uint8_t | 0-1 | Heating control logic (0=DISABLED, 1=ENABLED) | -| 1 | Room Temperature Threshold | uint8_t | 0-255 | Target room temperature in °C | -| 2 | Floor Temperature Threshold | uint8_t | 0-255 | Target floor temperature in °C | +| Byte | Field | Type | Range | Description | +|------|--------------------------|-------|-------|-------------| +| 0 | Heating Enable | uint8 | 0-1 | 0=DISABLED, 1=ENABLED | +| 1 | Room Temperature Threshold | uint8 | 0-255 | Raumtemperatur-Schwellwert in °C | +| 2 | Floor Temperature Threshold | uint8 | 0-255 | Bodentemperatur-Schwellwert in °C | -**Examples:** -- `01 16 19` = Enable heating, room 22°C, floor 25°C -- `00 14 18` = Disable heating, room 20°C, floor 24°C +**Beispiele:** +- `01 16 19` = Heizung AN, Raum 22°C, Boden 25°C +- `00 14 18` = Heizung AUS -### Send Interval Command +### Sendeintervall -**Length:** 2 bytes +**Length:** 2 Bytes **Format:** `'i'[interval_minutes]` -| Byte | Field | Type | Range | Description | -|------|-------|------|-------|-------------| -| 0 | Command | ASCII | 'i' (0x69) | Send interval command identifier | -| 1 | Interval | uint8_t | 1-255 | Send interval in minutes | +| Byte | Field | Type | Range | Description | +|------|----------|-------|-------|-------------| +| 0 | Command | ASCII | 0x69 | Befehlskennung 'i' | +| 1 | Interval | uint8 | 1-255 | Sendeintervall in Minuten | -**Examples:** -- `69 05` = Set send interval to 5 minutes -- `69 0A` = Set send interval to 10 minutes -- `69 0F` = Set send interval to 15 minutes -- `69 3C` = Set send interval to 60 minutes +**Beispiele:** +- `69 05` = 5 Minuten +- `69 0A` = 10 Minuten +- `69 3C` = 60 Minuten -## Data Conversion +### Last-Konfiguration -- **Temperature:** `value = (int16_t)(temperature * 100)` -- **Humidity:** `value = (int16_t)(humidity * 100)` -- **Decoding:** `actual_value = received_value / 100.0` +**Length:** 3 Bytes +**Format:** `'w'[watts_high][watts_low]` -## Special Cases +| Byte | Field | Type | Range | Description | +|------|-------------|-------|--------|-------------| +| 0 | Command | ASCII | 0x77 | Befehlskennung 'w' | +| 1-2 | Load Watts | uint16 | 1-65535 | Heizlast in Watt (big-endian), persistent gespeichert | -**\* Floor Temperature Sensor:** If the MLX90614 sensor is not available or not responding, the floor temperature will be transmitted as `-273.15°C` (hex: `95 5D`). \ No newline at end of file +**Beispiele:** +- `77 01 90` = 400W (0x0190) +- `77 03 E8` = 1000W (0x03E8) + +## Datenkonvertierung + +- **Temperatur:** `raw = (int16)(temperature * 100)` → `actual = raw / 100.0` +- **Luftfeuchtigkeit:** `raw = (int16)(humidity * 100)` → `actual = raw / 100.0` +- **ON-Zeit / Uptime:** uint24 big-endian, Sekunden seit Boot (wird bei Reboot zurückgesetzt) +- **Last:** uint16 big-endian, Watt + +## Sonderfälle + +**\* Bodentemperatursensor:** Wenn der MLX90614 nicht verfügbar ist, wird `-273.15°C` (hex: `95 5D`) übertragen. + +**Reboot-Erkennung (Portal):** Wenn `relay_on_time` im aktuellen Paket kleiner ist als im vorherigen, wurde das Gerät neu gestartet. diff --git a/README.md b/README.md index 2ad91cf..d9e1a4c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,15 @@ Alle Befehle als Base64 in ChirpStack eingeben: | Heizung AUS | `00 14 18` | `ABQa` | Heizung komplett deaktiviert | | Heizung AN, 20°C Raum, 24°C Boden | `01 14 18` | `ARQa` | Automatik mit niedrigeren Schwellwerten | +### Heizlast konfigurieren (3 Bytes) +**Format**: `'w'[watts_high][watts_low]` + +| Beschreibung | Hex | Base64 | Funktion | +|--------------|-----|--------|----------| +| 400W (Standard) | `77 01 90` | `dwGQ` | Typische Fußbodenheizung | +| 1000W | `77 03 E8` | `dwPo` | Stärkere Heizung | +| 2000W | `77 07 D0` | `dw/Q` | Hochleistungsheizung | + ### Sendeintervall ändern (2 Bytes) **Format**: `'i'[minuten]` @@ -58,14 +67,17 @@ Alle Befehle als Base64 in ChirpStack eingeben: ## Datenformat (Uplink) -Alle 10 Minuten wird ein 7-Byte Datenpaket gesendet: +Alle 10 Minuten wird ein 15-Byte Datenpaket gesendet (abwärtskompatibel zu 7-Byte): -| Byte | Inhalt | Format | -|------|--------|--------| -| 0-1 | Raumtemperatur | int16 × 100 (z.B. 2134 = 21.34°C) | -| 2-3 | Bodentemperatur | int16 × 100 | -| 4-5 | Luftfeuchtigkeit | int16 × 100 (z.B. 4520 = 45.20%) | -| 6 | Relais-Status | 0 = AUS, 1 = AN | +| Byte | Inhalt | Format | +|-------|-------------------|--------| +| 0-1 | Raumtemperatur | int16 × 100 (z.B. 2134 = 21.34°C) | +| 2-3 | Bodentemperatur | int16 × 100 | +| 4-5 | Luftfeuchtigkeit | int16 × 100 (z.B. 4520 = 45.20%) | +| 6 | Relais-Status | 0 = AUS, 1 = AN | +| 7-9 | Relay ON-Zeit | uint24, Sekunden seit Boot | +| 10-12 | Uptime | uint24, Sekunden seit Boot | +| 13-14 | Heizlast | uint16, Watt | ## Betriebsmodi diff --git a/chirpstack3_decoder.js b/chirpstack3_decoder.js new file mode 100644 index 0000000..6bc45a7 --- /dev/null +++ b/chirpstack3_decoder.js @@ -0,0 +1,33 @@ +// Decode decodes an array of bytes into an object. +// - fPort contains the LoRaWAN fPort number +// - bytes is an array of bytes, e.g. [225, 230, 255, 0] +// - variables contains the device variables e.g. {"calibration": "3.5"} +// The function must return an object, e.g. {"temperature": 22.5} +function Decode(fPort, bytes, variables) { + + // Hilfsfunktion: 16-Bit signed Big Endian lesen + function readInt16BE(b0, b1) { + var val = (b0 << 8) | b1; + if (val & 0x8000) val = val - 0x10000; + return val; + } + + if (bytes.length < 7) { + return { error: "invalid payload length" }; + } + + var d = {}; + + d.heating = readInt16BE(bytes[0], bytes[1]) / 100.0; + d.heatingFloor = readInt16BE(bytes[2], bytes[3]) / 100.0; + d.airHumidity = readInt16BE(bytes[4], bytes[5]) / 100.0; + d.switch = bytes[6]; + + if (bytes.length >= 15) { + d.relayOnTime = (bytes[7] << 16) | (bytes[8] << 8) | bytes[9]; + d.uptime = (bytes[10] << 16) | (bytes[11] << 8) | bytes[12]; + d.loadWatts = (bytes[13] << 8) | bytes[14]; + } + + return d; +} diff --git a/chirpstack3_encoder.js b/chirpstack3_encoder.js index 0b64807..80687ab 100644 --- a/chirpstack3_encoder.js +++ b/chirpstack3_encoder.js @@ -26,6 +26,16 @@ function Encode(fPort, obj, variables) { return bytes; } + // Load configuration format (3 bytes): 'w' + watts (2 bytes big-endian) + if (obj.load_watts !== undefined) { + var w = parseInt(obj.load_watts); + bytes[0] = 0x77; // ASCII 'w' + bytes[1] = (w >> 8) & 0xFF; + bytes[2] = w & 0xFF; + + return bytes; + } + return null; } @@ -34,4 +44,7 @@ function Encode(fPort, obj, variables) { // Result: [0x01, 0x16, 0x19] // Send interval: {"send_interval": 5} -// Result: [0x69, 0x05] \ No newline at end of file +// Result: [0x69, 0x05] + +// Load configuration: {"load_watts": 400} +// Result: [0x77, 0x01, 0x90] \ No newline at end of file diff --git a/debug_G2H-RS.php b/debug_G2H-RS.php new file mode 100644 index 0000000..f65ff08 --- /dev/null +++ b/debug_G2H-RS.php @@ -0,0 +1,530 @@ + 0, + 'status_message' => 'Methode GET nicht erlaubt.' + ); + break; + + case 'POST': + $daten = json_decode(file_get_contents('php://input'), true); + + if($debug) { + file_put_contents($file, "\n\nProcessing POST request\n", FILE_APPEND | LOCK_EX); + } + + // Skip downlink confirmations - only process uplink messages with sensor data + if(!isset($daten['objectJSON']) && !isset($daten['object'])) { + if($debug) { + file_put_contents($file, "Skipping - no sensor data (likely downlink confirmation)\n", FILE_APPEND | LOCK_EX); + } + $response = array( + 'status' => 200, + 'status_message' => 'downlink confirmation processed' + ); + break; + } + + // Initialize variables + $heating = ''; + $heatingFloor = ''; + $airHumidity = ''; + $switch = ''; + + // Extract data from decoded payload + $mesg = array(); + if(isset($daten['object'])) { + $mesg = $daten['object']; + } elseif(isset($daten['objectJSON'])) { + $mesg = json_decode($daten['objectJSON'], true); + } + + if(!empty($mesg)) { + if(isset($mesg['heating'])) { + $heating = $mesg['heating']; + } + + if(isset($mesg['heatingFloor'])) { + $heatingFloor = $mesg['heatingFloor']; + } + + if(isset($mesg['airHumidity'])) { + $airHumidity = $mesg['airHumidity']; + } + + if(isset($mesg['switch'])) { + $switch = $mesg['switch']; + } + } + + // Energy tracking fields (nur bei 15-Byte Payload vorhanden) + $relayOnTime = isset($mesg['relayOnTime']) ? (int)$mesg['relayOnTime'] : null; + $uptime = isset($mesg['uptime']) ? (int)$mesg['uptime'] : null; + $loadWatts = isset($mesg['loadWatts']) ? (int)$mesg['loadWatts'] : null; + + // Verbrauchsberechnung via DB-Tabelle g2h_rs_energy_tracking + $powerConsumptionWh = null; + if($relayOnTime !== null && $uptime !== null && $loadWatts !== null) { + $dbiot2 = new iotdbObj(); + $conn2 = $dbiot2->getConnstring_iot(); + $devId = mysqli_real_escape_string($conn2, $daten['deviceName']); + $row = mysqli_fetch_assoc(mysqli_query($conn2, + "SELECT relay_on_time, power_consumption_wh, UNIX_TIMESTAMP(updated_at) AS last_ts + FROM g2h_rs_energy_tracking WHERE device_id = '$devId'")); + + $prevOnTime = $row ? (int)$row['relay_on_time'] : 0; + $prevWh = $row ? (float)$row['power_consumption_wh'] : 0.0; + $elapsed = $row ? (time() - (int)$row['last_ts']) : 0; + + // Reboot-Erkennung: Uptime kleiner als vergangene Realzeit (60s Toleranz) + $reboot = ($row && $uptime < $elapsed - 60); + if($reboot) { + $delta = $relayOnTime; // Counter läuft seit Neustart von 0 + } else { + $delta = max(0, $relayOnTime - $prevOnTime); + } + $powerConsumptionWh = round($prevWh + ($delta * $loadWatts) / 3600.0, 4); + + mysqli_query($conn2, + "INSERT INTO g2h_rs_energy_tracking + (device_id, relay_on_time, power_consumption_wh) + VALUES ('$devId', $relayOnTime, $powerConsumptionWh) + ON DUPLICATE KEY UPDATE + relay_on_time = $relayOnTime, + power_consumption_wh = $powerConsumptionWh, + updated_at = CURRENT_TIMESTAMP"); + } + + // Extract time information + $time = 'TIME:'; + if(isset($daten['rxInfo']['0']['time'])) { + $time .= date("U", strtotime($daten['rxInfo']['0']['time'])); + } elseif(isset($daten['rxInfo']['time'])) { + $time .= date("U", strtotime($daten['rxInfo']['time'])); + } + + // Extract location information + $computedLocation = array(); + if(isset($daten['rxInfo']['0']['location'])) { + $location = $daten['rxInfo']['0']['location']; + if(isset($location['latitude']) && isset($location['longitude'])) { + $computedLocation['lat'] = (float)$location['latitude']; + $computedLocation['lng'] = (float)$location['longitude']; + $computedLocation['radius'] = 20; + $computedLocation['source'] = 2; + $computedLocation['status'] = 1; + } + } elseif(isset($daten['rxInfo']['location'])) { + $location = $daten['rxInfo']['location']; + if(isset($location['latitude']) && isset($location['longitude'])) { + $computedLocation['lat'] = (float)$location['latitude']; + $computedLocation['lng'] = (float)$location['longitude']; + $computedLocation['radius'] = 2000; + $computedLocation['source'] = 2; + $computedLocation['status'] = 1; + } + } + + $response = array( + 'status' => 400, + 'status_message' => 'success' + ); + + // Build JSON payload for IoT portal + $json = array(); + $json['id'] = $daten['deviceName']; + + if(isset($daten['importtime'])) { + $json['time'] = $daten['importtime']; + } else { + $json['time'] = time(); + } + + $json['computedLocation'] = $computedLocation; + $json['data'] = "G2H-RS-HTGS-LoRA"; + + // Map G2H-RS sensor data to IoT portal format + if($heating !== '') { + $json['heating'] = $heating; + } + + if($heatingFloor !== '') { + $json['heatingFloor'] = $heatingFloor; + } + + if($airHumidity !== '') { + $json['airHumidity'] = $airHumidity; + } + + if($switch !== '') { + $json['switch'] = $switch; + } + + if($relayOnTime !== null) $json['relayOnTime'] = $relayOnTime; + if($uptime !== null) $json['uptime'] = $uptime; + if($loadWatts !== null) $json['loadWatts'] = $loadWatts; + if($powerConsumptionWh !== null) $json['power_consumption'] = $powerConsumptionWh; + + // Handle sequence number - use fCnt from ChirpStack if available + $seqNumber_dev = 1; // Default + + if(isset($daten['fCnt']) && $daten['fCnt'] != '') { + // Use LoRaWAN frame counter as sequence number + $seqNumber_dev = (int)$daten['fCnt']; + } elseif(isset($mesg['seqno'])) { + // Use sequence number from decoded payload if available + $seqNumber_dev = (int)$mesg['seqno']; + } else { + // Fallback to internal counter + if(isset($seqNumber[$daten['deviceName']])) { + $seqNumber_dev = $seqNumber[$daten['deviceName']]; + $seqNumber_dev++; + } else { + $seqNumber_dev = isset($seqNumber['1']) ? $seqNumber['1'] : 1; + $seqNumber_dev++; + } + $seqNumber[$daten['deviceName']] = $seqNumber_dev; + } + + $json['seqNumber'] = (int)$seqNumber_dev; + $json['lqi'] = "Good"; + $json['decoderClass'] = "G2HRoomSensor"; + $json['countrycode'] = "276"; + + // Skip sending if sequence number is 1 (initialization) + if($json['seqNumber'] == '1') { + if($debug) { + file_put_contents($file, "\n\nSkipping send - seqNumber is 1\n", FILE_APPEND | LOCK_EX); + file_put_contents($file, json_encode($json), FILE_APPEND | LOCK_EX); + } + } else { + // Send to IoT portal + $json_encoded = json_encode($json); + + if($debug) { + file_put_contents($file, "\n\nSending to IoT portal:\n", FILE_APPEND | LOCK_EX); + file_put_contents($file, $json_encoded, FILE_APPEND | LOCK_EX); + } + + $url = 'https://iot.satspeed.com/api/public/message'; + $headers = array( + 'Accept: application/json', + 'Content-Type: application/json' + ); + + $curl = curl_init(); + curl_setopt($curl, CURLOPT_POST, true); + curl_setopt($curl, CURLOPT_POSTFIELDS, $json_encoded); + curl_setopt($curl, CURLOPT_URL, $url); + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true); + + $response = curl_exec($curl); + $code = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if($debug) { + file_put_contents($file, "\n\nResponse code: " . $code . "\n", FILE_APPEND | LOCK_EX); + file_put_contents($file, "Response: " . $response . "\n", FILE_APPEND | LOCK_EX); + } + + // AUTO-DOWNLINK: Send settings-based downlink after successful uplink processing + if($code == 200) { + sendAutomaticDownlink($daten['deviceName'], $daten['devEUI'], $file, $debug); + } + } + + break; + + case 'PUT': + $response = array( + 'status' => 0, + 'status_message' => 'Methode PUT nicht erlaubt.' + ); + break; + + case 'DELETE': + $response = array( + 'status' => 0, + 'status_message' => 'Methode DELETE nicht erlaubt.' + ); + break; + } + + +header('Content-Type: application/json'); +echo json_encode(["ok" => true]); + + +// Database operations - store message data +sleep(1); +$dbiot = new iotdbObj(); +$iotconnection = $dbiot->getConnstring_iot(); + +// Update last device message table +if(isset($daten['deviceName'])) { + $sql2 = "SELECT * FROM device_messages WHERE device_id = '".$daten['deviceName']."' ORDER BY id DESC LIMIT 1"; + $result2 = mysqli_query($iotconnection, $sql2); + $row = mysqli_fetch_assoc($result2); + + if($row && isset($row['id'])) { + $sql3 = "UPDATE last_device_message SET + id = '".$row['id']."', + time = '".$row['time']."', + data = '".$row['data']."', + seqNumber = '".$row['seqNumber']."', + created_at = '".$row['created_at']."', + updated_at = '".$row['updated_at']."', + atlas_location = '".$row['atlas_location']."', + data_formatted = '".$row['data_formatted']."', + lqi = '".$row['lqi']."', + decoderClass = '".$row['decoderClass']."' + WHERE device_id = '".$row['device_id']."'"; + mysqli_query($iotconnection, $sql3); + } +} + +// === AUTO-DOWNLINK FUNCTIONS === + +function sendAutomaticDownlink($deviceName, $devEUI, $logFile, $debug) { + if($debug) { + file_put_contents($logFile, "\n\n=== AUTO-DOWNLINK ===\n", FILE_APPEND | LOCK_EX); + file_put_contents($logFile, "Device: $deviceName, DevEUI: $devEUI\n", FILE_APPEND | LOCK_EX); + } + + // Convert Base64 DevEUI to Hex if needed + $hexDevEUI = $devEUI; + if(!ctype_xdigit($devEUI)) { + // DevEUI is Base64, decode to binary then to hex + $binaryDevEUI = base64_decode($devEUI); + $hexDevEUI = bin2hex($binaryDevEUI); + if($debug) { + file_put_contents($logFile, "Converted DevEUI: $devEUI -> $hexDevEUI\n", FILE_APPEND | LOCK_EX); + } + } + + // Get current settings from database + $settings = getG2HSettings($deviceName, $debug, $logFile); + + if(empty($settings)) { + if($debug) { + file_put_contents($logFile, "No settings found for device - skipping downlink\n", FILE_APPEND | LOCK_EX); + } + return; + } + + // Collect all settings for combined packet + $combinedSettings = [ + 'heating_enable' => true, // Default: heating enabled + 'room_temp' => 20, // Default room temperature + 'floor_temp' => 22 // Default floor temperature + ]; + + foreach($settings as $setting) { + switch($setting['type']) { + case 'r1': // Relais control + $combinedSettings['heating_enable'] = ($setting['value'] == '1'); + break; + case 't1': // Room temperature + $combinedSettings['room_temp'] = intval($setting['value']); + break; + case 't2': // Floor temperature + $combinedSettings['floor_temp'] = intval($setting['value']); + break; + } + } + + // Send combined downlink packet + $jsonPayload = json_encode($combinedSettings); + + if($debug) { + file_put_contents($logFile, "Preparing combined downlink packet\n", FILE_APPEND | LOCK_EX); + file_put_contents($logFile, "Combined settings: " . $jsonPayload . "\n", FILE_APPEND | LOCK_EX); + file_put_contents($logFile, "heating_enable: " . ($combinedSettings['heating_enable'] ? 'true' : 'false') . "\n", FILE_APPEND | LOCK_EX); + file_put_contents($logFile, "room_temp: " . $combinedSettings['room_temp'] . "\n", FILE_APPEND | LOCK_EX); + file_put_contents($logFile, "floor_temp: " . $combinedSettings['floor_temp'] . "\n", FILE_APPEND | LOCK_EX); + } + + sendChirpStackJsonDownlink($hexDevEUI, $jsonPayload, $logFile, $debug); +} + +function getG2HSettings($deviceName, $debug, $logFile) { + $dbiot = new iotdbObj(); + $iotconnection = $dbiot->getConnstring_iot(); + + // Query for G2H-RS settings based on DB analysis (without settings_area for flexibility): + // - Relais: name='active' AND settings_type=32 + // - Raumtemperatur: name='fixValue' AND settings_type=32 + // - Bodentemperatur: name='fixValue' AND settings_type=31 + $sql = "SELECT sf.value, sf.settings_type, sf.name, s.subject_id + FROM settings_fields sf, settings s + WHERE s.subject_id = '$deviceName' + AND s.user_type = 'user' + AND s.id = sf.settings + AND ( + (sf.name = 'active' AND sf.settings_type = 32) OR + (sf.name = 'fixValue' AND sf.settings_type = 32) OR + (sf.name = 'fixValue' AND sf.settings_type = 31) + )"; + + if($debug) { + file_put_contents($logFile, "SQL: $sql\n", FILE_APPEND | LOCK_EX); + } + + $result = mysqli_query($iotconnection, $sql); + $settings = array(); + + while($row = mysqli_fetch_assoc($result)) { + $setting = array(); + + if($row['name'] == 'active' && $row['settings_type'] == '32') { + $setting['type'] = 'r1'; // Relais control + $setting['value'] = ($row['value'] == 'true' || $row['value'] == '1') ? '1' : '0'; + } + elseif($row['name'] == 'fixValue' && $row['settings_type'] == '32') { + $setting['type'] = 't1'; // Room temperature + $setting['value'] = $row['value']; + } + elseif($row['name'] == 'fixValue' && $row['settings_type'] == '31') { + $setting['type'] = 't2'; // Floor temperature + $setting['value'] = $row['value']; + } + + if(!empty($setting)) { + $settings[] = $setting; + if($debug) { + file_put_contents($logFile, "Found setting: {$setting['type']} = {$setting['value']}\n", FILE_APPEND | LOCK_EX); + } + } + } + + return $settings; +} + + +function clearChirpStackDeviceQueue($devEUI, $logFile = null, $debug = false) { + $CS_URL = 'http://172.17.13.42:8080'; + $TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGlfa2V5X2lkIjoiMDcxM2MyNzUtMWIyNy00YzQ4LTlkYjQtNjc3MDg1NTgzN2M2IiwiYXVkIjoiYXMiLCJpc3MiOiJhcyIsIm5iZiI6MTc1NTk1OTgxOSwic3ViIjoiYXBpX2tleSJ9.sFDgV0Sk5LvXD1KAq50pXP2dmzf6qVtwJP8hPkriHpY'; + + $url = "{$CS_URL}/api/devices/{$devEUI}/queue"; + + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => "DELETE", + CURLOPT_HTTPHEADER => [ + "Grpc-Metadata-Authorization: Bearer {$TOKEN}", + "Content-Type: application/json" + ], + CURLOPT_TIMEOUT => 15, + CURLOPT_FAILONERROR => false, + ]); + + $response = curl_exec($curl); + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if($debug && $logFile) { + file_put_contents($logFile, "Queue cleared for device {$devEUI}, HTTP {$httpCode}: {$response}\n", FILE_APPEND | LOCK_EX); + } + + return $httpCode === 200; +} + +function sendChirpStackJsonDownlink($devEUI, $jsonPayload, $logFile, $debug) { + $CS_URL = 'http://172.17.13.42:8080'; + $TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGlfa2V5X2lkIjoiMDcxM2MyNzUtMWIyNy00YzQ4LTlkYjQtNjc3MDg1NTgzN2M2IiwiYXVkIjoiYXMiLCJpc3MiOiJhcyIsIm5iZiI6MTc1NTk1OTgxOSwic3ViIjoiYXBpX2tleSJ9.sFDgV0Sk5LvXD1KAq50pXP2dmzf6qVtwJP8hPkriHpY'; + + // Clear the queue first to prevent queue overflows + // Wait 5 seconds to ensure old packets had opportunity to be sent during downlink windows + sleep(5); + clearChirpStackDeviceQueue($devEUI, $logFile, $debug); + + // ChirpStack v3 format: we need to encode manually and send as base64 data + $jsonObject = json_decode($jsonPayload, true); + + // Manual encoding like the ChirpStack encoder does + $bytes = []; + $bytes[0] = $jsonObject['heating_enable'] ? 1 : 0; // Byte 0: heating enable + $bytes[1] = intval($jsonObject['room_temp']); // Byte 1: room temp + $bytes[2] = intval($jsonObject['floor_temp']); // Byte 2: floor temp + + $binaryPayload = pack('C*', ...$bytes); + $base64Payload = base64_encode($binaryPayload); + + $queueItem = [ + 'deviceQueueItem' => [ + 'confirmed' => false, + 'fPort' => 2, + 'data' => $base64Payload, // Base64 data for ChirpStack v3 + ], + ]; + + $url = "{$CS_URL}/api/devices/{$devEUI}/queue"; + $headers = [ + 'Accept: application/json', + 'Content-Type: application/json', + 'Grpc-Metadata-Authorization: Bearer ' . $TOKEN, + ]; + + if($debug) { + file_put_contents($logFile, "Sending JSON downlink: $jsonPayload\n", FILE_APPEND | LOCK_EX); + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => json_encode($queueItem, JSON_UNESCAPED_SLASHES), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_FAILONERROR => false, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if($debug) { + file_put_contents($logFile, "Binary Downlink HTTP $httpCode: $response\n", FILE_APPEND | LOCK_EX); + file_put_contents($logFile, "Encoded bytes: " . bin2hex($binaryPayload) . " (Base64: $base64Payload)\n", FILE_APPEND | LOCK_EX); + file_put_contents($logFile, "Full API Payload: " . json_encode($queueItem, JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND | LOCK_EX); + } +} + + +// Save sequence number changes +setarray($seqNumber); + +function getseqno() +{ + $testarray = unserialize(file_get_contents('/var/www/iot-dev/web/seqno.txt')); + return ($testarray); +} + +function setarray($seqno) +{ + file_put_contents('/var/www/iot-dev/web/seqno.txt', serialize($seqno)); + return; +} + +?> diff --git a/src/energy.c b/src/energy.c new file mode 100644 index 0000000..a6fa9de --- /dev/null +++ b/src/energy.c @@ -0,0 +1,62 @@ +#include +#include + +#include + +static int64_t relay_on_timestamp = 0; +static uint32_t relay_on_time_seconds = 0; +static bool relay_currently_on = false; +static uint16_t load_watts = 400; + +static int energy_set(const char *key, size_t len, settings_read_cb read_cb, void *cb_arg) +{ + if (strcmp(key, "load_watts") == 0) { + read_cb(cb_arg, &load_watts, sizeof(load_watts)); + } + return 0; +} + +SETTINGS_STATIC_HANDLER_DEFINE(energy, "energy", NULL, energy_set, NULL, NULL); + +void energy_init(void) +{ + settings_load_subtree("energy"); +} + +void energy_update_relay_state(bool new_state) +{ + int64_t now = k_uptime_get(); + + if (relay_currently_on && !new_state) { + relay_on_time_seconds += (uint32_t)((now - relay_on_timestamp) / 1000); + } + if (new_state && !relay_currently_on) { + relay_on_timestamp = now; + } + relay_currently_on = new_state; +} + +uint32_t energy_get_on_time_seconds(void) +{ + if (relay_currently_on) { + int64_t now = k_uptime_get(); + return relay_on_time_seconds + (uint32_t)((now - relay_on_timestamp) / 1000); + } + return relay_on_time_seconds; +} + +uint32_t energy_get_uptime_seconds(void) +{ + return (uint32_t)(k_uptime_get() / 1000); +} + +uint16_t energy_get_load_watts(void) +{ + return load_watts; +} + +void energy_set_load_watts(uint16_t watts) +{ + load_watts = watts; + settings_save_one("energy/load_watts", &load_watts, sizeof(load_watts)); +} diff --git a/src/energy.h b/src/energy.h new file mode 100644 index 0000000..6415570 --- /dev/null +++ b/src/energy.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +void energy_init(void); +void energy_update_relay_state(bool state); +uint32_t energy_get_on_time_seconds(void); +uint32_t energy_get_uptime_seconds(void); +uint16_t energy_get_load_watts(void); +void energy_set_load_watts(uint16_t watts); diff --git a/src/lora/lorawan.c b/src/lora/lorawan.c index 0b398b3..eb02084 100644 --- a/src/lora/lorawan.c +++ b/src/lora/lorawan.c @@ -6,6 +6,7 @@ #include #include +#include #define SETTINGS_KEY "LORAWAN_DEV_NONCE" @@ -41,12 +42,32 @@ static void dl_callback(uint8_t port, uint8_t flags, int16_t rssi, int8_t snr, u LOG_HEXDUMP_INF(hex_data, len, "Payload: "); } + /* Send interval command: 'i' + interval_minutes (1 byte) + * Format: ['i'][interval_minutes] + * Example: 'i' + 5 = 5 minutes + */ + if (len >= 2 && hex_data[0] == 'i') + { + uint8_t new_interval = hex_data[1]; + send_interval_minutes = new_interval; + LOG_INF("Send interval updated to %d minutes", send_interval_minutes); + } + /* Load configuration: 'w' + watts (2 bytes big-endian) + * Format: ['w'][watts_high][watts_low] + * Example: 0x77 0x01 0x90 = 400W + */ + else if (len >= 3 && hex_data[0] == 'w') + { + uint16_t new_load = ((uint16_t)hex_data[1] << 8) | hex_data[2]; + energy_set_load_watts(new_load); + LOG_INF("Load updated to %d W", new_load); + } /* Combined packet format: [heating_enable][temp1_threshold][temp2_threshold] * Byte 0: Heating control (0=DISABLED, 1=ENABLED) * Byte 1: Room temperature threshold (°C) * Byte 2: Floor temperature threshold (°C) */ - if (len >= 3) + else if (len >= 3) { uint8_t heating_enable = hex_data[0]; uint8_t room_temp_threshold = hex_data[1]; @@ -86,16 +107,6 @@ static void dl_callback(uint8_t port, uint8_t flags, int16_t rssi, int8_t snr, u LOG_INF("Heating control logic enabled with new thresholds"); } } - /* Send interval command: 'i' + interval_minutes (1 byte) - * Format: ['i'][interval_minutes] - * Example: 'i' + 5 = 5 minutes - */ - else if (len >= 2 && hex_data[0] == 'i') - { - uint8_t new_interval = hex_data[1]; - send_interval_minutes = new_interval; - LOG_INF("Send interval updated to %d minutes", send_interval_minutes); - } } struct lorawan_downlink_cb downlink_cb = { @@ -342,13 +353,12 @@ void send_data_packet(char *data, uint8_t data_len) } } -void create_data_package(char *buffer, float temp_room_mlx, float temp_floor, float humidity, uint8_t relais_state) +void create_data_package(char *buffer, float temp_room_mlx, float temp_floor, float humidity, uint8_t relais_state, uint32_t on_time, uint32_t uptime, uint16_t load_watts) { int16_t temp = (int16_t)(temp_room_mlx * 100); int16_t floor_temp = (int16_t)(temp_floor * 100); int16_t hum = (int16_t)(humidity * 100); - // Packen der Daten in das Byte-Array buffer[0] = (temp >> 8) & 0xFF; buffer[1] = temp & 0xFF; buffer[2] = (floor_temp >> 8) & 0xFF; @@ -356,4 +366,18 @@ void create_data_package(char *buffer, float temp_room_mlx, float temp_floor, fl buffer[4] = (hum >> 8) & 0xFF; buffer[5] = hum & 0xFF; buffer[6] = relais_state; + + /* Relay ON-time (uint24, seconds since boot) */ + buffer[7] = (on_time >> 16) & 0xFF; + buffer[8] = (on_time >> 8) & 0xFF; + buffer[9] = on_time & 0xFF; + + /* Uptime (uint24, seconds since boot) */ + buffer[10] = (uptime >> 16) & 0xFF; + buffer[11] = (uptime >> 8) & 0xFF; + buffer[12] = uptime & 0xFF; + + /* Load (uint16, Watt) */ + buffer[13] = (load_watts >> 8) & 0xFF; + buffer[14] = load_watts & 0xFF; } \ No newline at end of file diff --git a/src/lora/lorawan.h b/src/lora/lorawan.h index 8a409ff..f253640 100644 --- a/src/lora/lorawan.h +++ b/src/lora/lorawan.h @@ -29,4 +29,4 @@ void join_network_abp(); void send_data_packet(char *data, uint8_t data_len); -void create_data_package(char *package, float temp_room_mlx, float temp_floor, float humidity, uint8_t relais_state); +void create_data_package(char *package, float temp_room_mlx, float temp_floor, float humidity, uint8_t relais_state, uint32_t on_time, uint32_t uptime, uint16_t load_watts); diff --git a/src/main.c b/src/main.c index 2ddc4cb..3fec7c4 100644 --- a/src/main.c +++ b/src/main.c @@ -11,6 +11,7 @@ #include #include #include +#include LOG_MODULE_REGISTER(g2h_heat_main); @@ -31,6 +32,7 @@ int main(void) init_mlx90614(); init_sht4x(); init_lorawan(); + energy_init(); // join_network_otaa(); join_network_abp(); @@ -86,22 +88,26 @@ int main(void) // Heat OFF if ANY temperature reaches its threshold if (temp_below_threshold && floor_below_threshold) { - relais_state = 1; // Heat on - both temps below thresholds + relais_state = 1; } else { - relais_state = 0; // Heat off - at least one threshold reached + relais_state = 0; } + energy_update_relay_state(relais_state); set_relais_state(relais_state); } printk("relais: %d (heating_enabled: %d, temp_th: %d°C, floor_th: %d°C)\n", relais_state, heating_logic_enabled, temperature_threshold, floor_temp_threshold); /* Pack and send values */ - char data[7]; - create_data_package(data, temp, floor_temp, humidity, relais_state); - LOG_HEXDUMP_DBG(data, 7, "data"); - send_data_packet(data, 7); + char data[15]; + create_data_package(data, temp, floor_temp, humidity, relais_state, + energy_get_on_time_seconds(), + energy_get_uptime_seconds(), + energy_get_load_watts()); + LOG_HEXDUMP_DBG(data, 15, "data"); + send_data_packet(data, 15); /* Delay until next cycle */ k_sleep(K_MINUTES(send_interval_minutes));