Hey all,
I recently got myself a Napoleon Prestige PRO 665 Smart-Connected grill (the EU variant, item no. PRO665VXRSIBPSS-DE) and went down the rabbit hole of reverse engineering its cloud API to build a proper openHAB integration. Thought I’d share everything I’ve found in case anyone else here sits at the intersection of “serious grilling” and “smart home”.
Background
The grill uses Ayla Networks as its IoT backend — the same platform used by several appliance manufacturers. All communication goes through Ayla’s EU cloud (ads-eu.aylanetworks.com). There is no local API — lan_enabled is hardcoded to false on the EU firmware and can’t be changed via the consumer API. Cloud-only for now.
I used HTTP Catcher on iOS to intercept the Napoleon Home App traffic and Claude as a reverse engineering partner to work through the API structure.
What’s working
- Full authentication via the Ayla API (24h token, auto-refreshed at 03:00)
- Bulk polling of all 87 device properties in a single HTTP call every 10 seconds
- Live updates for: grill chamber temp, all active meat probe temps (including wireless w1-format probes), gas weight, smoker state/mode, system mode, RSSI
- Write commands for: power, smoker, all three lights, Knob RGB LEDs
- Automatic light resync after power-on
Key API facts
Auth: POST https://user-field-eu.aylanetworks.com/users/sign_in.json
app_id: smarthome_eu-rA-hQ-id-5Q-id
app_secret: smarthome_eu-rA-hQ-id-gHzZGo5048znNn0F9nuyc_PSyBw
Read: GET https://ads-eu.aylanetworks.com/apiv1/dsns/{DSN}/properties.json
Write: POST https://ads-eu.aylanetworks.com/apiv1/devices/{DEVICE_KEY}/properties/{NAME}/datapoints.json
Body: {"datapoint":{"value": VALUE}}
Target temp (batch required):
POST https://ads-eu.aylanetworks.com/apiv1/batch_datapoints.json
Gotchas
PRB_TMP_FOUR/PRB_TMP_FIVE(wireless probes) return JSON strings:{"w1":[t1,t2,t3,t4,t5]}— take the last elementTRGT_TMP_MAINand probe targets use{"ptr":[VALUE]}format;4095means “no target set”HOOD_Lis the hood interior light, not a position sensor — there is no confirmed hood-open property- Setting
TRGT_TMP_MAINrequires a batch write of three properties simultaneously (the official app does this) BTR_LVL_PB_1reports wireless probe 1 battery on a 1–5 scale
What’s NOT yet known
GRILL_STATEandFLME_STATproperties exist but always returnnull— meaning unclearCOOK_TIMEreturns Unix timestamps per probe — not yet decoded into useful durations- No confirmed hood position sensor
Items file
String Napoleon_Email "Napoleon E-Mail" <text>
String Napoleon_Password "Napoleon Passwort" <text>
Number Napoleon_Garraum "Garraum [%.0f °C]" <temperature>
Number Napoleon_Probe1 "Fühler 1 [%.0f °C]" <temperature>
Number Napoleon_Probe2 "Fühler 2 [%.0f °C]" <temperature>
Number Napoleon_Probe4 "Fühler 4 [%.0f °C]" <temperature>
Switch Napoleon_Power "Grill Power" <switch>
Switch Napoleon_Smoker "Smoker" <smoke>
Number Napoleon_SmokerMode "Smoker Modus [%.0f]" <smoke>
Number Napoleon_SysMode "System Modus [%.0f]" <settings>
Switch Napoleon_Deckellicht "Deckellicht" <light>
Switch Napoleon_Unterschrank "Unterschrank" <light>
Switch Napoleon_LED "White LED" <light>
Number Napoleon_GasGramm "Gas [%.0f g]" <gas>
Number Napoleon_RSSI "RSSI [%.0f dBm]" <qualityofservice>
Number Napoleon_KnobR "Knopf LED Rot [%.0f]" <colorwheel>
Number Napoleon_KnobG "Knopf LED Grün [%.0f]" <colorwheel>
Number Napoleon_KnobB "Knopf LED Blau [%.0f]" <colorwheel>
napoleon.js — full JS Scripting rule file follows below.
const { actions, items, rules, triggers, cache } = require('openhab');
console.loggerName = 'org.openhab.napoleon';
// Napoleon API Konfiguration
const LOGIN_URL = 'https://user-field-eu.aylanetworks.com/users/sign_in.json';
const API_BASE_URL = 'https://ads-eu.aylanetworks.com/apiv1';
const DEVICE_DSN = 'xxx';
const DEVICE_ID = 'xxx';
const APP_ID = 'smarthome_eu-rA-hQ-id-5Q-id';
const APP_SECRET = 'smarthome_eu-rA-hQ-id-gHzZGo5048znNn0F9nuyc_PSyBw';
const HTTP_TIMEOUT_MS = 15000;
const HTTP_GET_TIMEOUT_RETRIES = 1;
// Cache-Schluessel fuer Token-Verwaltung
const CACHE_KEY_TOKEN = 'napoleon.accessToken';
const CACHE_KEY_TOKEN_EXPIRES_AT = 'napoleon.accessTokenExpiresAt';
// Cache-Schluessel fuer Polling-Intervalle
const CACHE_KEY_INTERVAL_BULK = 'napoleon.interval.bulk';
// Property -> Item Mapping inkl. Datentyp fuer Update-Konvertierung
const PROPERTY_MAP = {
PRB_TMP_MAIN: { item: 'Napoleon_Garraum', type: 'number' },
PRB_TMP_ONE: { item: 'Napoleon_Probe1', type: 'number' },
PRB_TMP_TWO: { item: 'Napoleon_Probe2', type: 'number' },
PRB_TMP_FOUR: { item: 'Napoleon_Probe4', type: 'number' },
PWR_CNTRL: { item: 'Napoleon_Power', type: 'switch' },
SMKR_PWR_CNTRL: { item: 'Napoleon_Smoker', type: 'switch' },
SMKR_MODE: { item: 'Napoleon_SmokerMode', type: 'number' },
SYS_MODE: { item: 'Napoleon_SysMode', type: 'number' },
HOOD_L: { item: 'Napoleon_Deckellicht', type: 'switch' },
TNK_WGHT: { item: 'Napoleon_GasGramm', type: 'number' },
RSSI: { item: 'Napoleon_RSSI', type: 'number' },
KNOB_CLR_R: { item: 'Napoleon_KnobR', type: 'number' },
KNOB_CLR_G: { item: 'Napoleon_KnobG', type: 'number' },
KNOB_CLR_B: { item: 'Napoleon_KnobB', type: 'number' },
WHITE_LED: { item: 'Napoleon_LED', type: 'switch' },
CBNT_L: { item: 'Napoleon_Unterschrank', type: 'switch' }
};
const readItemState = (itemName) => {
try {
const value = `${items.getItem(itemName).state}`;
if (value === 'NULL' || value === 'UNDEF') {
return null;
}
return value;
} catch (error) {
console.error(`[Napoleon] Konnte Item ${itemName} nicht lesen: ${error}`);
return null;
}
};
const getToken = () => {
const token = cache.private.get(CACHE_KEY_TOKEN);
return token ? `${token}` : null;
};
const getTokenExpiresAt = () => {
const expiresAt = cache.private.get(CACHE_KEY_TOKEN_EXPIRES_AT);
if (expiresAt == null) {
return 0;
}
const parsed = Number(`${expiresAt}`);
return Number.isFinite(parsed) ? parsed : 0;
};
const isTokenExpired = () => Date.now() >= getTokenExpiresAt();
const ensureToken = () => {
const currentToken = getToken();
if (!currentToken || isTokenExpired()) {
return login();
}
return currentToken;
};
const login = () => {
const email = readItemState('Napoleon_Email');
const password = readItemState('Napoleon_Password');
if (!email || !password) {
console.error('[Napoleon] Login nicht moeglich: Napoleon_Email oder Napoleon_Password fehlt');
return null;
}
const payload = JSON.stringify({
user: {
email,
password,
application: {
app_id: APP_ID,
app_secret: APP_SECRET
}
}
});
try {
const response = actions.HTTP.sendHttpPostRequest(
LOGIN_URL,
'application/json',
payload,
{},
HTTP_TIMEOUT_MS
);
if (!response) {
console.error('[Napoleon] Login-Response ist leer');
return null;
}
const parsed = JSON.parse(response);
const token = parsed?.access_token ?? null;
const expiresIn = Number(parsed?.expires_in ?? 86400);
if (!token) {
console.error(`[Napoleon] Login ohne access_token: ${response}`);
return null;
}
const expiresAt = Date.now() + (Number.isFinite(expiresIn) ? expiresIn : 86400) * 1000;
cache.private.put(CACHE_KEY_TOKEN, token);
cache.private.put(CACHE_KEY_TOKEN_EXPIRES_AT, expiresAt);
console.info('[Napoleon] Login erfolgreich, Token aktualisiert');
return token;
} catch (error) {
console.error(`[Napoleon] Login-Fehler: ${error}`);
return null;
}
};
const isUnauthorizedError = (error) => {
const message = `${error ?? ''}`.toLowerCase();
return message.includes('401') || message.includes('unauthorized');
};
const isTimeoutError = (error) => {
const message = `${error ?? ''}`.toLowerCase();
return message.includes('timeoutexception') || message.includes('timeout');
};
const httpGetWithTimeoutRetry = (url, headerMap, logContext) => {
const requestHeaders = {
...headerMap
};
let lastError = null;
for (let attempt = 0; attempt <= HTTP_GET_TIMEOUT_RETRIES; attempt += 1) {
try {
const response = actions.HTTP.sendHttpGetRequest(url, requestHeaders, HTTP_TIMEOUT_MS);
return { ok: true, response };
} catch (error) {
lastError = error;
const timeoutRetryAllowed = isTimeoutError(error) && attempt < HTTP_GET_TIMEOUT_RETRIES;
if (timeoutRetryAllowed) {
console.warn(`[Napoleon] Timeout bei ${logContext}, starte Retry ${attempt + 1}/${HTTP_GET_TIMEOUT_RETRIES}`);
continue;
}
return { ok: false, response: null, error };
}
}
return { ok: false, response: null, error: lastError };
};
const parseBulkProperties = (responseBody) => {
try {
const parsed = JSON.parse(responseBody);
if (Array.isArray(parsed)) {
return parsed;
}
if (Array.isArray(parsed?.properties)) {
return parsed.properties;
}
return [];
} catch (error) {
console.error(`[Napoleon] Bulk-Poll JSON-Fehler: ${error}`);
return [];
}
};
const pollBulk = () => {
const token = ensureToken();
if (!token) {
return;
}
const url = `${API_BASE_URL}/dsns/${DEVICE_DSN}/properties.json`;
let response = null;
const firstTry = httpGetWithTimeoutRetry(url, { Authorization: `auth_token ${token}` }, 'Bulk-Poll');
if (firstTry.ok) {
response = firstTry.response;
} else if (isUnauthorizedError(firstTry.error)) {
const refreshedToken = login();
if (!refreshedToken) {
return;
}
const secondTry = httpGetWithTimeoutRetry(url, { Authorization: `auth_token ${refreshedToken}` }, 'Bulk-Poll (nach Login)');
if (!secondTry.ok) {
console.error(`[Napoleon] Bulk-Poll nach Token-Refresh fehlgeschlagen: ${secondTry.error}`);
return;
}
response = secondTry.response;
} else {
console.error(`[Napoleon] Bulk-Poll Fehler: ${firstTry.error}`);
return;
}
if (!response) {
console.warn('[Napoleon] Bulk-Poll: leere Response');
return;
}
const entries = parseBulkProperties(response);
entries.forEach((entry) => {
const property = entry?.property;
const propertyName = property?.name;
if (!propertyName || !PROPERTY_MAP[propertyName]) {
return;
}
updateMappedItem(propertyName, property?.value ?? null);
});
};
const writePropertyWithToken = (propertyName, value, token) => {
const url = `${API_BASE_URL}/devices/${DEVICE_ID}/properties/${propertyName}/datapoints.json`;
const payload = JSON.stringify({ datapoint: { value } });
const headers = {
Authorization: `auth_token ${token}`
};
try {
actions.HTTP.sendHttpPostRequest(url, 'application/json', payload, headers, HTTP_TIMEOUT_MS);
return { ok: true, retryAuth: false };
} catch (error) {
console.error(`[Napoleon] POST-Fehler fuer ${propertyName}=${value}: ${error}`);
return { ok: false, retryAuth: isUnauthorizedError(error) };
}
};
const writeProperty = (propertyName, value) => {
const token = ensureToken();
if (!token) {
return false;
}
const firstTry = writePropertyWithToken(propertyName, value, token);
if (firstTry.ok) {
return true;
}
if (!firstTry.retryAuth) {
return false;
}
const refreshedToken = login();
if (!refreshedToken) {
return false;
}
return writePropertyWithToken(propertyName, value, refreshedToken).ok;
};
const normalizeSwitchState = (value) => {
if (value === 1 || value === '1' || value === true || `${value}` === 'ON') {
return 'ON';
}
return 'OFF';
};
const updateMappedItem = (propertyName, value) => {
const mapping = PROPERTY_MAP[propertyName];
if (!mapping) {
return;
}
try {
if (mapping.type === 'switch') {
items.getItem(mapping.item).postUpdate(normalizeSwitchState(value));
return;
}
const numericValue = Number(value);
if (Number.isFinite(numericValue)) {
items.getItem(mapping.item).postUpdate(numericValue);
}
} catch (error) {
console.error(`[Napoleon] Konnte Item ${mapping.item} nicht updaten: ${error}`);
}
};
const clearStoredInterval = (cacheKey) => {
const existingTimer = cache.private.get(cacheKey);
if (!existingTimer) {
return;
}
try {
clearInterval(existingTimer);
} catch (error) {
console.error(`[Napoleon] Konnte alten Interval-Timer ${cacheKey} nicht stoppen: ${error}`);
}
};
const startPollingIntervals = () => {
clearStoredInterval(CACHE_KEY_INTERVAL_BULK);
const timer = setInterval(pollBulk, 10 * 1000);
cache.private.put(CACHE_KEY_INTERVAL_BULK, timer);
};
const SWITCH_COMMAND_TO_PROPERTY = {
Napoleon_Power: 'PWR_CNTRL',
Napoleon_Smoker: 'SMKR_PWR_CNTRL',
Napoleon_Deckellicht: 'HOOD_L',
Napoleon_Unterschrank: 'CBNT_L',
Napoleon_LED: 'WHITE_LED',
Napoleon_CabinetLight: 'CBNT_L'
};
const KNOB_COMMAND_TO_PROPERTY = {
Napoleon_KnobR: 'KNOB_CLR_R',
Napoleon_KnobG: 'KNOB_CLR_G',
Napoleon_KnobB: 'KNOB_CLR_B'
};
const LIGHT_STATE_TO_PROPERTY = {
Napoleon_Deckellicht: 'HOOD_L',
Napoleon_Unterschrank: 'CBNT_L',
Napoleon_LED: 'WHITE_LED'
};
const applyDesiredLightStates = () => {
Object.entries(LIGHT_STATE_TO_PROPERTY).forEach(([itemName, propertyName]) => {
try {
const state = `${items.getItem(itemName).state}`;
if (state !== 'ON' && state !== 'OFF') {
return;
}
writeProperty(propertyName, state === 'ON' ? 1 : 0);
} catch (error) {
console.error(`[Napoleon] Konnte gewuenschten Lichtzustand fuer ${itemName} nicht anwenden: ${error}`);
}
});
};
rules.JSRule({
name: 'Napoleon Startup',
description: 'Initialisiert Login und startet Polling beim Startlevel 100',
overwrite: true,
triggers: [
triggers.SystemStartlevelTrigger(100)
],
execute: () => {
login();
pollBulk();
startPollingIntervals();
}
});
rules.JSRule({
name: 'Napoleon Token Refresh 03:00',
description: 'Erneuert das API Token taeglich um 03:00',
overwrite: true,
triggers: [
triggers.GenericCronTrigger('0 0 3 * * ?')
],
execute: () => {
login();
}
});
rules.JSRule({
name: 'Napoleon Power-On Light Resync',
description: 'Zieht Lichtstatus nach sobald Grill auf ON wechselt',
overwrite: true,
triggers: [
triggers.ItemStateChangeTrigger('Napoleon_Power', undefined, 'ON')
],
execute: () => {
// Kurzer Puffer, bis das Device nach dem Einschalten Schreibbefehle annimmt.
setTimeout(() => {
applyDesiredLightStates();
pollBulk();
}, 5000);
}
});
rules.JSRule({
name: 'Napoleon Switch Command Handler',
description: 'Sendet ON/OFF Kommandos als 1/0 an die API',
overwrite: true,
triggers: [
triggers.ItemCommandTrigger('Napoleon_Power'),
triggers.ItemCommandTrigger('Napoleon_Smoker'),
triggers.ItemCommandTrigger('Napoleon_Deckellicht'),
triggers.ItemCommandTrigger('Napoleon_Unterschrank'),
triggers.ItemCommandTrigger('Napoleon_LED'),
triggers.ItemCommandTrigger('Napoleon_CabinetLight')
],
execute: (event) => {
const itemName = event.itemName;
const propertyName = SWITCH_COMMAND_TO_PROPERTY[itemName];
const command = `${event.receivedCommand}`;
if (!propertyName) {
return;
}
if (command !== 'ON' && command !== 'OFF') {
console.error(`[Napoleon] Ungueltiger Switch-Befehl fuer ${itemName}: ${command}`);
return;
}
const apiValue = command === 'ON' ? 1 : 0;
const success = writeProperty(propertyName, apiValue);
if (success) {
items.getItem(itemName).postUpdate(command);
}
}
});
rules.JSRule({
name: 'Napoleon Knob Command Handler',
description: 'Schreibt RGB-Knob-Werte direkt als Integer',
overwrite: true,
triggers: [
triggers.ItemCommandTrigger('Napoleon_KnobR'),
triggers.ItemCommandTrigger('Napoleon_KnobG'),
triggers.ItemCommandTrigger('Napoleon_KnobB')
],
execute: (event) => {
const itemName = event.itemName;
const propertyName = KNOB_COMMAND_TO_PROPERTY[itemName];
const raw = `${event.receivedCommand}`;
const numericValue = Number.parseInt(raw, 10);
if (!propertyName) {
return;
}
if (!Number.isInteger(numericValue)) {
console.error(`[Napoleon] Ungueltiger RGB-Wert fuer ${itemName}: ${raw}`);
return;
}
const success = writeProperty(propertyName, numericValue);
if (success) {
items.getItem(itemName).postUpdate(numericValue);
}
}
});
rules.JSRule({
name: 'Napoleon Module Load',
description: 'Loggt das Laden des Moduls',
overwrite: true,
triggers: [
triggers.ItemStateChangeTrigger('Openhab_Online', undefined, 'ON'),
triggers.ItemStateChangeTrigger('Rule_Reload', undefined, 'ON')
],
execute: () => {
console.info('[Napoleon] napoleon.js geladen');
}
});
Happy to share more captures if anyone wants to dig into specific properties.
