Skip to content

Migration script ​

This HomeyScript points your existing Flows at the devices you paired in Anker Eufy. Moving from the old apps explains what it does and what it leaves alone.

How to run it ​

  1. Pair your devices in Anker Eufy first, and keep the old apps enabled.
  2. Install HomeyScript and open it at https://my.homey.app/scripts.
  3. Make a new script, and paste the whole script below into it. The copy button is in the top-right corner of the code block.
  4. Run it as it is. softRun = true is the default: nothing is saved, and the output shows which devices it paired and every change it would make.
  5. When the output looks right, change softRun to false at the top of the script and run it again.
  6. Remove the old apps once the final check at the end of the run reports no old devices left in any Flow.

The settings you may want to change are at the top of the script, under CONFIGURATION.

The script ​

js
/**
 * HomeyScript: Eufy old-app → new-app Flow migration v4.6
 *
 * 1. Change the CONFIGURATION below if needed.
 * 2. Run with softRun = true first: nothing is saved, it only shows what it would do.
 * 3. Set softRun = false and run again to update the Flows.
 *
 * How the script works is described below the configuration.
 */


// ============================================================================
// CONFIGURATION
// ============================================================================

// true  = diagnostic only (nothing is saved)
// false = actually update Flows
const softRun = true;

// Detailed logging of changes in a live run.
const detailedLog = true;

// "Sound the alarm" duration when the old card had none.
const defaultAlarmSeconds = 60;

// Old apps to migrate FROM.
const OLD_APP_IDS = new Set([
    'com.eufylife.security',
    'com.eufylife.home',
]);

// New app to migrate TO.
const NEW_APP_ID = 'com.eufy';

// Optional: serials to leave out, e.g. ['T8000P0000000000'].
const skipSerials = new Set([]);

// Fields of an OLD device's data that can hold its serial, in order of
// preference. The first one whose value matches a NEW device wins.
const OLD_SERIAL_FIELDS = [
    'device_sn',
    'deviceSn',
    'deviceSN',
    'sn',
    'serial',
    'serialNumber',
    'deviceId',
    'device_id',
    'devId',
    'id',
    'station_sn',
    'stationSn',
];

// Fields of a NEW device's data that hold its serial.
const NEW_SERIAL_FIELDS = [
    'sn',
    'device_sn',
];


/**
 * HOW IT WORKS
 *
 * Migrates Flows from the old apps
 *
 *     com.eufylife.security   (Eufy Security: cameras, doorbells, HomeBase, sensors)
 *     com.eufylife.home       (Eufy Clean: robot vacuums)
 *
 * to the new app
 *
 *     com.eufy                (Anker Eufy)
 *
 *
 * HOW DEVICES ARE FOUND
 *
 *   No hard-coded device IDs. OLD and NEW devices are paired by serial number:
 *
 *     OLD Eufy Security : data.device_sn  (a HomeBase stores its station_sn there)
 *     OLD Eufy Clean    : the first serial-like field in its device data
 *     NEW Anker Eufy    : data.sn
 *
 *   Arming cards move to the NEW station device whose serial matches the old
 *   device's station_sn. A standalone camera (its own station) keeps them on
 *   the camera itself. Devices that can't be paired one-to-one are logged and
 *   left alone.
 *
 *
 * FLOW CARDS
 *
 *   The new app names its cards per device type (eufy_camera_*, eufy_doorbell_*,
 *   eufy_sensor_*, eufy_station_*), so every mapping resolves against the NEW
 *   device's driver. A mapping that has no card for that driver is not guessed:
 *   the Flow is skipped and listed.
 *
 *   Mapped (old → new):
 *
 *     Triggers
 *       NTFY_MOTION_DETECTION      → motion
 *       NTFY_FACE_DETECTION        → person_detected        (token user → person)
 *       NTFY_KNOWN_FACE_DETECTION  → known_person_detected  (token user → person)
 *       NTFY_PET_DETECTED          → pet_detection
 *       NTFY_VEHICLE_DETECTED      → vehicle_detected
 *       NTFY_CRYING_DETECTED       → crying_detected
 *       NTFY_SOUND_DETECTED        → sound_detected
 *       NTFY_PRESS_DOORBELL        → doorbell_press
 *       CMD_SET_ARMING             → arming_mode_changed  (station or standalone camera)
 *
 *     Conditions
 *       CHECK_ARMING               → arming mode is …     (station or standalone camera)
 *       CMD_SET_FLOODLIGHT_MANUAL_SWITCH → light is on (inverted for "off")
 *
 *     Actions
 *       CMD_SNAPSHOT               → snapshot
 *       CMD_TRIGGER_ALARM          → trigger            (duration carried over)
 *       CMD_SET_HUB_ALARM_CLOSE    → stop
 *       CMD_REBOOT_HUB             → reboot
 *       CMD_SET_ARMING             → set arming mode      (station or standalone camera)
 *       CMD_IRCUT_SWITCH           → night vision       (OFF → Off, ON → Infrared)
 *       CMD_DEV_LED_SWITCH         → status LED on/off
 *       CMD_SET_FLOODLIGHT_MANUAL_SWITCH → light on/off
 *       CMD_BAT_DOORBELL_WDR_SWITCH → WDR on/off
 *       CMD_INDOOR_PAN_TURN        → Turn 360 (Room scan)
 *       CMD_INDOOR_PAN_TURN_PTZ    → pan left/right, tilt up/down (repeat → steps)
 *       CMD_SET_SNOOZE_MODE_CHIME  → snooze time (only when the old card snoozed motion)
 *
 *     Eufy Clean
 *       clean speed                → suction (quiet/standard/turbo/max)
 *       clean params               → set clean parameters (type, extent, mop level)
 *       control mode               → Homey's start / dock / stop cards, or resume
 *                                    (STOP pauses in the new app, so STOP and PAUSE match)
 *       CMD_SET_SNOOZE_MODE(_HOMEBASE) → snooze time     (seconds; HOMEBASE only when it snoozed motion)
 *
 *   Not mapped (the Flow is skipped and listed with the reason, rebuild by hand):
 *     no card in the new app yet: streaming, recording, motion tracking, HomeBase ringtone, HomeBase alarm volume, loitering
 *       trigger, setting the doorbell video quality, the known-face condition
 *     picked by name in the new app: quick response, cleaning scene
 *
 *
 * DIAGNOSTIC RUN:
 *
 *   softRun = true  → nothing is saved; shows the device pairing and every
 *                     change that WOULD be made.
 *   softRun = false → Flows are actually updated.
 *
 *
 * IMPORTANT:
 *
 * Keep the old apps ENABLED until all migrations and the final verification
 * are complete. Pair all devices in the new app before running.
 */


// ============================================================================
// ARGUMENT CONVERTERS
// ============================================================================

const ARMING_MODES = {
    away: '0',
    home: '1',
    schedule: '2',
    custom_1: '3',
    custom_2: '4',
    custom_3: '5',
    off: '6',
    geo: '47',
    disarmed: '63',
};

function unwrapValue(value) {
    if (value !== null && typeof value === 'object') {
        if (Object.prototype.hasOwnProperty.call(value, 'value')) {
            return unwrapValue(value.value);
        }
        if (Object.prototype.hasOwnProperty.call(value, 'id')) {
            return unwrapValue(value.id);
        }
    }

    return value;
}

