diff --git a/addons/asksinanalyzer/.gitignore b/addons/asksinanalyzer/.gitignore new file mode 100644 index 000000000..74cf9f288 --- /dev/null +++ b/addons/asksinanalyzer/.gitignore @@ -0,0 +1 @@ +asksinanalyzer-*.tar.gz diff --git a/addons/asksinanalyzer/README.md b/addons/asksinanalyzer/README.md new file mode 100644 index 000000000..54c304fc5 --- /dev/null +++ b/addons/asksinanalyzer/README.md @@ -0,0 +1,67 @@ +# AskSinAnalyzer CCU + +Port des [AskSinAnalyzerXS](https://github.com/psi-4ward/AskSinAnalyzerXS) direkt auf die CCU: +Logging und grafische Auswertung aller Homematic-Funktelegramme (BidCos RX/TX dekodiert +inkl. RSSI, HmIP als Rohdaten) — ohne zusätzlichen Arduino-Sniffer. + +## Architektur + +``` +Funkmodul ⇄ multimacd (TrafficLogger) + │ schreibt /var/log/multimacd-traffic-YYYY-MM-DD.log + ▼ + www/api.cgi (tclsh, lighttpd) + │ Offset-Tailing, Geräteliste (rfd-XML-RPC + ReGa) + ▼ + www/index.html (Single-Page-UI, kein externes CDN) +``` + +- **Datenquelle** ist der `TrafficLogger` in `src/multimacd` (Konfig-Schalter + `Traffic Log = 1`, `Traffic Log Directory = /var/log` in der multimacd.conf + bzw. `/etc/config_templates/multimacd.conf`). `api.cgi` liest das + `Traffic Log Directory` aus `/etc/config/multimacd.conf` (Fallback `/var/log`), + ein geändertes Verzeichnis wird also automatisch übernommen. +- **`www/api.cgi`** — Endpunkte: + - `?cmd=dates` — verfügbare Log-Tage (JSON) + - `?cmd=data&date=YYYY-MM-DD&offset=N` — neue Logzeilen ab Datei-Offset (Live-Polling) + - `?cmd=devlist[&refresh=1]` — Geräteliste: RF-Adresse via rfd `listDevices` + (Port 2001), Namen via ReGa; Cache `/tmp/asksinanalyzer.devlist.json` (600 s) +- **`www/index.html`** — Telegramm-Tabelle (live), Filter (Suche, Richtung, + Protokoll, Typ, Gerät), Analyse-Tab (RSSI-Verlauf, Telegramm-Rate, + Geräte-Statistik mit DutyCycle-Schätzung), CSV-Export. + +## Installation auf der CCU + +Voraussetzung: multimacd mit TrafficLogger (dieses Repo) und aktiviertem +`Traffic Log = 1` im Konfig-Template. + +### Variante 1: Addon-Paket (WebUI) + +```sh +./build-addon.sh # erzeugt asksinanalyzer-.tar.gz +``` + +Das Paket über die WebUI installieren: *Einstellungen → Systemsteuerung → +Zusatzsoftware → Datei auswählen → Installieren*. Das Addon erscheint danach +in der Zusatzsoftware-Liste (mit Uninstall und Link zur UI). +Paketinhalt: `update_script` (Installer), `rc.d/asksinanalyzer` +(WebUI-Integration: info/uninstall), `asksinanalyzer/` (Payload). + +### Variante 2: manuell per SSH + +```sh +CCU=root@ccu +ssh $CCU 'mkdir -p /usr/local/addons/asksinanalyzer/www' +scp www/index.html www/api.cgi $CCU:/usr/local/addons/asksinanalyzer/www/ +ssh $CCU 'chmod 755 /usr/local/addons/asksinanalyzer /usr/local/addons/asksinanalyzer/www /usr/local/addons/asksinanalyzer/www/api.cgi + ln -sfn /usr/local/addons/asksinanalyzer/www /usr/local/etc/config/addons/www/asksinanalyzer' +``` + +Aufruf: `http:///addons/asksinanalyzer/` + +## Hinweise + +- `/var/log` ist tmpfs: Logs sind nach einem Reboot weg (~5–10 MB/Tag). + Optionale Bereinigung alter Tageslogs per Cron: + `10 0 * * * find /var/log -name "multimacd-traffic-*.log" -mtime +7 -delete` +- HmIP-Payloads sind AES-verschlüsselt und werden nur als Rohdaten angezeigt. diff --git a/addons/asksinanalyzer/VERSION b/addons/asksinanalyzer/VERSION new file mode 100644 index 000000000..3eefcb9dd --- /dev/null +++ b/addons/asksinanalyzer/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/addons/asksinanalyzer/build-addon.sh b/addons/asksinanalyzer/build-addon.sh new file mode 100755 index 000000000..0b291bbc2 --- /dev/null +++ b/addons/asksinanalyzer/build-addon.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Baut das installierbare CCU-Addon-Paket asksinanalyzer-.tar.gz +# (Installation ueber WebUI -> Systemsteuerung -> Zusatzsoftware) +set -e +cd "$(dirname "$0")" + +VERSION=$(cat VERSION) +OUT="asksinanalyzer-${VERSION}.tar.gz" +STAGE=$(mktemp -d) +trap 'rm -rf "${STAGE}"' EXIT + +# Paketlayout: update_script im Archiv-Root, Payload darunter +cp pkg/update_script "${STAGE}/" +mkdir -p "${STAGE}/rc.d" +cp pkg/rc.d/asksinanalyzer "${STAGE}/rc.d/" +mkdir -p "${STAGE}/asksinanalyzer/www" +cp VERSION "${STAGE}/asksinanalyzer/" +cp www/index.html www/api.cgi "${STAGE}/asksinanalyzer/www/" + +chmod 755 "${STAGE}/update_script" "${STAGE}/rc.d/asksinanalyzer" \ + "${STAGE}/asksinanalyzer/www/api.cgi" + +tar -czf "${OUT}" -C "${STAGE}" \ + --owner=root --group=root --numeric-owner \ + update_script rc.d asksinanalyzer + +echo "erstellt: $(pwd)/${OUT}" +tar -tzf "${OUT}" diff --git a/addons/asksinanalyzer/pkg/rc.d/asksinanalyzer b/addons/asksinanalyzer/pkg/rc.d/asksinanalyzer new file mode 100644 index 000000000..9815f5db6 --- /dev/null +++ b/addons/asksinanalyzer/pkg/rc.d/asksinanalyzer @@ -0,0 +1,33 @@ +#!/bin/sh +# AskSinAnalyzer CCU - rc.d-Script (WebUI Zusatzsoftware) + +ADDON_NAME=asksinanalyzer +ADDON_DIR=/usr/local/addons/${ADDON_NAME} +WWW_LINK=/usr/local/etc/config/addons/www/${ADDON_NAME} +RCD_FILE=/usr/local/etc/config/rc.d/${ADDON_NAME} + +case "$1" in + ""|start|stop|restart) + # rein passives Web-Addon (CGI + statische Seite) - kein Dienst noetig + ;; + info) + echo "Info: AskSinAnalyzer CCU
" + echo "Info: Logging und grafische Auswertung aller BidCos-/HmIP-Funktelegramme direkt auf der CCU.
" + echo "Info: Benoetigt multimacd mit TrafficLogger (OpenCCU) und 'Traffic Log = 1' in der multimacd.conf." + echo "Name: AskSinAnalyzer CCU" + echo "Version: $(cat ${ADDON_DIR}/VERSION 2>/dev/null || echo unbekannt)" + echo "Operations: uninstall" + echo "Config-Url: /addons/${ADDON_NAME}/" + ;; + uninstall) + rm -f ${WWW_LINK} + rm -rf ${ADDON_DIR} + rm -f /tmp/asksinanalyzer.devlist.json + rm -f ${RCD_FILE} + ;; + *) + echo "Usage: $0 {start|stop|restart|info|uninstall}" >&2 + exit 1 + ;; +esac +exit 0 diff --git a/addons/asksinanalyzer/pkg/update_script b/addons/asksinanalyzer/pkg/update_script new file mode 100644 index 000000000..1f8e79aa4 --- /dev/null +++ b/addons/asksinanalyzer/pkg/update_script @@ -0,0 +1,32 @@ +#!/bin/sh +# AskSinAnalyzer CCU - Addon-Installation +# Wird vom WebUI-Addon-Installer nach dem Entpacken des tar.gz aufgerufen. +# $1 = Plattform (CCU2, CCU3, HM-RASPBERRYMATIC, ...) - keine binaeren +# Bestandteile, daher plattformunabhaengig. + +ADDON_NAME=asksinanalyzer +ADDONS_DIR=/usr/local/addons +WWW_DIR=/usr/local/etc/config/addons/www +RCD_DIR=/usr/local/etc/config/rc.d + +# /usr/local beschreibbar mounten (aeltere CCU-Firmware) +mount | grep -q "/usr/local" || mount /usr/local + +# Addon-Dateien installieren +mkdir -p ${ADDONS_DIR}/${ADDON_NAME} +cp -af ${ADDON_NAME}/* ${ADDONS_DIR}/${ADDON_NAME}/ +chmod 755 ${ADDONS_DIR}/${ADDON_NAME} ${ADDONS_DIR}/${ADDON_NAME}/www +chmod 755 ${ADDONS_DIR}/${ADDON_NAME}/www/api.cgi +chmod 644 ${ADDONS_DIR}/${ADDON_NAME}/www/index.html + +# Web-Verzeichnis verlinken -> http:///addons/asksinanalyzer/ +mkdir -p ${WWW_DIR} +ln -sfn ${ADDONS_DIR}/${ADDON_NAME}/www ${WWW_DIR}/${ADDON_NAME} + +# rc.d-Script (WebUI-Integration: info/uninstall) +mkdir -p ${RCD_DIR} +cp -af rc.d/${ADDON_NAME} ${RCD_DIR}/ +chmod 755 ${RCD_DIR}/${ADDON_NAME} + +sync +exit 0 diff --git a/addons/asksinanalyzer/www/api.cgi b/addons/asksinanalyzer/www/api.cgi new file mode 100755 index 000000000..20f853488 --- /dev/null +++ b/addons/asksinanalyzer/www/api.cgi @@ -0,0 +1,221 @@ +#!/bin/tclsh +# AskSinAnalyzer CCU - API backend +# Datenquelle: /multimacd-traffic-YYYY-MM-DD.log (TrafficLogger +# in multimacd; Verzeichnis aus /etc/config/multimacd.conf, Fallback /var/log) +# Kommandos: +# ?cmd=dates -> JSON: verfuegbare Log-Daten +# ?cmd=data&date=YYYY-MM-DD&offset=N -> text/plain: "OFFSET \n" + neue Logzeilen ab Offset +# ?cmd=devlist[&refresh=1] -> JSON: Geraeteliste (RF-Adresse, Serial, Name, Typ) + +set LOGDIR "/var/log" +set LOGPREFIX "multimacd-traffic-" +set CONFFILE "/etc/config/multimacd.conf" +set CACHEFILE "/tmp/asksinanalyzer.devlist.json" +set CACHETTL 600 + +# "Traffic Log Directory" aus der multimacd.conf uebernehmen (Fallback: /var/log) +catch { + set fh [open $CONFFILE r] + set conf [read $fh] + close $fh + foreach line [split $conf "\n"] { + if {[regexp {^\s*Traffic Log Directory\s*=\s*(.*\S)\s*$} $line -> dir]} { + set LOGDIR $dir + } + } +} + +# ---------- Helpers ---------- + +proc getParams {} { + global env + set params [dict create] + if {![info exists env(QUERY_STRING)]} { return $params } + foreach pair [split $env(QUERY_STRING) "&"] { + set idx [string first "=" $pair] + if {$idx < 0} { continue } + set k [string range $pair 0 [expr {$idx-1}]] + set v [string range $pair [expr {$idx+1}] end] + # url-decode (einfach: %XX und +) + set v [string map {+ { }} $v] + regsub -all {%([0-9A-Fa-f]{2})} $v {\\u00\1} v + set v [subst -nocommands -novariables $v] + dict set params $k $v + } + return $params +} + +proc jsonEscape {s} { + set out "" + foreach ch [split $s ""] { + scan $ch %c code + if {$ch eq "\""} { append out "\\\"" } \ + elseif {$ch eq "\\"} { append out "\\\\" } \ + elseif {$code < 0x20} { append out [format "\\u%04x" $code] } \ + elseif {$code > 0x7e} { append out [format "\\u%04x" $code] } \ + else { append out $ch } + } + return $out +} + +proc httpHeader {contentType} { + puts "Content-Type: $contentType\r" + puts "Cache-Control: no-cache\r" + puts "\r" +} + +# ---------- Kommandos ---------- + +proc cmdDates {} { + global LOGDIR LOGPREFIX + set dates {} + foreach f [lsort [glob -nocomplain -directory $LOGDIR "${LOGPREFIX}*.log"]] { + set base [file tail $f] + if {[regexp "^${LOGPREFIX}(\\d{4}-\\d{2}-\\d{2})\\.log$" $base -> d]} { + lappend dates "\"$d\"" + } + } + httpHeader "application/json" + puts "\[[join $dates ,]\]" +} + +proc cmdData {params} { + global LOGDIR LOGPREFIX + set date [expr {[dict exists $params date] ? [dict get $params date] : ""}] + if {![regexp {^\d{4}-\d{2}-\d{2}$} $date]} { + # default: heute + set date [clock format [clock seconds] -format "%Y-%m-%d"] + } + set offset 0 + if {[dict exists $params offset]} { + set o [dict get $params offset] + if {[string is integer -strict $o] && $o >= 0} { set offset $o } + } + set path [file join $LOGDIR "${LOGPREFIX}${date}.log"] + # Datei komplett lesen, bevor der Header geschrieben wird - so landet bei + # I/O-Fehlern nie ein zweiter Header (JSON-Fehlerhandler) in der Antwort + set chunk "" + if {[catch { + if {![file exists $path]} { + set offset 0 + } else { + set size [file size $path] + if {$offset > $size} { set offset 0 } + set fh [open $path r] + fconfigure $fh -translation binary + seek $fh $offset + set chunk [read $fh] + close $fh + } + }]} { + # Lesefehler: leere Antwort, Offset unveraendert (Client versucht es erneut) + catch {close $fh} + set chunk "" + } + httpHeader "text/plain; charset=utf-8" + # nur vollstaendige Zeilen ausliefern + set lastNl [string last "\n" $chunk] + if {$lastNl < 0} { + puts "OFFSET $offset" + return + } + set chunk [string range $chunk 0 $lastNl] + puts "OFFSET [expr {$offset + $lastNl + 1}]" + puts -nonewline $chunk +} + +proc buildDevList {} { + load tclrpc.so + load tclrega.so + + # Namen aus der ReGa: Serial -> Name + array set names {} + if {![catch {rega_script { + string sDevId; + foreach(sDevId, root.Devices().EnumIDs()) { + var d = dom.GetObject(sDevId); + if (d.ReadyConfig() == true) { + WriteLine(d.Address() # "\t" # d.Name()); + } + } + }} regaResult]} { + array set res $regaResult + if {[info exists res(STDOUT)]} { + foreach line [split $res(STDOUT) "\n"] { + set parts [split $line "\t"] + if {[llength $parts] >= 2} { + set ser [string trim [lindex $parts 0]] + set nam [string trim [lindex $parts 1]] + if {$ser ne ""} { set names($ser) $nam } + } + } + } + } + + # RF-Adressen aus dem rfd (BidCos-RF, Port 2001) + set entries {} + if {![catch {xmlrpc http://127.0.0.1:2001/ listDevices} devs]} { + foreach d $devs { + # Array vor jedem Geraet leeren - auch nach einem Fehler in der + # vorigen Iteration duerfen keine Felder des alten Geraets uebrigbleiben + array unset a + catch { + array set a $d + if {[string first ":" $a(ADDRESS)] < 0 && [info exists a(RF_ADDRESS)]} { + set serial $a(ADDRESS) + set rf $a(RF_ADDRESS) + set type [expr {[info exists a(TYPE)] ? $a(TYPE) : ""}] + set name [expr {[info exists names($serial)] ? $names($serial) : $serial}] + if {$serial eq "BidCoS-RF"} { set name "Zentrale (CCU)" } + set name [encoding convertfrom iso8859-1 $name] + lappend entries "{\"address\":$rf,\"serial\":\"[jsonEscape $serial]\",\"name\":\"[jsonEscape $name]\",\"type\":\"[jsonEscape $type]\"}" + } + } + } + } + return "{\"created_at\":[clock seconds],\"devices\":\[[join $entries ,]\]}" +} + +proc cmdDevList {params} { + global CACHEFILE CACHETTL + set refresh [expr {[dict exists $params refresh] && [dict get $params refresh] eq "1"}] + set json "" + if {!$refresh && [file exists $CACHEFILE] && + ([clock seconds] - [file mtime $CACHEFILE]) < $CACHETTL} { + set fh [open $CACHEFILE r] + fconfigure $fh -encoding utf-8 + set json [read $fh] + close $fh + } + if {$json eq ""} { + set json [buildDevList] + catch { + set fh [open $CACHEFILE w] + fconfigure $fh -encoding utf-8 + puts -nonewline $fh $json + close $fh + } + } + httpHeader "application/json; charset=utf-8" + puts -nonewline $json +} + +# ---------- Main ---------- + +set params [getParams] +set cmd [expr {[dict exists $params cmd] ? [dict get $params cmd] : ""}] + +if {[catch { + switch -- $cmd { + dates { cmdDates } + data { cmdData $params } + devlist { cmdDevList $params } + default { + httpHeader "application/json" + puts "{\"error\":\"unknown cmd\"}" + } + } +} err]} { + httpHeader "application/json" + puts "{\"error\":\"[jsonEscape $err]\"}" +} diff --git a/addons/asksinanalyzer/www/index.html b/addons/asksinanalyzer/www/index.html new file mode 100644 index 000000000..4c9bd9d9a --- /dev/null +++ b/addons/asksinanalyzer/www/index.html @@ -0,0 +1,749 @@ + + + + + +AskSinAnalyzer CCU + + + +
+

