Dear @wuermchen ,
as I read that you’ve already tried to solve this - may a more concrete description is useful for you:
Repo: https://github.com/xIExodusIx/MMM-FRITZ-Box-Callmonitor-py3
Patched file: node_helper.js (only this one file is changed)
Frontend file: unchanged
Problem
Numbers blocked via the FRITZ!Box “number range” blocklist (Rufnummern-Bereichsliste, not phonebook-blocked) cause the FRITZ!Box to emit RING and DISCONNECT events within a few milliseconds of each other.
The original module fires:
SHOW_ALERT immediately on inbound
HIDE_ALERT immediately on disconnected
Both DOM updates happen within the same render frame → browser crashes.
Phonebook-blocked numbers are handled separately (the library emits a dedicated blocked event) and are not affected by this issue.
Fix concept
Don’t fire SHOW_ALERT immediately. Buffer the inbound notification for SPAM_SUPPRESS_WINDOW_MS (default: 1500 ms). If a disconnected event for the same call.id arrives within that window → drop the call entirely. No SHOW_ALERT, no HIDE_ALERT, no DOM churn.
Real calls (ring duration > 1.5 s, or accepted within 1.5 s) work exactly as before — the only difference is the pop-up appears with a ~1.5 s delay, which is invisible in practice.
Behavior matrix
Scenario
Behavior
Real call, rings > 1.5 s
Pop-up appears with ~1.5 s delay (imperceptible)
Real call, accepted within 1.5 s
connected handler flushes the timer, fires CALL_CONNECTED
Spam call (RING + DISCONNECT < 1.5 s)
Timer is cancelled, no notification at all → no crash
Phonebook-blocked number
Unchanged — library emits dedicated blocked event
Outbound call
Unchanged — separate event path, Map is never touched
Changes (all in node_helper.js)
#
Location
Action
1
After line 11 (after require imports)
Add constant SPAM_SUPPRESS_WINDOW_MS = 1500
2
start: (line 23–29)
Add one line: this.pendingInbound = new Map();
3
monitor.on("inbound", ...) (line 82–87)
Replace body — instead of firing sendSocketNotification immediately, store a timer in the Map
4
monitor.on("connected", ...) (line 105–110)
Add timer-cleanup at the start, rest unchanged
5
monitor.on("disconnected", ...) (line 113–117)
Add timer-check at the start — if pending → clearTimeout + return
All changes are marked with // CHANGES for wuermchen comments in the file.
Tuning
SPAM_SUPPRESS_WINDOW_MS = 1500 is a safe default.
Very fast hardware/network: 800–1000 ms is enough
Slow setups: bump to 2000 ms
For a runtime config option, expose it as this.config.suppressWindow and read in setupMonitor
Caveats / things to verify in your setup
call.id must exist on the library’s event objects.
The library node-fritzbox-callmonitor derives it from the FRITZ!Box stream’s ConnectionId. Should work, but if your installed library version uses a different property name (e.g. call.connectionId), adjust accordingly. Easy to verify with console.log(call) in the inbound handler.
Assumption: blocklist-range calls emit inbound + disconnected.
This matches the forum description (RING + DISCONNECT). If your specific FRITZ!Box firmware emits something different (e.g. directly blocked), the patch won’t help — but won’t break anything either, because the existing blocked path is untouched.
Side effect: no RELOAD_CALLS for suppressed spam calls.
The original disconnected handler triggers a refresh of the call list from the FRITZ!Box API. Suppressed spam calls won’t trigger this refresh — the on-screen list updates only at the next real event. Acceptable in practice (spam calls are in the FRITZ!Box history anyway).
Installation
cd ~/MagicMirror/modules/MMM-FRITZ-Box-Callmonitor-py3
cp node_helper.js node_helper.js.orig # backup
# replace node_helper.js with the patched version
touch ~/MagicMirror/config/config.js # graceful restart via pm2 file-watch
Rollback:
cd ~/MagicMirror/modules/MMM-FRITZ-Box-Callmonitor-py3
cp node_helper.js.orig node_helper.js
touch ~/MagicMirror/config/config.js
Complexity assessment
Structurally simple — one file, one function (setupMonitor), ~15 new lines. No race conditions (Node.js is single-threaded). No memory leak (timer + Map entry are explicitly cleaned up in every code path).
The uncertainty is not in the code, but in the two library assumptions above (point 1 and 2 under Caveats). The fix has not been verified against a live spam call yet — it is derived from the forum description and the library’s documented event model.
complete patched node_helper.js:
(You will find all changes marked with “// CHANGES for wuermchen — Spam-Call Suppression”
"use strict";
const NodeHelper = require("node_helper");
const CallMonitor = require("node-fritzbox-callmonitor");
const vcard = require("vcard-json");
const phoneFormatter = require("phone-formatter");
const xml2js = require("xml2js");
const moment = require('moment');
const exec = require('child_process').exec;
const {PythonShell} = require('python-shell');
const path = require("path");
// ============================================================================
// CHANGES for wuermchen — Spam-Call Suppression
// ----------------------------------------------------------------------------
// Background: FRITZ!Box "number range" blocklist entries trigger RING and
// DISCONNECT almost simultaneously. The original code fires SHOW_ALERT on
// "inbound" and HIDE_ALERT on "disconnected" immediately, causing two
// overlapping DOM updates within a few ms → browser crashes.
//
// Fix: buffer "inbound" notifications for SPAM_SUPPRESS_WINDOW_MS. If a
// "disconnected" with the same call.id arrives before the timer fires, drop
// the call entirely (no SHOW_ALERT, no HIDE_ALERT). Real calls (ring > 1.5 s
// or accepted) work as before, just with ~1.5 s delay on the pop-up.
// ============================================================================
const SPAM_SUPPRESS_WINDOW_MS = 1500;
// ============================================================================
const CALL_TYPE = Object.freeze({
INCOMING: "1",
MISSED: "2",
OUTGOING: "3",
BLOCKED: "10" //New entry for blocked calls as found on: https://fritzconnection.readthedocs.io/en/1.14.0/sources/library_modules.html
})
//outgoing missed calls are not in the list
module.exports = NodeHelper.create({
// Subclass start method.
start: function () {
this.ownNumbers = []
this.started = false;
//create addressbook dictionary
this.AddressBook = {};
// CHANGES for wuermchen — pending inbound calls awaiting suppression decision
this.pendingInbound = new Map(); // call.id -> timer
console.log("Starting module: " + this.name);
},
normalizePhoneNumber(number) {
return phoneFormatter.normalize(number.replace(/\s/g, ""));
},
getName: function (number) {
//Normalize number
var number_formatted = this.normalizePhoneNumber(number);
//Check if number is in AdressBook if yes return the name
if (number_formatted in this.AddressBook) {
return this.AddressBook[number_formatted];
} else {
//Not in AdressBook return original number
return number;
}
},
socketNotificationReceived: function (notification, payload) {
//Received config from client
if (notification === "CONFIG") {
//set config to config send by client
this.config = payload;
//if monitor has not been started before (makes sure it does not get started again if the web interface is reloaded)
if (!this.started) {
//set started to true, so it won't start again
this.started = true;
console.log("Received config for " + this.name);
this.parseVcardFile();
this.setupMonitor();
}
//send fresh data to front end (page might have been refreshed)
if (this.config.password !== "") {
this.loadDataFromAPI();
}
}
if (notification === "RELOAD_CALLS") {
this.loadDataFromAPI("--calls-only");
}
if (notification === "RELOAD_CONTACTS") {
this.loadDataFromAPI("--contacts-only");
}
},
setupMonitor: function () {
//helper variable so that the module-this is available inside our callbacks
var self = this;
//Set up CallMonitor with config received from client
var monitor = new CallMonitor(this.config.fritzIP, this.config.fritzPort);
// ====================================================================
// CHANGES for wuermchen — buffer inbound instead of firing immediately
// ====================================================================
monitor.on("inbound", function (call) {
//If caller is not empty
if (call.caller == "") return;
var payload = self.getName(call.caller);
var timer = setTimeout(function () {
self.pendingInbound.delete(call.id);
self.sendSocketNotification("call", payload);
}, SPAM_SUPPRESS_WINDOW_MS);
self.pendingInbound.set(call.id, timer);
});
// ====================================================================
monitor.on("outbound", function (call) {
//Save own number (call.caller) to ownNumbers Array to distinguish inbound/outbound on "connected" handler
if (!self.ownNumbers.includes(call.caller))
self.ownNumbers.push(call.caller)
self.sendSocketNotification("outbound", call.called);
});
//Call blocked
monitor.on("blocked", function (call) {
var name = call.type === "blocked" ? self.getName(call.called) : self.getName(call.caller);
//send clear command to interface
self.sendSocketNotification("blocked", self.getName(call.caller)); //{ "caller": name, "duration": call.duration });
});
//Call accepted
monitor.on("connected", function (call) {
// CHANGES for wuermchen — flush pending inbound timer before firing connected
var pending = self.pendingInbound.get(call.id);
if (pending) {
clearTimeout(pending);
self.pendingInbound.delete(call.id);
}
var name = self.ownNumbers.includes(call.caller) ? self.getName(call.called) : self.getName(call.caller);
var direction = self.ownNumbers.includes(call.caller) ? "out" : "in";
self.sendSocketNotification("connected", { "caller": name, "direction": direction });
});
//Caller disconnected
monitor.on("disconnected", function (call) {
// CHANGES for wuermchen — if inbound was still buffered, this is a spam call: suppress both
var pending = self.pendingInbound.get(call.id);
if (pending) {
clearTimeout(pending);
self.pendingInbound.delete(call.id);
return; // never showed the pop-up, no need to clear it
}
var name = call.type === 'outbound' ? self.getName(call.called) : self.getName(call.caller);
//send clear command to interface
self.sendSocketNotification("disconnected", { "caller": name, "duration": call.duration });
});
console.log(this.name + " is waiting for incoming calls.");
},
parseVcardFile: function () {
var self = this;
if (!this.config.vCard) {
return;
}
vcard.parseVcardFile(self.config.vCard, function (err, data) {
//In case there is an error reading the vcard file
if (err) {
self.sendSocketNotification("error", "vcf_parse_error");
if (self.config.debug) {
console.log("[" + self.name + "] error while parsing vCard " + err);
}
return
}
//For each contact in vcf file
for (var i = 0; i < data.length; i++) {
//For each phone number in contact
for (var a = 0; a < data[i].phone.length; a++) {
//normalize and add to AddressBook
self.AddressBook[self.normalizePhoneNumber(data[i].phone[a].value)] = data[i].fullname;
}
}
self.sendSocketNotification("contacts_loaded", Object.keys(self.AddressBook).length);
});
},
loadCallList: function (body) {
var self = this;
xml2js.parseString(body, function (err, result) {
if (err) {
self.sendSocketNotification("error", "calllist_parse_error");
console.error(self.name + " error while parsing call list: " + err);
return;
}
var callArray = result.root.Call;
var callHistory = []
for (var index in callArray) {
var call = callArray[index];
var type = call.Type[0];
//Trying to handle blocked calls these lines 164-171 are new from "if to else" and it works!
if (name = type == CALL_TYPE.BLOCKED || type == CALL_TYPE.INCOMING ? self.getName(call.Caller[0]) : self.getName(call.Called[0]));
var duration = call.Duration[0];
if (type == CALL_TYPE.INCOMING && self.config.deviceFilter && self.config.deviceFilter.indexOf(call.Device[0]) > -1) {
continue;
}
else
//From here the original script is ongoing
var name = type == CALL_TYPE.MISSED || type == CALL_TYPE.INCOMING ? self.getName(call.Caller[0]) : self.getName(call.Called[0]);
var duration = call.Duration[0];
if (type == CALL_TYPE.INCOMING && self.config.deviceFilter && self.config.deviceFilter.indexOf(call.Device[0]) > -1) {
continue;
}
var callInfo = { "time": moment(call.Date[0], "DD.MM.YY HH:mm"), "caller": name, "type": type, "duration": duration };
if (call.Name[0]) {
callInfo.caller = call.Name[0];
}
callHistory.push(callInfo)
}
self.sendSocketNotification("call_history", callHistory);
});
},
loadPhonebook: function (body) {
var self = this;
xml2js.parseString(body, function (err, result) {
if (err) {
self.sendSocketNotification("error", "phonebook_parse_error");
if (self.config.debug) {
console.error(self.name + " error while parsing phonebook: " + err);
}
return;
}
var contactsArray = result.phonebooks.phonebook[0].contact;
for (var index in contactsArray) {
var contact = contactsArray[index];
var contactNumbers = contact.telephony[0].number;
var contactName = contact.person[0].realName;
for (var index in contactNumbers) {
var currentNumber = self.normalizePhoneNumber(contactNumbers[index]._);
self.AddressBook[currentNumber] = contactName[0];
}
}
self.sendSocketNotification("contacts_loaded", Object.keys(self.AddressBook).length);
});
},
loadDataFromAPI: function (additionalOption) {
var self = this;
if (self.config.debug) {
console.log('Starting access to FRITZ!Box...');
}
let args = ['-i', self.config.fritzIP, '-p', self.config.password];
if (self.config.username !== "") {
args.push('-u');
args.push(self.config.username);
}
if (additionalOption) {
args.push(additionalOption);
}
let options = {
pythonPath: 'python3',
mode: 'json',
scriptPath: path.resolve(__dirname),
args: args
};
let pyshell = new PythonShell('fritz_access.py', options);
pyshell.on('message', function (message) {
if (message.filename.indexOf("calls") !== -1) {
//Call list file
self.loadCallList(message.content);
} else {
//Phone book file
self.loadPhonebook(message.content);
}
});
//End the input stream and allow the process to exit
pyshell.end(function (error) {
if (error) {
var errorUnknown = true;
if (error.traceback.indexOf("XMLSyntaxError") !== -1) {
//Password is probably wrong
self.sendSocketNotification("error", "login_error");
errorUnknown = false;
}
if (error.traceback.indexOf("failed to load external entity") !== -1) {
//Probably no network connection
self.sendSocketNotification("error", "network_error");
errorUnknown = false;
}
if (errorUnknown) {
self.sendSocketNotification("error", "unknown_error");
}
if (self.config.debug) {
console.error(self.name + " error while accessing FRITZ!Box: ");
console.error(error.traceback);
}
return;
}
if (self.config.debug) {
console.log('Access to FRITZ!Box finished.');
}
});
}
});