function parseLegacyBoolean(value) {
    value = unwrapValue(value);

    if (typeof value === 'boolean') {
        return value;
    }

    if (typeof value === 'number') {
        if (value === 0) return false;
        if (value === 1) return true;
        return null;
    }

    if (typeof value === 'string') {
        const normalized = value.trim().toLowerCase();

        if (['0', 'false', 'off', 'disable', 'disabled', 'no'].includes(normalized)) {
            return false;
        }
        if (['1', 'true', 'on', 'enable', 'enabled', 'yes', 'infrared', 'ir'].includes(normalized)) {
            return true;
        }
    }

    return null;
}

/** Reads one old argument; throws when it is absent. */
function requireArg(args, key) {
    const value = unwrapValue(args?.[key]);

    if (value === undefined || value === null || value === '') {
        throw new Error(`missing argument "${key}"`);
    }

    return value;
}

function boolArg(args, key) {
    const parsed = parseLegacyBoolean(requireArg(args, key));

    if (parsed === null) {
        throw new Error(`"${key}" is not on/off: ${safeJson(args?.[key])}`);
    }

    return parsed;
}

function armingArg(args, key) {
    const raw = String(requireArg(args, key)).trim().toLowerCase();
    const value = ARMING_MODES[raw];

    if (value === undefined) {
        throw new Error(`unknown arming mode "${raw}"`);
    }

    return value;
}

const PTZ_DIRECTIONS = new Set(['left', 'right', 'up', 'down']);

// Eufy Clean speed → the new suction value (its autocomplete row).
const CLEAN_SPEEDS = {
    quiet: { id: '0', name: 'Quiet' },
    standard: { id: '1', name: 'Standard' },
    turbo: { id: '2', name: 'Turbo' },
    max: { id: '3', name: 'Max' },
};

const CLEAN_TYPES = { SWEEP_AND_MOP: 'sweepAndMop', SWEEP_ONLY: 'sweep', MOP_ONLY: 'mop' };
const CLEAN_MOP_LEVELS = { HIGH: 'high', MEDIUM: 'middle', LOW: 'low' };
const CLEAN_EXTENTS = { NORMAL: 'normal', NARROW: 'narrow', QUICK: 'quick' };

// Eufy Clean control mode → the new card. clean/dock/stop are Homey's own
// vacuum cards; "stop" pauses in the new app, so STOP and PAUSE both land there.
const CLEAN_CONTROL_CARDS = {
    AUTO_CLEAN: 'clean',
    GO_HOME: 'dock',
    STOP: 'stop',
    PAUSE: 'stop',
    PLAY: 'resume_cleaning',
};
const HOMEY_VACUUM_CARDS = new Set(['clean', 'dock', 'stop']);

function directionArg(args, key) {
    const raw = String(requireArg(args, key)).trim().toLowerCase();

    if (!PTZ_DIRECTIONS.has(raw)) {
        throw new Error(`"${raw}" is not a pan/tilt step (left, right, up, down)`);
    }

    return raw;
}

/** The old "repeat" count as the new 1–10 "steps"; absent means one step. */
function stepsArg(args, key) {
    const raw = unwrapValue(args?.[key]);

    if (raw === undefined || raw === null || raw === '') {
        return 1;
    }

    const steps = Number(raw);

    if (!Number.isInteger(steps) || steps < 1 || steps > 10) {
        throw new Error(`"${key}" must be 1–10 steps, got ${safeJson(raw)}`);
    }

    return steps;
}

/** Reads a dropdown and maps it through a table; throws on a value the table lacks. */
function choiceArg(args, key, table) {
    const raw = String(requireArg(args, key)).trim();
    const value = table[raw] ?? table[raw.toUpperCase()] ?? table[raw.toLowerCase()];

    if (value === undefined) {
        throw new Error(`"${key}" has no new equivalent for "${raw}"`);
    }

    return value;
}

/**
 * The old snooze cards snoozed motion plus a chime or HomeBase flag; the new
 * card snoozes motion only. Converted only when the old card snoozed motion.
 */
function motionSnoozeArgs(args, flagKey) {
    if (!boolArg(args, 'motion')) {
        throw new Error('the old card did not snooze motion, and the new card snoozes motion only');
    }

    return { value: secondsArg(args, 'snooze'), note: boolArg(args, flagKey) ? `${flagKey} snooze not carried over` : null };
}

function secondsArg(args, key) {
    const seconds = Number(requireArg(args, key));

    if (!Number.isFinite(seconds) || seconds < 0) {
        throw new Error(`"${key}" is not a duration: ${safeJson(args?.[key])}`);
    }

    return seconds;
}


// ============================================================================
// FLOW CARD MAPPING
// ============================================================================
//
// Key: the old card suffix (homey:device:<old uuid>:<suffix>).
//
//   card     new card name; the full id is eufy_<driver>_<card>
//            (a function of the old args when the old card's argument
//            picks the new card)
//   requires capability the NEW device must carry for the card to show
//   drivers  new drivers that have this card
//   target   'self' (default): the paired NEW device
//            'arming': the NEW station device of the old device's station_sn,
//            or the paired NEW device itself when it is a standalone camera
//            that owns its guard mode
//   args     old args → new args (throw = can't convert → Flow is skipped)
//   invert   old args → true when the new condition must be inverted
//   tokens   renamed trigger tokens { old: new }; null = no longer provided
// ============================================================================

const CAMERAS = ['camera', 'doorbell'];
const DETECTORS = ['camera', 'doorbell', 'sensor'];
const SIRENS = ['camera', 'doorbell', 'station'];
const ARMING = ['station', 'camera'];
const ARMING_CAPABILITY = 'eufy_arming_armingmode';