AskSinAnalyzer CCU

+
+ + +
+ verbinde… +
+ + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + +
ZeitProtoRSSILenCntTypFlagsVonAnPayload
+
+
+ +
+
+
+
+

RSSI-Verlauf (empfangene BidCos-Telegramme)

+ +
+
+
+

Telegramm-Rate (pro Minute)

+ +
+
+

Telegramme pro Gerät

+
+
+
+
+ +
+ + + + diff --git a/bin/x86_64-linux-gnu/multimacd b/bin/x86_64-linux-gnu/multimacd index 2fa40ccc1..8cbce2a38 100755 Binary files a/bin/x86_64-linux-gnu/multimacd and b/bin/x86_64-linux-gnu/multimacd differ diff --git a/lib/x86_64-linux-gnu/libelvutils.so b/lib/x86_64-linux-gnu/libelvutils.so old mode 100644 new mode 100755 index 30eb607bd..f2e6f8add Binary files a/lib/x86_64-linux-gnu/libelvutils.so and b/lib/x86_64-linux-gnu/libelvutils.so differ diff --git a/src/hs485d/CMakeLists.txt b/src/hs485d/CMakeLists.txt index e2aa4f4ff..53667e154 100644 --- a/src/hs485d/CMakeLists.txt +++ b/src/hs485d/CMakeLists.txt @@ -1,7 +1,7 @@ add_custom_command( OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/generated-hs485d-version.h" COMMAND "${CMAKE_COMMAND}" -E env sh "${CMAKE_CURRENT_SOURCE_DIR}/version.sh" > /tmp/openccu-hs485d-version.txt - COMMAND "${CMAKE_COMMAND}" -DINPUT=/tmp/openccu-hs485d-version.txt -DOUTPUT="${CMAKE_CURRENT_BINARY_DIR}/generated-hs485d-version.h" -DMACRO_NAME=HS485D_VERSION -P "${CMAKE_SOURCE_DIR}/cmake/WriteVersionHeader.cmake" + COMMAND "${CMAKE_COMMAND}" -DINPUT=/tmp/openccu-hs485d-version.txt "-DOUTPUT=${CMAKE_CURRENT_BINARY_DIR}/generated-hs485d-version.h" -DMACRO_NAME=HS485D_VERSION -P "${CMAKE_SOURCE_DIR}/cmake/WriteVersionHeader.cmake" VERBATIM ) add_custom_target(hs485d-version-header DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/generated-hs485d-version.h") diff --git a/src/multimacd/CMakeLists.txt b/src/multimacd/CMakeLists.txt index 88c4b8b58..5b3484f85 100644 --- a/src/multimacd/CMakeLists.txt +++ b/src/multimacd/CMakeLists.txt @@ -5,7 +5,7 @@ file(GLOB_RECURSE MULTIMACD_SOURCES CONFIGURE_DEPENDS add_custom_command( OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/generated-multimacd-version.h" COMMAND "${CMAKE_COMMAND}" -E env sh "${CMAKE_CURRENT_SOURCE_DIR}/version.sh" > /tmp/openccu-multimacd-version.txt - COMMAND "${CMAKE_COMMAND}" -DINPUT=/tmp/openccu-multimacd-version.txt -DOUTPUT="${CMAKE_CURRENT_BINARY_DIR}/generated-multimacd-version.h" -DMACRO_NAME=MULTIMACD_VERSION -P "${CMAKE_SOURCE_DIR}/cmake/WriteVersionHeader.cmake" + COMMAND "${CMAKE_COMMAND}" -DINPUT=/tmp/openccu-multimacd-version.txt "-DOUTPUT=${CMAKE_CURRENT_BINARY_DIR}/generated-multimacd-version.h" -DMACRO_NAME=MULTIMACD_VERSION -P "${CMAKE_SOURCE_DIR}/cmake/WriteVersionHeader.cmake" VERBATIM ) add_custom_target(multimacd-version-header DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/generated-multimacd-version.h") diff --git a/src/multimacd/MultimacManager.cpp b/src/multimacd/MultimacManager.cpp index 913d73873..98d3ea6f9 100644 --- a/src/multimacd/MultimacManager.cpp +++ b/src/multimacd/MultimacManager.cpp @@ -1,12 +1,13 @@ -/* -* Copyright 2026 eQ-3 AG - All Rights Reserved. -* -* Licensed under the HMSL 2 (the "License"). You may not use -* this file except in compliance with the License. You can obtain a copy -* in the file HMSL.txt in the source distribution. -*/ - +/* +* Copyright 2026 eQ-3 AG - All Rights Reserved. +* +* Licensed under the HMSL 2 (the "License"). You may not use +* this file except in compliance with the License. You can obtain a copy +* in the file HMSL.txt in the source distribution. +*/ + #include "MultimacManager.h" +#include "TrafficLogger.h" #ifndef WIN32 #include @@ -57,6 +58,8 @@ bool MultimacManager::Run( const PropertyMap& config ) _configuration = config; _run = true; + TrafficLogger::Instance().Configure( config.GetIntValue("Traffic Log") != 0, config.GetStringValue("Traffic Log Directory") ); + _subsystemManager.SetDownstreamFrameSink( _macController ); _macController.SetUpstreamFrameSink( _subsystemManager ); _fastMacResponder.SetUpstreamFrameSink( _macController ); diff --git a/src/multimacd/Subsystem.cpp b/src/multimacd/Subsystem.cpp index c3d46527d..c08be6679 100644 --- a/src/multimacd/Subsystem.cpp +++ b/src/multimacd/Subsystem.cpp @@ -1,11 +1,11 @@ -/* -* Copyright 2026 eQ-3 AG - All Rights Reserved. -* -* Licensed under the HMSL 2 (the "License"). You may not use -* this file except in compliance with the License. You can obtain a copy -* in the file HMSL.txt in the source distribution. -*/ - +/* +* Copyright 2026 eQ-3 AG - All Rights Reserved. +* +* Licensed under the HMSL 2 (the "License"). You may not use +* this file except in compliance with the License. You can obtain a copy +* in the file HMSL.txt in the source distribution. +*/ + #include "Subsystem.h" #include "SerialFrame/SerialFrame.h" #include "tinythread.h" @@ -14,6 +14,7 @@ #include "IDownstreamSerialFrameSink.h" #include "MultimacManager.h" #include "UpstreamCharConnection.h" +#include "TrafficLogger.h" #ifndef WIN32 #include #endif @@ -223,6 +224,7 @@ void Subsystem::SendFrameUpstream( SerialFrame* frame ) void Subsystem::SendFrameDownstream( SerialFrame* frame ) { + TrafficLogger::Instance().OnDownstreamFrame( *frame ); LockGuard lock(_mutex); frame->SetResponsibleSubsystem( this ); _mapFrameIdToSequenceCounter[frame->GetId()] = frame->GetSequenceCounter(); diff --git a/src/multimacd/SubsystemManager.cpp b/src/multimacd/SubsystemManager.cpp index 5b411e204..a789044ed 100644 --- a/src/multimacd/SubsystemManager.cpp +++ b/src/multimacd/SubsystemManager.cpp @@ -1,16 +1,17 @@ -/* -* Copyright 2026 eQ-3 AG - All Rights Reserved. -* -* Licensed under the HMSL 2 (the "License"). You may not use -* this file except in compliance with the License. You can obtain a copy -* in the file HMSL.txt in the source distribution. -*/ - +/* +* Copyright 2026 eQ-3 AG - All Rights Reserved. +* +* Licensed under the HMSL 2 (the "License"). You may not use +* this file except in compliance with the License. You can obtain a copy +* in the file HMSL.txt in the source distribution. +*/ + #include "SubsystemManager.h" #include "Subsystem.h" #include "SubsystemBidcos.h" #include "SubsystemHmIp.h" #include "SerialFrame/SerialFrame.h" +#include "TrafficLogger.h" #include #include "Sysutils.h" #ifndef WIN32 @@ -87,6 +88,7 @@ bool SubsystemManager::Stop() void SubsystemManager::OnUpstreamFrame( SerialFrame* frame ) { + TrafficLogger::Instance().OnUpstreamFrame( *frame ); _incomingQueue.Add( frame ); } diff --git a/src/multimacd/TrafficLogger.cpp b/src/multimacd/TrafficLogger.cpp new file mode 100644 index 000000000..3921218fa --- /dev/null +++ b/src/multimacd/TrafficLogger.cpp @@ -0,0 +1,240 @@ +/* +* Copyright 2026 eQ-3 AG - All Rights Reserved. +* +* Licensed under the HMSL 2 (the "License"). You may not use +* this file except in compliance with the License. You can obtain a copy +* in the file HMSL.txt in the source distribution. +*/ + +#include "TrafficLogger.h" +#include "SerialFrame/SerialFrame.h" +#include "SerialFrame/LowLevelMacFrame.h" +#include "SerialFrame/HmIpStackFrame.h" +#include +#ifndef WIN32 +#include +#endif + +/*static*/ TrafficLogger* TrafficLogger::_instance = NULL; + +/*static*/ TrafficLogger& TrafficLogger::Instance() +{ + if( !_instance ) + { + _instance = new TrafficLogger(); + } + return *_instance; +} + +TrafficLogger::TrafficLogger() +{ + _enabled = false; + _directory = "/var/log"; + _file = NULL; + _fileYear = 0; + _fileMonth = 0; + _fileDay = 0; +} + +TrafficLogger::~TrafficLogger() +{ + if( _file ) + { + fclose( _file ); + } +} + +void TrafficLogger::Configure( bool enabled, const std::string& directory ) +{ + LockGuard lock( _mutex ); + _enabled = enabled; + if( !directory.empty() ) + { + _directory = directory; + } + if( _file ) + { + fclose( _file ); + _file = NULL; + _fileYear = _fileMonth = _fileDay = 0; + } +} + +bool TrafficLogger::IsEnabled()const +{ + return _enabled; +} + +void TrafficLogger::OnUpstreamFrame( const SerialFrame& frame ) +{ + if( !_enabled ) + { + return; + } + switch( frame.GetSubsystem() ) + { + case SerialFrame::FrameSubsystemType_LowLevelMac: + if( frame.GetCommand() == LowLevelMacFrame::FrameType_RxTelegram ) + { + //telegram data starts after frame end time (2 bytes), options (1 byte) and rssi (1 byte) + if( frame.Data().size() > 4 ) + { + LogBidcosTelegram( "RX", frame.Data().GetRange( 4, frame.Data().size() - 4 ) ); + } + } + break; + case SerialFrame::FrameSubsystemType_HmIpStack: + if( frame.GetCommand() == HmIpStackFrame::FrameType_RxFrameEvent ) + { + LogHmIpFrame( "RX", frame.Data() ); + } + break; + default: + break; + } +} + +void TrafficLogger::OnDownstreamFrame( const SerialFrame& frame ) +{ + if( !_enabled ) + { + return; + } + switch( frame.GetSubsystem() ) + { + case SerialFrame::FrameSubsystemType_LowLevelMac: + switch( frame.GetCommand() ) + { + case LowLevelMacFrame::FrameType_Tx: + case LowLevelMacFrame::FrameType_TxWitRxModeSet: + //telegram data starts after frame start time (2 bytes) and options (1 byte) + if( frame.Data().size() > 3 ) + { + LogBidcosTelegram( "TX", frame.Data().GetRange( 3, frame.Data().size() - 3 ) ); + } + break; + default: + break; + } + break; + case SerialFrame::FrameSubsystemType_HmIpStack: + switch( frame.GetCommand() ) + { + case HmIpStackFrame::FrameType_SendProtocolFrame: + case HmIpStackFrame::FrameType_OtauFrameSend: + LogHmIpFrame( "TX", frame.Data() ); + break; + default: + break; + } + break; + default: + break; + } +} + +void TrafficLogger::LogBidcosTelegram( const char* direction, const BinaryData& telegramData ) +{ + //telegram layout: [0]=counter, [1]=flags, [2]=type, [3..5]=sender, [6..8]=receiver, [9..]=payload + //the length equals the on-air length byte, which counts all bytes following it + char buffer[80]; + snprintf( buffer, sizeof(buffer), "LEN=%02X CNT=%02X FLAGS=%02X TYPE=%02X FROM=%06X TO=%06X PAYLOAD=", + (unsigned)telegramData.size(), + telegramData.GetUInt8Value( 0 ), + telegramData.GetUInt8Value( 1 ), + telegramData.GetUInt8Value( 2 ), + telegramData.GetUInt24Value( 3 ), + telegramData.GetUInt24Value( 6 ) ); + std::string fields = buffer; + if( telegramData.size() > 9 ) + { + fields += ToHex( telegramData.GetRange( 9, telegramData.size() - 9 ) ); + } + WriteLine( direction, "BIDCOS", fields ); +} + +void TrafficLogger::LogHmIpFrame( const char* direction, const BinaryData& frameData ) +{ + //the HmIP stack frame content is passed through this daemon transparently, + //so it is logged unparsed + char buffer[16]; + snprintf( buffer, sizeof(buffer), "LEN=%02X DATA=", (unsigned)frameData.size() ); + WriteLine( direction, "HMIP", std::string( buffer ) + ToHex( frameData ) ); +} + +void TrafficLogger::WriteLine( const char* direction, const char* protocol, const std::string& fields ) +{ + struct tm localTime; + int milliseconds; +#ifndef WIN32 + struct timeval tv; + gettimeofday( &tv, NULL ); + localtime_r( &tv.tv_sec, &localTime ); + milliseconds = tv.tv_usec / 1000; +#else + time_t now = time( NULL ); + localTime = *localtime( &now ); + milliseconds = 0; +#endif + + LockGuard lock( _mutex ); + if( !_enabled ) + { + return; + } + FILE* file = GetLogFile( localTime ); + if( !file ) + { + return; + } + fprintf( file, "%04d-%02d-%02d %02d:%02d:%02d.%03d %s %s %s\n", + localTime.tm_year + 1900, localTime.tm_mon + 1, localTime.tm_mday, + localTime.tm_hour, localTime.tm_min, localTime.tm_sec, milliseconds, + direction, protocol, fields.c_str() ); + fflush( file ); +} + +FILE* TrafficLogger::GetLogFile( const struct tm& localTime ) +{ + if( _file && (localTime.tm_year == _fileYear) && (localTime.tm_mon == _fileMonth) && (localTime.tm_mday == _fileDay) ) + { + return _file; + } + if( _file ) + { + fclose( _file ); + _file = NULL; + } + _fileYear = localTime.tm_year; + _fileMonth = localTime.tm_mon; + _fileDay = localTime.tm_mday; + + char filename[64]; + snprintf( filename, sizeof(filename), "multimacd-traffic-%04d-%02d-%02d.log", + localTime.tm_year + 1900, localTime.tm_mon + 1, localTime.tm_mday ); + std::string path = _directory; + if( (!path.empty()) && (path[path.length()-1] != '/') ) + { + path += '/'; + } + path += filename; + _file = fopen( path.c_str(), "a" ); + if( !_file ) + { + LOG( Logger::LOG_WARNING, "TrafficLogger: unable to open %s", path.c_str() ); + } + return _file; +} + +/*static*/ std::string TrafficLogger::ToHex( const BinaryData& data ) +{ + static const char* HEX_DIGITS = "0123456789ABCDEF"; + std::string s; + s.reserve( data.size() * 2 ); + for( size_t i = 0; i < data.size(); i++ ) + { + s += HEX_DIGITS[data.at( i ) >> 4]; + s += HEX_DIGITS[data.at( i ) & 0x0f]; + } + return s; +} diff --git a/src/multimacd/TrafficLogger.h b/src/multimacd/TrafficLogger.h new file mode 100644 index 000000000..96619143a --- /dev/null +++ b/src/multimacd/TrafficLogger.h @@ -0,0 +1,54 @@ +/* +* Copyright 2026 eQ-3 AG - All Rights Reserved. +* +* Licensed under the HMSL 2 (the "License"). You may not use +* this file except in compliance with the License. You can obtain a copy +* in the file HMSL.txt in the source distribution. +*/ + +#pragma once +#include "BinaryData.h" +#include "tinythread.h" +#include +#include +#include +#include + +class SerialFrame; + +/// +/// Logs all radio rx/tx traffic passing between the subsystems and the rf module +/// into one log file per day (e.g. /var/log/multimacd-traffic-2026-07-06.log). +/// Bidcos telegrams are logged with their decoded fields (length, counter, flags, +/// type, sender, receiver, payload), HmIP frames are logged as raw frame data. +/// +class TrafficLogger +{ +public: + static TrafficLogger& Instance(); + void Configure( bool enabled, const std::string& directory ); + bool IsEnabled()const; + /// frame received from the rf module (rx path) + void OnUpstreamFrame( const SerialFrame& frame ); + /// frame sent towards the rf module (tx path) + void OnDownstreamFrame( const SerialFrame& frame ); +private: + TrafficLogger(); + ~TrafficLogger(); + void LogBidcosTelegram( const char* direction, const BinaryData& telegramData, int rssi ); + void LogHmIpFrame( const char* direction, const BinaryData& frameData ); + void WriteLine( const char* direction, const char* protocol, const std::string& fields ); + FILE* GetLogFile( const struct tm& localTime ); + static std::string ToHex( const BinaryData& data ); + + //atomic: read lock-free on the per-frame fast path, written by Configure() + std::atomic _enabled; + std::string _directory; + FILE* _file; + int _fileYear; + int _fileMonth; + int _fileDay; + tthread::mutex _mutex; + typedef tthread::lock_guard LockGuard; + static TrafficLogger* _instance; +}; diff --git a/src/multimacd/multimacd.conf b/src/multimacd/multimacd.conf index 109e9186c..a4ceaad7c 100644 --- a/src/multimacd/multimacd.conf +++ b/src/multimacd/multimacd.conf @@ -1,11 +1,11 @@ -/* -* Copyright 2026 eQ-3 AG - All Rights Reserved. -* -* Licensed under the HMSL 2 (the "License"). You may not use -* this file except in compliance with the License. You can obtain a copy -* in the file HMSL.txt in the source distribution. -*/ - +/* +* Copyright 2026 eQ-3 AG - All Rights Reserved. +* +* Licensed under the HMSL 2 (the "License"). You may not use +* this file except in compliance with the License. You can obtain a copy +* in the file HMSL.txt in the source distribution. +*/ + Coprocessor Device Path = /dev/mxs_auart_raw.0 Log Level = 3 Log Identifier = multimac @@ -18,3 +18,5 @@ Default Subsystem = HmIP Loop Master Device = /dev/eq3loop Loop Slave Device Bidcos = mmd_bidcos Loop Slave Device HmIP = ttyS0 +Traffic Log = 0 +Traffic Log Directory = /var/log diff --git a/src/multimacd/multimacd.cpp b/src/multimacd/multimacd.cpp index 7897fbdcc..cbfdafd2f 100644 --- a/src/multimacd/multimacd.cpp +++ b/src/multimacd/multimacd.cpp @@ -1,11 +1,11 @@ -/* -* Copyright 2026 eQ-3 AG - All Rights Reserved. -* -* Licensed under the HMSL 2 (the "License"). You may not use -* this file except in compliance with the License. You can obtain a copy -* in the file HMSL.txt in the source distribution. -*/ - +/* +* Copyright 2026 eQ-3 AG - All Rights Reserved. +* +* Licensed under the HMSL 2 (the "License"). You may not use +* this file except in compliance with the License. You can obtain a copy +* in the file HMSL.txt in the source distribution. +*/ + #include #include #include @@ -34,6 +34,8 @@ static const char* CONFIG_DEFAULTS[] = { // "Transparent Cmdline Pattern", "*update*", "Bidcos Exe Pattern", "*/bin/rfd", "Default Subsystem", "HmIP", + "Traffic Log", "0", + "Traffic Log Directory", "/var/log", // "Loop Master Device", "/dev/eq3loop", // "Loop Slave Device Bidcos", "mmd_bidcos", // "Loop Slave Device HmIP", "mmd_hmip", @@ -88,12 +90,13 @@ static void usage(const char* procname) { std::cout << "multimacd " << MULTIMACD_VERSION << " (" << __DATE__ << ")" << std::endl << std::endl - << "Usage: " << procname << " [-f config file] [-c] [-l loglevel] [-p devnode] [-d]" << std::endl + << "Usage: " << procname << " [-f config file] [-c] [-l loglevel] [-p devnode] [-d] [-t]" << std::endl << " -f: use alternate config file" << std::endl << " -c: log to console instead of syslog" << std::endl << " -l: set log level" << std::endl << " -p: specify coprocessor device node" << std::endl - << " -d: run as daemon/service" << std::endl; + << " -d: run as daemon/service" << std::endl + << " -t: log all radio rx/tx traffic to daily files in /var/log" << std::endl; if(logger) delete logger; @@ -161,6 +164,7 @@ int main(int argc, char **argv) bool cmdline_log_console=false; std::string config_filename = CONFIG_FILENAME; bool cmdline_daemonize=false; + bool cmdline_traffic_log=false; const char* device = NULL; installSignalHandlers(); @@ -180,6 +184,8 @@ int main(int argc, char **argv) cmdline_log_console=true; }else if(strcmp(argv[i], "-d")==0){ cmdline_daemonize=true; + }else if(strcmp(argv[i], "-t")==0){ + cmdline_traffic_log=true; }else{ usage(argv[0]); } @@ -202,6 +208,10 @@ int main(int argc, char **argv) { config_data.SetIntValue("Log Level", cmdline_loglevel); } + if( cmdline_traffic_log ) + { + config_data.SetIntValue("Traffic Log", 1); + } setup_logging( config_data ); diff --git a/src/rfd/CMakeLists.txt b/src/rfd/CMakeLists.txt index 2e1d560c8..91230444a 100644 --- a/src/rfd/CMakeLists.txt +++ b/src/rfd/CMakeLists.txt @@ -1,7 +1,7 @@ add_custom_command( OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/generated-rfd-version.h" COMMAND "${CMAKE_COMMAND}" -E env sh "${CMAKE_CURRENT_SOURCE_DIR}/version.sh" > /tmp/openccu-rfd-version.txt - COMMAND "${CMAKE_COMMAND}" -DINPUT=/tmp/openccu-rfd-version.txt -DOUTPUT="${CMAKE_CURRENT_BINARY_DIR}/generated-rfd-version.h" -DMACRO_NAME=RFD_VERSION -P "${CMAKE_SOURCE_DIR}/cmake/WriteVersionHeader.cmake" + COMMAND "${CMAKE_COMMAND}" -DINPUT=/tmp/openccu-rfd-version.txt "-DOUTPUT=${CMAKE_CURRENT_BINARY_DIR}/generated-rfd-version.h" -DMACRO_NAME=RFD_VERSION -P "${CMAKE_SOURCE_DIR}/cmake/WriteVersionHeader.cmake" VERBATIM ) add_custom_target(rfd-version-header DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/generated-rfd-version.h")