Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions addons/asksinanalyzer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
asksinanalyzer-*.tar.gz
67 changes: 67 additions & 0 deletions addons/asksinanalyzer/README.md
Original file line number Diff line number Diff line change
@@ -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-<version>.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://<ccu>/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.
1 change: 1 addition & 0 deletions addons/asksinanalyzer/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.0.0
28 changes: 28 additions & 0 deletions addons/asksinanalyzer/build-addon.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/sh
# Baut das installierbare CCU-Addon-Paket asksinanalyzer-<version>.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}"
33 changes: 33 additions & 0 deletions addons/asksinanalyzer/pkg/rc.d/asksinanalyzer
Original file line number Diff line number Diff line change
@@ -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: <b>AskSinAnalyzer CCU</b><br>"
echo "Info: Logging und grafische Auswertung aller BidCos-/HmIP-Funktelegramme direkt auf der CCU.<br>"
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
32 changes: 32 additions & 0 deletions addons/asksinanalyzer/pkg/update_script
Original file line number Diff line number Diff line change
@@ -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://<ccu>/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
221 changes: 221 additions & 0 deletions addons/asksinanalyzer/www/api.cgi
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/bin/tclsh
# AskSinAnalyzer CCU - API backend
# Datenquelle: <Traffic Log Directory>/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>\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]\"}"
}
Loading