const flowCardMappings = {

    // ------------------------------------------------------------------------
    // Triggers
    // ------------------------------------------------------------------------

    'trigger_NTFY_MOTION_DETECTION': { card: 'motion', drivers: DETECTORS },
    'trigger_NTFY_FACE_DETECTION': { card: 'person_detected', drivers: CAMERAS, tokens: { user: 'person' } },
    'trigger_NTFY_KNOWN_FACE_DETECTION': { card: 'known_person_detected', drivers: CAMERAS, tokens: { user: 'person' } },
    'trigger_NTFY_PET_DETECTED': { card: 'pet_detection', drivers: DETECTORS },
    'trigger_NTFY_VEHICLE_DETECTED': { card: 'vehicle_detected', drivers: DETECTORS },
    'trigger_NTFY_CRYING_DETECTED': { card: 'crying_detected', drivers: DETECTORS },
    'trigger_NTFY_SOUND_DETECTED': { card: 'sound_detected', drivers: DETECTORS },
    'trigger_NTFY_PRESS_DOORBELL': { card: 'doorbell_press', drivers: ['doorbell'] },
    'trigger_CMD_SET_ARMING': { card: 'arming_mode_changed', drivers: ARMING, target: 'arming', tokens: { user: null } },

    // ------------------------------------------------------------------------
    // Conditions
    // ------------------------------------------------------------------------

    'condition_CHECK_ARMING': {
        card: 'arming_armingmode_equals',
        drivers: ARMING,
        target: 'arming',
        args: a => ({ value: armingArg(a, 'condition_CHECK_ARM_TYPE') }),
    },

    'condition_CMD_SET_FLOODLIGHT_MANUAL_SWITCH': {
        card: 'light_light_is',
        drivers: CAMERAS,
        args: () => ({}),
        invert: a => !boolArg(a, 'condition_CMD_SET_FLOODLIGHT_MANUAL_SWITCH_TYPE'),
    },

    // ------------------------------------------------------------------------
    // Actions
    // ------------------------------------------------------------------------

    'action_CMD_SNAPSHOT': { card: 'snapshot', drivers: CAMERAS, args: () => ({}) },

    'action_CMD_TRIGGER_ALARM': {
        card: 'trigger',
        drivers: SIRENS,
        args: a => {
            const raw = Number(unwrapValue(a?.action_CMD_TRIGGER_ALARM_TIME ?? a?.seconds));
            return { seconds: Number.isFinite(raw) && raw > 0 ? raw : defaultAlarmSeconds };
        },
    },

    'action_CMD_SET_HUB_ALARM_CLOSE': { card: 'stop', drivers: SIRENS, args: () => ({}) },
    'action_CMD_REBOOT_HUB': { card: 'reboot', drivers: SIRENS, args: () => ({}) },

    'action_CMD_SET_ARMING': {
        card: 'arming_armingmode_set_value',
        drivers: ARMING,
        target: 'arming',
        args: a => ({ value: armingArg(a, 'action_CMD_SET_ARM_TYPE') }),
    },

    // Legacy OFF → Night Vision Off ("0"), legacy ON → Night Vision Infrared ("1")
    'action_CMD_IRCUT_SWITCH': {
        card: 'camera_nightvision_set_value',
        drivers: CAMERAS,
        args: a => ({ value: boolArg(a, 'action_CMD_IRCUT_SWITCH_TYPE') ? '1' : '0' }),
    },

    'action_CMD_DEV_LED_SWITCH': {
        card: 'camera_statusled_set',
        drivers: CAMERAS,
        args: a => ({ state: boolArg(a, 'action_CMD_DEV_LED_SWITCH_TYPE') ? 'on' : 'off' }),
    },

    'action_CMD_SET_FLOODLIGHT_MANUAL_SWITCH': {
        card: 'light_light_set',
        drivers: CAMERAS,
        args: a => ({ state: boolArg(a, 'action_CMD_SET_FLOODLIGHT_MANUAL_SWITCH_TYPE') ? 'on' : 'off' }),
    },

    'action_CMD_INDOOR_PAN_TURN': {
        card: 'rotate360',
        drivers: CAMERAS,
        requires: 'eufy_ptz',
        args: () => ({}),
    },

    'action_CMD_INDOOR_PAN_TURN_PTZ': {
        card: a => directionArg(a, 'action_CMD_INDOOR_PAN_TURN_PTZ_TYPE'),
        drivers: ['camera'],
        requires: 'eufy_ptz',
        args: a => ({ steps: stepsArg(a, 'repeat') }),
    },

    'action_CMD_BAT_DOORBELL_WDR_SWITCH': {
        card: 'doorbell_wdrswitch_set',
        drivers: ['doorbell'],
        args: a => ({ state: boolArg(a, 'action_CMD_BAT_DOORBELL_WDR_SWITCH_TYPE') ? 'on' : 'off' }),
    },

    'action_CMD_SET_SNOOZE_MODE': {
        card: 'motion_snoozetime_set_value',
        drivers: DETECTORS,
        args: a => ({ value: secondsArg(a, 'snooze') }),
    },

    'action_CMD_SET_SNOOZE_MODE_HOMEBASE': {
        card: 'motion_snoozetime_set_value',
        drivers: DETECTORS,
        args: a => motionSnoozeArgs(a, 'homebase'),
    },

    'action_CMD_SET_SNOOZE_MODE_CHIME': {
        card: 'motion_snoozetime_set_value',
        drivers: DETECTORS,
        args: a => motionSnoozeArgs(a, 'chime'),
    },

    // ------------------------------------------------------------------------
    // Eufy Clean (robot vacuums)
    // ------------------------------------------------------------------------

    'action_measure_clean_speed': {
        card: 'suction_suction_set_value',
        drivers: ['vacuum'],
        args: a => ({ value: choiceArg(a, 'action_measure_clean_speed_type', CLEAN_SPEEDS) }),
    },

    'action_clean_params': {
        card: 'set_clean_param',
        drivers: ['vacuum'],
        args: a => ({
            cleanType: choiceArg(a, 'action_clean_params_cleantype', CLEAN_TYPES),
            cleanExtent: choiceArg(a, 'action_clean_params_cleanextent', CLEAN_EXTENTS),
            mopLevel: choiceArg(a, 'action_clean_params_mopmode', CLEAN_MOP_LEVELS),
        }),
    },

    'action_control_mode': {
        card: a => choiceArg(a, 'action_control_mode_type', CLEAN_CONTROL_CARDS),
        homeyCard: name => HOMEY_VACUUM_CARDS.has(name),
        drivers: ['vacuum'],
        requires: 'vacuumcleaner_state',
        args: () => ({}),
    },
};

// Why a known old card has no mapping, shown instead of the generic reason.
const NO_CARD = 'The new app has no card for this yet. Rebuild by hand, or keep this Flow on the old app.';

const UNMAPPED_REASONS = {
    'action_CMD_START_STREAM': `Streaming: ${NO_CARD}`,
    'action_CMD_START_STREAM_HLS_RTMP': `Streaming: ${NO_CARD}`,
    'action_CMD_STOP_STREAM': `Streaming: ${NO_CARD}`,
    'trigger_STREAM_STARTED': `Streaming: ${NO_CARD}`,
    'action_CMD_RECORD': `Recording: ${NO_CARD}`,
    'action_CMD_MOTION_TRACKING': `Motion tracking: ${NO_CARD}`,
    'action_CMD_TRIGGER_RINGTONE_HUB': `Playing a HomeBase ringtone: ${NO_CARD}`,
    'action_CMD_SET_HUB_ALARM_VOLUME': `HomeBase alarm volume: ${NO_CARD}`,
    'trigger_NTFY_LOITERING_DETECTION': `Loitering detected: ${NO_CARD}`,
    'condition_KNOWN_FACE':
        'The new app has no "known face is …" condition. Use "A known person was detected" and compare its Person tag instead.',
    'action_CMD_BAT_DOORBELL_VIDEO_QUALITY':
        'The new app reads the doorbell video quality but has no card to set it. Rebuild by hand.',
    'action_CMD_DOORBELL_QUICK_RESPONSE':
        'The new quick-response card picks one of your own recorded responses by name, and the old card only had a number. Pick the response again in the new card.',
    'action_scenes':
        'The new "Run a cleaning scene" card picks one of the robot\'s own scenes by name, and the old card only had a number. Pick the scene again in the new card.',
};

// Legacy custom card ids look like "<device uri>:action_…", ":trigger_…" or
// ":condition_…". Anything of that shape without a mapping skips the Flow.
const LEGACY_CUSTOM_CARD = /^(action|trigger|condition)_/;


// ============================================================================
// CAPABILITY / FLOW TOKEN MAPPING
// ============================================================================
//
// Device tokens and capability references (homey:device:<uuid>|<capability>).
// Standard Homey capabilities (alarm_motion, measure_battery, onoff,
// vacuumcleaner_state, ...) keep their name and only get the new UUID.
// ============================================================================

const capabilityMappings = {
    'CMD_DEV_LED_SWITCH': 'eufy_camera_statusled',
    'CMD_SET_FLOODLIGHT_MANUAL_SWITCH': 'eufy_light_light',
    'CMD_IRCUT_SWITCH': 'eufy_camera_nightvision',
    'CMD_SET_SNOOZE_MODE': 'eufy_motion_snoozetime',
    'CMD_SET_SNOOZE_MODE_HOMEBASE': 'eufy_motion_snoozetime',
    'CMD_BAT_DOORBELL_WDR_SWITCH': 'eufy_doorbell_wdrswitch',
    'CMD_SNAPSHOT': 'eufy_snapshot',
    'NTFY_MOTION_DETECTION': 'alarm_motion',
    'NTFY_FACE_DETECTION': 'eufy_detect_person',
    'NTFY_PET_DETECTED': 'eufy_detect_pet',
    'NTFY_VEHICLE_DETECTED': 'eufy_detect_vehicle',
    'NTFY_CRYING_DETECTED': 'eufy_detect_crying',
    'NTFY_SOUND_DETECTED': 'eufy_detect_sound',
    'NTFY_PRESS_DOORBELL': 'eufy_detect_ring',
};

// Longest first, so CMD_SET_SNOOZE_MODE_HOMEBASE is replaced before its prefix
// CMD_SET_SNOOZE_MODE.
const capabilityMappingEntries = Object.entries(capabilityMappings)
    .sort(([a], [b]) => b.length - a.length);

// Old-app capabilities that do not exist in the new app. A reference to one of
// these that is not in capabilityMappings skips the Flow.
const LEGACY_ONLY_CAPABILITY = {
    'com.eufylife.security': /^(CMD_[A-Z0-9_]+|NTFY_[A-Z0-9_]+|alarm_arm_mode)$/,
    'com.eufylife.home': /^(measure_clean_speed|measure_docked|measure_error|measure_is_charging|measure_recharge_needed|measure_work_mode|measure_work_status|action_clean_speed)$/,
};


// ============================================================================
// RUNTIME STATE
// ============================================================================

let resolvedMigrations = [];

// Every OLD-app device, paired or not: { device, appId, paired }.
let allOldDevices = [];

// NEW devices by normalized serial.
let newDevicesBySn = new Map();

let flowTokens = {};

// Card names (the part after the last ":") the installed NEW app offers, or
// null when they could not be read.
let availableCards = null;


// ============================================================================
// BASIC HELPERS
// ============================================================================

function clone(value) {
    return JSON.parse(JSON.stringify(value));
}

function escapeRegex(value) {
    return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function getFlowName(flow) {
    return flow?.name || flow?.title || flow?.id || '<unnamed flow>';
}

function cardLabel(card) {
    return card?.id || card?.uri || card?.ownerUri || '<unknown card>';
}

function addChangedPath(changedPaths, path) {
    if (!changedPaths.includes(path)) {
        changedPaths.push(path);
    }
}

function safeJson(value) {
    try {
        return JSON.stringify(value);
    } catch (_) {
        return String(value);
    }
}

function replaceAll(value, from, to) {
    return value.split(from).join(to);
}


// ============================================================================
// FLOW TOKEN REGISTRY
// ============================================================================

/**
 * Reads which Flow cards the installed apps offer, so a card is never pointed
 * at one the installed version of the NEW app does not have.
 */
async function loadAvailableCards() {
    try {
        const lists = await Promise.all([
            Homey.flow.getFlowCardTriggers(),
            Homey.flow.getFlowCardConditions(),
            Homey.flow.getFlowCardActions(),
        ]);

        availableCards = new Set();

        for (const list of lists) {
            for (const [key, card] of Object.entries(list || {})) {
                for (const id of [key, card?.id]) {
                    if (typeof id === 'string') {
                        availableCards.add(id.split(':').pop());
                    }
                }
            }
        }

        const own = [...availableCards].filter(name => name.startsWith('eufy_')).length;
        console.log(`Loaded ${availableCards.size} Flow cards (${own} from ${NEW_APP_ID}).`);

        if (!own) {
            console.log(`⚠️ No ${NEW_APP_ID} cards found; the card check is off for this run.`);
            availableCards = null;
        }
    } catch (err) {
        console.log(`⚠️ Could not read the installed Flow cards (${err?.message || err}); the card check is off for this run.`);
        availableCards = null;
    }
}

async function loadFlowTokens() {
    try {
        if (!Homey.flowtoken || typeof Homey.flowtoken.getFlowTokens !== 'function') {
            console.log('⚠️ Homey.flowtoken.getFlowTokens() unavailable.');
            flowTokens = {};
            return;
        }

        flowTokens = await Homey.flowtoken.getFlowTokens();
        console.log(`Loaded ${Object.keys(flowTokens).length} FlowTokens.`);
    } catch (err) {
        console.log(`⚠️ Could not load FlowTokens: ${err?.message || err}`);
        flowTokens = {};
    }
}


// ============================================================================
// DEVICE DISCOVERY & PAIRING BY SERIAL
// ============================================================================

function getDeviceAppId(device) {
    const uri = device?.ownerUri || device?.driverUri || device?.driverId || '';
    const match = /^homey:app:([^:]+)/.exec(uri);
    return match ? match[1] : null;
}

/** The NEW app's driver id of a device: "homey:app:com.eufy:camera" → "camera". */
function getDriver(device) {
    const uri = device?.driverId || device?.driverUri || '';
    return uri.split(':').pop() || null;
}

function isOldHomeBase(device) {
    return /HOMEBASE/i.test(device?.driverId || device?.driverUri || '');
}

function normalizeSn(value) {
    if (typeof value === 'number') {
        value = String(value);
    }
    return typeof value === 'string' && value.trim()
        ? value.trim().toUpperCase()
        : null;
}

/** Serial candidates of a device, in order of preference: [{ field, sn }]. */
function serialCandidates(device, fields) {
    const data = device?.data || {};
    const out = [];
    const seen = new Set();

    const add = (field, sn) => {
        if (sn && !seen.has(sn)) {
            seen.add(sn);
            out.push({ field, sn });
        }
    };

    for (const field of fields) {
        const sn = normalizeSn(data[field]);
        add(field, sn);

        // Eufy Security stores id as "<serial>-<numeric id>".
        if (sn && sn.includes('-')) {
            add(`${field} (before "-")`, sn.split('-')[0]);
        }
    }

    return out;
}

/**
 * Picks the NEW device for an OLD one among NEW devices sharing its serial.
 * A standalone camera can show up as both a camera and a station; the old
 * HomeBase goes to the station, anything else to the non-station device.
 */
function chooseNewDevice(newDevices, oldDevice) {
    if (newDevices.length === 1) {
        return newDevices[0];
    }

    const wantStation = isOldHomeBase(oldDevice);
    const preferred = newDevices.filter(d => (getDriver(d) === 'station') === wantStation);

    return preferred.length === 1 ? preferred[0] : null;
}

/**
 * Whether a NEW device carries the guard-mode control its arming cards need.
 * Strict on cameras (only a standalone one has it); a station is assumed to
 * have it when the device object carries no capabilities list.
 */
function hasArming(device) {
    if (Array.isArray(device?.capabilities)) {
        return device.capabilities.includes(ARMING_CAPABILITY);
    }
    return getDriver(device) === 'station';
}

/** The NEW station device for an OLD device, by its station_sn. */
function findNewStation(migration) {
    const data = migration.oldDevice?.data || {};
    const sn = normalizeSn(data.station_sn || data.stationSn || data.device_sn);

    if (!sn) {
        return { device: null, sn: null };
    }

    const stations = (newDevicesBySn.get(sn) || []).filter(d => getDriver(d) === 'station');

    return { device: stations.length === 1 ? stations[0] : null, sn };
}

async function discoverPairs() {
    const devices = await Homey.devices.getDevices();

    const oldDevices = [];
    const newWithoutSn = [];

    newDevicesBySn = new Map();

    for (const device of Object.values(devices)) {
        const appId = getDeviceAppId(device);

        if (OLD_APP_IDS.has(appId)) {
            oldDevices.push({ device, appId });
            continue;
        }

        if (appId !== NEW_APP_ID) {
            continue;
        }

        const candidates = serialCandidates(device, NEW_SERIAL_FIELDS);

        if (!candidates.length) {
            newWithoutSn.push(device);
            continue;
        }

        const sn = candidates[0].sn;
        if (!newDevicesBySn.has(sn)) {
            newDevicesBySn.set(sn, []);
        }
        newDevicesBySn.get(sn).push(device);
    }

    // Resolve each OLD device to the NEW device sharing its serial.
    const claimsByNewId = new Map();
    const oldWithoutSn = [];
    const unmatchedOld = [];
    const skipped = [];
    const ambiguous = [];

    for (const entry of oldDevices) {
        const candidates = serialCandidates(entry.device, OLD_SERIAL_FIELDS);

        if (!candidates.length) {
            oldWithoutSn.push(entry);
            continue;
        }

        const hit = candidates.find(c => newDevicesBySn.has(c.sn));

        if (!hit) {
            unmatchedOld.push({ ...entry, sn: candidates[0].sn });
            continue;
        }

        if (skipSerials.has(hit.sn)) {
            skipped.push({ ...entry, sn: hit.sn });
            continue;
        }

        const newDevices = newDevicesBySn.get(hit.sn);
        const newDevice = chooseNewDevice(newDevices, entry.device);

        if (!newDevice) {
            ambiguous.push({
                sn: hit.sn,
                reason: 'serial is on more than one NEW device',
                oldDevices: [entry.device],
                newDevices,
            });
            continue;
        }

        if (!claimsByNewId.has(newDevice.id)) {
            claimsByNewId.set(newDevice.id, []);
        }
        claimsByNewId.get(newDevice.id).push({ ...entry, sn: hit.sn, field: hit.field, newDevice });
    }

    // A NEW device may be claimed by exactly one OLD device.
    const pairs = [];

    for (const claims of claimsByNewId.values()) {
        if (claims.length === 1) {
            pairs.push(claims[0]);
            continue;
        }

        ambiguous.push({
            sn: claims[0].sn,
            reason: 'more than one OLD device matches the same NEW device',
            oldDevices: claims.map(c => c.device),
            newDevices: [claims[0].newDevice],
        });
    }

    const pairedNewIds = new Set(pairs.map(p => p.newDevice.id));
    const newOnly = [...newDevicesBySn.values()]
        .flat()
        .filter(device => !pairedNewIds.has(device.id));

    return { pairs, oldDevices, unmatchedOld, oldWithoutSn, skipped, ambiguous, newWithoutSn, newOnly };
}


// ============================================================================
// IMAGE TOKEN MAPPING
// ============================================================================

function addImageMapping(migration, oldToken, type, source) {
    if (!oldToken || !type) {
        return;
    }
    if (migration.imageMappings.has(oldToken)) {
        return;
    }
    migration.imageMappings.set(oldToken, { type, source });
}

function registryTokenToDropToken(tokenId, migration) {
    const prefix = `${migration.oldUri}:`;

    if (tokenId.startsWith(prefix)) {
        return `${migration.oldUri}|${tokenId.substring(prefix.length)}`;
    }

    return tokenId;
}

function discoverImageTokens(migration) {
    const registryPrefix = `${migration.oldUri}:image-camera-`;

    for (const [key, token] of Object.entries(flowTokens)) {
        const candidates = new Set();

        if (key) {
            candidates.add(key);
        }
        if (token?.id) {
            candidates.add(token.id);
        }

        for (const tokenId of candidates) {
            if (!tokenId.startsWith(registryPrefix)) {
                continue;
            }

            const description = `${tokenId} ${token?.title || ''}`;
            const type = /snapshot/i.test(description) ? 'snapshot' : 'event';

            addImageMapping(
                migration,
                registryTokenToDropToken(tokenId, migration),
                type,
                'FlowToken registry'
            );
        }
    }

    // Device data fallback
    const data = migration.oldDevice?.data || {};
    const deviceSn = data.device_sn || data.deviceSn;
    const stationSn = data.station_sn || data.stationSn;

    if (deviceSn) {
        addImageMapping(migration, `${migration.oldUri}|image-camera-${deviceSn}-Snapshot`, 'snapshot', 'device_sn');
    }

    if (stationSn) {
        addImageMapping(migration, `${migration.oldUri}|image-camera-${stationSn}`, 'event', 'station_sn');
    }
}


// ============================================================================
// PREPARE DEVICE MIGRATIONS
// ============================================================================

async function prepareMigrations() {
    const {
        pairs,
        oldDevices,
        unmatchedOld,
        oldWithoutSn,
        skipped,
        ambiguous,
        newWithoutSn,
        newOnly,
    } = await discoverPairs();

    resolvedMigrations = [];

    const pairedOldIds = new Set(pairs.map(p => p.device.id));
    allOldDevices = oldDevices.map(entry => ({ ...entry, paired: pairedOldIds.has(entry.device.id) }));

    console.log('');
    console.log(`OLD-app devices found      : ${oldDevices.length}`);
    for (const appId of OLD_APP_IDS) {
        console.log(`  ${appId.padEnd(25)}: ${oldDevices.filter(d => d.appId === appId).length}`);
    }
    console.log(`Device pairs matched by SN : ${pairs.length}`);

    for (const pair of pairs) {
        const oldDevice = pair.device;
        const newDevice = pair.newDevice;

        const migration = {
            sn: pair.sn,
            oldAppId: pair.appId,
            oldId: oldDevice.id,
            newId: newDevice.id,
            oldUri: `homey:device:${oldDevice.id}`,
            newUri: `homey:device:${newDevice.id}`,
            newDriver: getDriver(newDevice),
            oldDevice,
            newDevice,
            imageMappings: new Map(),
        };

        migration.station = findNewStation(migration);

        discoverImageTokens(migration);
        resolvedMigrations.push(migration);

        console.log('');
        console.log(`  SN: ${pair.sn}   (matched on old data.${pair.field})`);
        console.log(`  OLD: "${oldDevice.name}" | ${oldDevice.id}`);
        console.log(`       ${oldDevice.driverId}`);
        console.log(`  NEW: "${newDevice.name}" | ${newDevice.id}`);
        console.log(`       ${newDevice.driverId}`);
        if (migration.oldAppId === 'com.eufylife.security') {
            console.log(migration.station.device
                ? `  Arming: station "${migration.station.device.name}" | ${migration.station.device.id}`
                : hasArming(newDevice)
                    ? '  Arming: on the camera itself (standalone)'
                    : `  Arming: no station, and no arming control on the camera (${migration.station.sn || 'no station_sn'})`);
        }
        console.log(`  Image token mappings: ${migration.imageMappings.size}`);
    }

    if (unmatchedOld.length) {
        console.log('');
        console.log('⚠️ OLD devices with no NEW device of the same serial (not migrated):');
        for (const { device, appId, sn } of unmatchedOld) {
            console.log(`  • [${appId}] "${device.name}" | ${sn} | ${device.id}`);
        }
    }

    if (ambiguous.length) {
        console.log('');
        console.log('⚠️ Ambiguous matches (not migrated):');
        for (const { sn, reason, oldDevices: olds, newDevices: news } of ambiguous) {
            console.log(`  • ${sn}: ${reason}`);
            for (const d of olds) console.log(`      OLD "${d.name}" | ${d.id}`);
            for (const d of news) console.log(`      NEW "${d.name}" | ${d.id}`);
        }
    }

    if (skipped.length) {
        console.log('');
        console.log('Skipped by skipSerials:');
        for (const { device, sn } of skipped) {
            console.log(`  • "${device.name}" | ${sn}`);
        }
    }

    if (oldWithoutSn.length || newWithoutSn.length) {
        console.log('');
        console.log('⚠️ Eufy devices without a readable serial (not migrated):');
        for (const { device, appId } of oldWithoutSn) {
            console.log(`  • [${appId}] "${device.name}" | ${device.id} | data: ${safeJson(device.data)}`);
        }
        for (const device of newWithoutSn) {
            console.log(`  • [${NEW_APP_ID}] "${device.name}" | ${device.id} | data: ${safeJson(device.data)}`);
        }
    }

    if (newOnly.length) {
        console.log('');
        console.log(`NEW-app devices with no OLD counterpart (nothing to do): ${newOnly.length}`);
    }
}


// ============================================================================
// IMAGE TOKEN MIGRATION
// ============================================================================

function imageTarget(migration, suffix, separator) {
    return /snapshot/i.test(suffix)
        ? `${migration.newUri}${separator}image-camera-snapshot`
        : `${migration.newUri}${separator}image-camera-event`;
}

function migrateImageToken(value, migration) {
    let result = value;

    // Exact known mappings
    for (const [oldToken, info] of migration.imageMappings.entries()) {
        if (!result.includes(oldToken)) {
            continue;
        }

        const target = info.type === 'snapshot'
            ? `${migration.newUri}|image-camera-snapshot`
            : `${migration.newUri}|image-camera-event`;

        result = replaceAll(result, oldToken, target);
    }

    // OLD UUID + old image token suffix
    const oldPipeRegex = new RegExp(`${escapeRegex(migration.oldUri)}\\|image-camera-([A-Za-z0-9_-]+)`, 'gi');
    result = result.replace(oldPipeRegex, (_, suffix) => imageTarget(migration, suffix, '|'));

    // Heal NEW UUID + old suffix from earlier migration attempts
    const newPipeRegex = new RegExp(`${escapeRegex(migration.newUri)}\\|image-camera-([A-Za-z0-9_-]+)`, 'gi');
    result = result.replace(newPipeRegex, (full, suffix) => {
        if (/^snapshot$/i.test(suffix) || /^event$/i.test(suffix)) {
            return full;
        }
        return imageTarget(migration, suffix, '|');
    });

    // Registry-style ":" token syntax
    const oldColonRegex = new RegExp(`${escapeRegex(migration.oldUri)}:image-camera-([A-Za-z0-9_-]+)`, 'gi');
    result = result.replace(oldColonRegex, (_, suffix) => imageTarget(migration, suffix, ':'));

    return result;
}


// ============================================================================
// GENERIC STRING MIGRATION (tokens, capabilities, device UUID)
// ============================================================================

function migrateString(value) {
    let result = value;

    for (const migration of resolvedMigrations) {

        if (!result.includes(migration.oldId) && !result.includes(migration.newId)) {
            continue;
        }

        // Image tokens first
        result = migrateImageToken(result, migration);

        // Capability / token references
        for (const [oldCapability, newCapability] of capabilityMappingEntries) {
            result = replaceAll(result, `${migration.oldUri}|${oldCapability}`, `${migration.newUri}|${newCapability}`);
            result = replaceAll(result, `${migration.oldUri}:${oldCapability}`, `${migration.newUri}:${newCapability}`);
        }

        // Generic device UUID replacement
        if (result.includes(migration.oldId)) {
            result = replaceAll(result, migration.oldId, migration.newId);
        }
    }

    return result;
}

function migrateDeep(value, path, changedPaths) {
    if (typeof value === 'string') {
        const migrated = migrateString(value);

        if (migrated !== value) {
            addChangedPath(changedPaths, path);
        }

        return migrated;
    }

    if (Array.isArray(value)) {
        for (let i = 0; i < value.length; i++) {
            value[i] = migrateDeep(value[i], `${path}[${i}]`, changedPaths);
        }
        return value;
    }

    if (value !== null && typeof value === 'object') {
        for (const key of Object.keys(value)) {
            value[key] = migrateDeep(value[key], `${path}.${key}`, changedPaths);
        }
    }

    return value;
}


// ============================================================================
// LEGACY REFERENCE CHECK
// ============================================================================

/**
 * Finds references to old-app capabilities that have no new equivalent,
 * anywhere in the card (capability cards, tokens in arguments, droptoken).
 */
function findLegacyOnlyReference(card) {
    const json = safeJson(card);

    for (const migration of resolvedMigrations) {
        const legacyOnly = LEGACY_ONLY_CAPABILITY[migration.oldAppId];

        if (!legacyOnly || !json.includes(migration.oldId)) {
            continue;
        }

        const refRegex = new RegExp(`${escapeRegex(migration.oldUri)}[|:]([A-Za-z0-9_]+)`, 'g');

        for (const [, name] of json.matchAll(refRegex)) {
            if (capabilityMappings[name] || flowCardMappings[name]) {
                continue;
            }

            if (legacyOnly.test(name) || legacyOnly.test(name.replace(/_(true|false|changed)$/, ''))) {
                return {
                    id: cardLabel(card),
                    suffix: name,
                    reason: `Uses old capability "${name}" that does not exist in ${NEW_APP_ID}. Rebuild this card by hand.`,
                };
            }
        }
    }

    return null;
}


// ============================================================================
// MIGRATE ONE FLOW CARD
// ============================================================================

function findCardMapping(card) {
    if (!card?.id) {
        return null;
    }

    for (const migration of resolvedMigrations) {
        const prefix = `${migration.oldUri}:`;

        if (card.id.startsWith(prefix)) {
            return { migration, suffix: card.id.substring(prefix.length) };
        }
    }

    return null;
}

/**
 * Applies a flowCardMappings entry to a card.
 * Answers { note } on success, { unmapped } when it can't be done safely.
 */
function applyCardMapping(card, migration, suffix, mapping) {
    const fail = reason => ({ unmapped: { id: card.id, suffix, reason } });

    let target = migration.newDevice;

    if (mapping.target === 'arming') {
        target = migration.station.device || (hasArming(migration.newDevice) ? migration.newDevice : null);

        if (!target) {
            return fail(
                `No NEW device has the guard mode for ${migration.station.sn || 'this device'}: no station ` +
                `device, and "${migration.newDevice.name}" has no arming control. Update ${NEW_APP_ID} and ` +
                'let the device connect once, or rebuild this card by hand.'
            );
        }

        if (!hasArming(target)) {
            return fail(`"${target.name}" has no arming control in ${NEW_APP_ID}. Rebuild this card by hand.`);
        }
    }

    const driver = getDriver(target);
    const beforeArgs = clone(card.args || {});
    let baseName;

    try {
        baseName = typeof mapping.card === 'function' ? mapping.card(beforeArgs) : mapping.card;
    } catch (err) {
        return fail(`Could not convert arguments ${safeJson(beforeArgs)}: ${err.message}`);
    }

    if (!mapping.drivers.includes(driver)) {
        return fail(`"${target.name}" is a ${driver} in ${NEW_APP_ID}, which has no "${baseName}" card. Rebuild this card by hand.`);
    }

    if (mapping.requires && Array.isArray(target.capabilities) && !target.capabilities.includes(mapping.requires)) {
        return fail(`"${target.name}" has no ${mapping.requires} capability in ${NEW_APP_ID}, so the card would not show. Rebuild this card by hand.`);
    }

    const homeyCard = mapping.homeyCard ? mapping.homeyCard(baseName) : false;
    const cardName = homeyCard ? baseName : `eufy_${driver}_${baseName}`;

    if (!homeyCard && availableCards && !availableCards.has(cardName)) {
        return fail(
            `The installed ${NEW_APP_ID} has no "${cardName}" card. Update ${NEW_APP_ID} and run again, ` +
            'or rebuild this card by hand.'
        );
    }

    let newArgs;
    let invert = false;

    try {
        newArgs = mapping.args ? mapping.args(beforeArgs) : undefined;
        invert = mapping.invert ? mapping.invert(beforeArgs) : false;
    } catch (err) {
        return fail(`Could not convert arguments ${safeJson(beforeArgs)}: ${err.message}`);
    }

    let argNote = null;

    if (newArgs && Object.prototype.hasOwnProperty.call(newArgs, 'note')) {
        argNote = newArgs.note;
        delete newArgs.note;
    }

    const oldCardId = card.id;
    card.id = `homey:device:${target.id}:${cardName}`;

    if (typeof card.uri === 'string') {
        card.uri = replaceAll(replaceAll(card.uri, oldCardId, card.id), migration.oldId, target.id);
    }
    if (typeof card.ownerUri === 'string') {
        card.ownerUri = replaceAll(card.ownerUri, migration.oldId, target.id);
    }

    if (newArgs !== undefined) {
        card.args = newArgs;
    }

    if (invert) {
        card.inverted = !card.inverted;
    }

    const parts = [];

    if (newArgs !== undefined && safeJson(beforeArgs) !== safeJson(newArgs)) {
        parts.push(`args ${safeJson(beforeArgs)} → ${safeJson(newArgs)}`);
    }
    if (invert) {
        parts.push('condition inverted (old card checked "off")');
    }
    if (argNote) {
        parts.push(argNote);
    }
    if (target !== migration.newDevice) {
        parts.push(`moved to "${target.name}"`);
    }

    return { note: parts.join('; ') || null, tokens: mapping.tokens || null };
}

function migrateCard(card) {
    const result = { changedPaths: null, unmapped: null, note: null, tokens: null };

    if (!card) {
        return result;
    }

    const changedPaths = [];
    const found = findCardMapping(card);

    if (found) {
        const { migration, suffix } = found;
        const mapping = flowCardMappings[suffix];

        if (mapping) {
            const applied = applyCardMapping(card, migration, suffix, mapping);

            if (applied.unmapped) {
                return { ...result, unmapped: applied.unmapped };
            }

            addChangedPath(changedPaths, '$.id');
            result.note = applied.note;
            result.tokens = applied.tokens;
        } else if (LEGACY_CUSTOM_CARD.test(suffix)) {
            return {
                ...result,
                unmapped: {
                    id: card.id,
                    suffix,
                    reason: UNMAPPED_REASONS[suffix]
                        || `No equivalent card in ${NEW_APP_ID} (old app: ${migration.oldAppId}). Rebuild this card by hand.`,
                },
            };
        }
    }

    const legacyOnly = findLegacyOnlyReference(card);

    if (legacyOnly) {
        return { ...result, unmapped: legacyOnly };
    }

    // Tokens, capability references and device UUIDs
    migrateDeep(card, '$', changedPaths);

    result.changedPaths = changedPaths.length ? changedPaths : null;

    return result;
}


// ============================================================================
// PROCESS ONE CARD
// ============================================================================

/**
 * ref: how other cards in the Flow refer to this card's tokens
 * ("trigger" in a regular Flow, the card key in an Advanced Flow).
 */
function processCard(card, type, ref, changes, unmapped, tokenRenames) {
    if (!card) {
        return;
    }

    const beforeId = cardLabel(card);
    const beforeDroptoken = card.droptoken;
    const result = migrateCard(card);

    if (result.unmapped) {
        unmapped.push({ type, ...result.unmapped });
        return;
    }

    if (!result.changedPaths) {
        return;
    }

    if (result.tokens) {
        for (const [from, to] of Object.entries(result.tokens)) {
            tokenRenames.push({ type, ref, from, to });
        }
    }

    changes.push({
        type,
        beforeId,
        afterId: cardLabel(card),
        beforeDroptoken,
        afterDroptoken: card.droptoken,
        paths: result.changedPaths,
        note: result.note,
    });
}

/**
 * Renames trigger tokens other cards use ("[[trigger::user]]" → "[[trigger::person]]").
 * Answers the unmapped entries for tokens the new card no longer provides.
 */
function applyTokenRenames(flow, tokenRenames, changes) {
    const unmapped = [];

    for (const { type, ref, from, to } of tokenRenames) {
        const oldRef = `${ref}::${from}`;
        const json = safeJson(flow);

        if (!json.includes(oldRef)) {
            continue;
        }

        if (to === null) {
            unmapped.push({
                type,
                id: oldRef,
                suffix: from,
                reason: `The Flow uses the "${from}" tag of this trigger, which the new card does not provide. Rebuild by hand.`,
            });
            continue;
        }

        const renamed = JSON.parse(replaceAll(json, oldRef, `${ref}::${to}`));

        for (const key of Object.keys(flow)) {
            delete flow[key];
        }
        Object.assign(flow, renamed);

        changes.push({
            type: `${type} tag`,
            beforeId: oldRef,
            afterId: `${ref}::${to}`,
            paths: ['tag references'],
            note: null,
        });
    }

    return unmapped;
}


// ============================================================================
// LOGGING
// ============================================================================

function printChanges(changes) {
    for (const change of changes) {
        console.log(`   • ${change.type}`);
        console.log(`       ${change.beforeId}`);

        if (change.beforeId !== change.afterId) {
            console.log(`         → ${change.afterId}`);
        }

        console.log(`       changed: ${change.paths.join(', ')}`);

        if (change.beforeDroptoken !== change.afterDroptoken) {
            console.log('       droptoken:');
            console.log(`         ${change.beforeDroptoken || '<none>'}`);
            console.log(`           → ${change.afterDroptoken || '<none>'}`);
        }

        if (change.note) {
            console.log(`       ${change.note}`);
        }
    }
}

function printUnmapped(unmapped) {
    for (const item of unmapped) {
        console.log(`   • ${item.type}: ${item.id}`);
        console.log(`     suffix: ${item.suffix}`);
        if (item.reason) {
            console.log(`     ${item.reason}`);
        }
    }
}


// ============================================================================
// SHARED FLOW LOOP
// ============================================================================

/**
 * Runs the migration over one kind of Flow.
 *
 * collectCards(flow) → [{ card, type, ref }]
 * save(flow)         → Promise, persists the migrated clone
 */
async function migrateFlows(label, flows, collectCards, save) {
    const stats = { matching: 0, updated: 0, wouldUpdate: 0, failed: 0, skipped: 0 };

    for (const originalFlow of Object.values(flows)) {
        const flow = clone(originalFlow);
        const changes = [];
        const unmapped = [];
        const tokenRenames = [];

        for (const { card, type, ref } of collectCards(flow)) {
            processCard(card, type, ref, changes, unmapped, tokenRenames);
        }

        if (!unmapped.length) {
            unmapped.push(...applyTokenRenames(flow, tokenRenames, changes));
        }

        if (!changes.length && !unmapped.length) {
            continue;
        }

        stats.matching++;

        // Unknown card => skip entire Flow
        if (unmapped.length) {
            stats.skipped++;
            console.log('');
            console.log(`⚠️ SKIPPED UNMAPPED${label}: "${getFlowName(flow)}"`);
            printUnmapped(unmapped);
            continue;
        }

        // Soft-run
        if (softRun) {
            stats.wouldUpdate++;
            console.log('');
            console.log(`🟡 WOULD UPDATE${label}: "${getFlowName(flow)}"`);
            printChanges(changes);
            continue;
        }

        // LIVE update
        try {
            await save(flow);
            stats.updated++;
            console.log('');
            console.log(`✅ UPDATED${label}: "${getFlowName(flow)}"`);
            if (detailedLog) {
                printChanges(changes);
            }
        } catch (err) {
            stats.failed++;
            console.log('');
            console.log(`❌ FAILED${label}: "${getFlowName(flow)}"`);
            console.log(`   ${err?.description || err?.message || err}`);
            if (detailedLog) {
                printChanges(changes);
            }
        }
    }

    return stats;
}


// ============================================================================
// REGULAR FLOWS
// ============================================================================

async function migrateRegularFlows() {
    console.log('');
    console.log('Loading regular flows...');

    const flows = await Homey.flow.getFlows();
    console.log(`Loaded ${Object.keys(flows).length} regular flows.`);

    return migrateFlows(
        '',
        flows,
        flow => [
            { card: flow.trigger, type: 'Trigger', ref: 'trigger' },
            ...(Array.isArray(flow.conditions) ? flow.conditions : []).map(card => ({ card, type: 'Condition', ref: null })),
            ...(Array.isArray(flow.actions) ? flow.actions : []).map(card => ({ card, type: 'Action', ref: null })),
        ],
        flow => Homey.flow.updateFlow({ id: flow.id, flow })
    );
}


// ============================================================================
// ADVANCED FLOWS
// ============================================================================

async function migrateAdvancedFlows() {
    console.log('');
    console.log('Loading Advanced Flows...');

    const flows = await Homey.flow.getAdvancedFlows();
    console.log(`Loaded ${Object.keys(flows).length} advanced flows.`);

    return migrateFlows(
        ' ADVANCED',
        flows,
        flow => Object.entries(flow.cards || {}).map(([cardId, card]) => ({ card, type: `Card ${cardId}`, ref: cardId })),
        flow => Homey.flow.updateAdvancedFlow({ id: flow.id, advancedflow: { cards: flow.cards } })
    );
}


// ============================================================================
// FINAL OLD-DEVICE VERIFICATION
// ============================================================================

function findOldReferences(flows) {
    const remaining = [];

    for (const flow of Object.values(flows)) {
        const json = safeJson(flow);
        const found = allOldDevices.filter(entry => json.includes(entry.device.id));

        if (found.length) {
            remaining.push({ name: getFlowName(flow), id: flow.id, found });
        }
    }

    return remaining;
}

function printOldReferences(title, remaining) {
    if (!remaining.length) {
        return;
    }

    console.log('');
    console.log(title);

    for (const item of remaining) {
        console.log(`  • "${item.name}"`);
        console.log(`    ${item.id}`);
        for (const { device, appId, paired } of item.found) {
            console.log(`    OLD "${device.name}" [${appId}] ${device.id}${paired ? '' : '  (NOT PAIRED)'}`);
        }
    }
}

async function verifyRemainingOldReferences() {
    console.log('');
    console.log('============================================================');
    console.log('FINAL OLD EUFY REFERENCE SCAN');
    console.log('============================================================');

    const regularRemaining = findOldReferences(await Homey.flow.getFlows());
    const advancedRemaining = findOldReferences(await Homey.flow.getAdvancedFlows());

    console.log(`Regular Flows with OLD Eufy references : ${regularRemaining.length}`);
    console.log(`Advanced Flows with OLD Eufy references: ${advancedRemaining.length}`);

    printOldReferences('REGULAR FLOWS STILL REFERENCING OLD EUFY DEVICES:', regularRemaining);
    printOldReferences('ADVANCED FLOWS STILL REFERENCING OLD EUFY DEVICES:', advancedRemaining);

    if (!regularRemaining.length && !advancedRemaining.length) {
        console.log('');
        console.log('✅ No references to OLD Eufy devices remain.');
    }

    return { regular: regularRemaining, advanced: advancedRemaining };
}


// ============================================================================
// MAIN
// ============================================================================

function printStats(title, stats) {
    console.log('');
    console.log(`${title}:`);
    console.log(`  Matching flows       : ${stats.matching}`);
    console.log(`  Updated              : ${stats.updated}`);
    console.log(`  Would update         : ${stats.wouldUpdate}`);
    console.log(`  Failed               : ${stats.failed}`);
    console.log(`  Skipped unmapped     : ${stats.skipped}`);
}

async function main() {
    console.log('');
    console.log('▶️ Starting Eufy migration script v4.6');
    console.log('');
    console.log(`    Old apps              = ${[...OLD_APP_IDS].join(', ')}`);
    console.log(`    New app               = ${NEW_APP_ID}`);
    console.log(`    Soft-run (diagnostic) = ${softRun}`);
    console.log(`    Detailed log          = ${detailedLog}`);
    console.log(`    Default alarm         = ${defaultAlarmSeconds}s`);

    await loadFlowTokens();
    await loadAvailableCards();
    await prepareMigrations();

    if (!resolvedMigrations.length) {
        throw new Error('No OLD/NEW device pairs could be matched by serial. Pair your devices in the new app first.');
    }

    const regular = await migrateRegularFlows();
    const advanced = await migrateAdvancedFlows();

    console.log('');
    console.log('============================================================');
    console.log('SUMMARY');
    console.log('============================================================');

    console.log('');
    console.log(`Device pairs migrated: ${resolvedMigrations.length}`);

    printStats('Regular Flows', regular);
    printStats('Advanced Flows', advanced);

    console.log('');
    console.log(`Soft-run: ${softRun}`);

    await verifyRemainingOldReferences();

    const unpaired = allOldDevices.filter(entry => !entry.paired);

    if (unpaired.length) {
        console.log('');
        console.log(
            `⚠️ ${unpaired.length} OLD device(s) were not paired and not migrated. ` +
            'Pair them in the new app (or check the warnings above) and run again.'
        );
    }

    console.log('');
    console.log(softRun
        ? 'Diagnostic run completed. Nothing was saved. Set softRun = false to apply.'
        : 'Migration run completed.');
}


// ============================================================================
// START
// ============================================================================

main().catch(err => {
    console.log('');
    console.log('❌ Migration script failed:');
    console.log(err?.stack || err?.message || err);
});

Independent and unofficial. Not affiliated with, endorsed by, or sponsored by Anker Innovations or eufy.