# WD DRIVE Klipper extension module: motor control parameter write/read
# (CANopen object dictionary pass-through).
#
# Documentation: https://www.wd-drive.com/en/docs/modes/klipper
#
# MCU commands (write / read):
#   wdd_set oid=%c index=%u sub_index=%u value=%u
#   wdd_get oid=%c index=%u sub_index=%u
#     -> response wdd_value oid=%c index=%u sub_index=%u value=%u status=%c
#        status: 0 = ok, 1 = index does not exist, 2 = sub-index unavailable or
#                not readable, 3 = entry length exceeds 4 bytes
#
# Control-loop tuning (position / velocity / current):
#   <loop>_bandwidth set       -> <loop>_tuning_mode = 1, the bandwidth is sent
#                                 (kp / ki are ignored)
#   <loop>_bandwidth not set   -> <loop>_tuning_mode = 0, <loop>_kp is required;
#                                 velocity / current also require <loop>_ki
#                                 (position has no integral term, only position_kp)
#   Set either the bandwidth or kp / ki. Missing values raise a config error.
#
# Write semantics: every option explicitly present in the config is sent; the
# homing-on-block parameters (homing_*) have defaults and are always sent.
# 0x2130 all-zero is a degenerate configuration: CiA-402 homing refuses to start
# and Klipper no longer reports "homed", so the homing move ends with
# "No trigger ... after full movement". Other options keep the firmware default
# when they are not configured.
#
# Example:
#   [wdd stepper_x]
#   current_bandwidth: 1000        # -> tuning_mode = 1, wdd_set index=0x2112 sub_index=0x01 value=1000
#   velocity_kp: 1.2               # no velocity_bandwidth -> tuning_mode = 0
#   velocity_ki: 0.05              #   kp / ki must both be present
#   position_kp: 0.8               # position only needs kp
#   inertia: 320                   # legacy alias for the load model, sent to both
#                                  #   0x2211 (stp) and 0x2111 (lin); the firmware
#                                  #   skips the index that does not exist
#   position_velocity_feed_forward: 0.5   # -> wdd_set index=0x60FB sub_index=0x02 value=500
#   over_voltage_fault: 54         # -> wdd_set index=0x2001 sub_index=0x01 value=540 (0.1 V)
#   motor_over_temp_fault: 85      # -> wdd_set index=0x2003 sub_index=0x01 value=850 (0.1 C)
#   homing_force_limit: 1.0        # -> wdd_set index=0x2130 sub_index=0x01 value=1000 (mA)
#   homing_current_threshold: 0.7  # -> wdd_set index=0x2130 sub_index=0x02 value=700 (mA)
#   homing_following_error: 2.0    # -> wdd_set index=0x2130 sub_index=0x03 value=2.0 * steps_per_mm
#   motor_temperature: True        # optional: enable the OD 0x2000:0C motor
#                                  #   temperature sensor
#                                  #   (optional motor_temperature_gcode_id: T0 -> shown by M105)
#
# Runtime adjustment (no restart required):
#   WDD_SET STEPPER=stepper_x PARAM=current_bandwidth VALUE=800
#   WDD_SET STEPPER=stepper_x INDEX=0x2112 SUB_INDEX=1 VALUE=800
#
# Runtime read:
#   WDD_GET STEPPER=stepper_x PARAM=current_bandwidth    # physical value via the table scale
#   WDD_GET STEPPER=stepper_x INDEX=0x2112 SUB_INDEX=1   # raw object dictionary value

import logging

import mcu

# Object dictionary parameter table.
#
#   key:   Klipper config option name (lower case)
#   value: (index, sub_index, od_size_bytes, scale, note)
#     scale: the value sent is int(round(config_value * scale)); it may be a
#            callable(self) for values that depend on instance attributes such
#            as steps_per_mm
OD_PARAM_TABLE = {
    # --- Current loop: 0x2112 bandwidth parameters / 0x60F6 control parameter set ---
    'current_bandwidth':    (0x2112, 0x01, 2, 1,    'currentBandwidth'),
    'current_tuning_mode':  (0x2112, 0x02, 1, 1,    'tuningMode (1=bandwidth mode, 0=kp/ki)'),
    'current_kp':           (0x60F6, 0x01, 4, 1000, 'currentControllerP_gain (x1000)'),
    'current_ki':           (0x60F6, 0x02, 4, 1000, 'currentControllerI_gain (x1000)'),

    # --- Velocity loop: 0x2113 bandwidth parameters / 0x60F9 control parameter set ---
    'velocity_bandwidth':   (0x2113, 0x01, 2, 1,    'velocityBandwidth'),
    'velocity_tuning_mode': (0x2113, 0x02, 1, 1,    'tuningMode'),
    'velocity_kp':          (0x60F9, 0x01, 4, 1000, 'velocityControllerP_gain (x1000)'),
    'velocity_ki':          (0x60F9, 0x02, 4, 1000, 'velocityControllerI_gain (x1000)'),

    # --- Velocity-loop feed-forward: 0x60F9:04/05 (velocity mode, CiA-402 PV/HM) ---
    'velocity_feed_forward':      (0x60F9, 0x04, 4, 1000,
                                   'velocityFeed_forward (x1000, feedforward torque = command velocity x coefficient, 0=disabled)'),
    'velocity_accel_feed_forward': (0x60F9, 0x05, 4, 1000,
                                   'accelerationFeed_forward (x1000, feedforward torque = acceleration x J x coefficient; lin acceleration unit mm, firmware scales x0.001 to m)'),

    # --- Position loop: 0x2114 bandwidth parameters / 0x60FB control parameter set ---
    'position_bandwidth':   (0x2114, 0x01, 2, 1,    'positionBandwidth'),
    'position_tuning_mode': (0x2114, 0x02, 1, 1,    'tuningMode'),
    'position_kp':          (0x60FB, 0x01, 4, 1000, 'positionControllerP_gain (x1000)'),
    'position_velocity_feed_forward':     (0x60FB, 0x02, 4, 1000,
                                           'velocityFeed_forward (x1000, position-mode velocity setpoint feedforward ratio, 1.0=pass-through)'),
    'position_acceleration_feed_forward': (0x60FB, 0x03, 4, 1000,
                                           'accelerationFeed_forward (x1000, feedforward torque = acceleration x J x coefficient; lin acceleration unit mm, firmware scales x0.001 to m,'
                                           ' set 0.7 -> OD 700 -> actual 0.0007)'),
    # 0x60F9 and 0x60FB feed-forward are split by control mode: position modes
    # (CSP/PP/IP/HM/Klipper) use 0x60FB, velocity mode (PV) uses 0x60F9.

    # --- Voltage protection: 0x2001 (OD stores 0.1 V, filter in ms) ---
    'over_voltage_fault':       (0x2001, 0x01, 2, 10,   'over_voltageFaultThreshold (V)'),
    'over_voltage_warning':     (0x2001, 0x02, 2, 10,   'over_voltageWarningThreshold (V)'),
    'under_voltage_fault':      (0x2001, 0x03, 2, 10,   'under_voltageFaultThreshold (V)'),
    'under_voltage_warning':    (0x2001, 0x04, 2, 10,   'under_voltageWarningThreshold (V)'),
    'voltage_filter_time':      (0x2001, 0x05, 2, 1,    'voltageFilterTime (ms)'),

    # --- Current and overload protection: 0x2002 ---
    'short_circuit_threshold':  (0x2002, 0x01, 4, 1000, 'shortCircuitThreshold (A, stored as mA in OD)'),
    'over_current_filter_time': (0x2002, 0x02, 2, 1,    'over_currentFilterTime (ms)'),
    'i2t_overload_level':       (0x2002, 0x03, 2, 1,    'I2TOverloadLevel (%, warning level)'),
    'i2t_max_time':             (0x2002, 0x04, 2, 1,    'I2TMaximumTime (ms, full-bucket time)'),

    # --- Motor temperature protection: 0x2003 (OD stores 0.1 C) ---
    'motor_over_temp_fault':    (0x2003, 0x01, 2, 10,   'motorOver_tempFaultThreshold (°C)'),
    'motor_over_temp_warning':  (0x2003, 0x02, 2, 10,   'motorOver_tempWarningThreshold (°C)'),

    # --- Homing on block: 0x2130 ---
    # The controller checks phase current (0x2130:02) and following error
    # (0x2130:03); both must hold for 0x2130:04 before the block is detected.
    'homing_force_limit':       (0x2130, 0x01, 2, 1000,
                                 'hardStopForceLimit (A, stored as mA in OD; 0=use 0x6072 torque limit)'),
    'homing_current_threshold': (0x2130, 0x02, 2, 1000,
                                 'collisionCurrentThreshold (A, stored as mA in OD; 0=condition not used)'),
    'homing_following_error':   (0x2130, 0x03, 4, lambda self: self.steps_per_mm,
                                 'followingErrorThreshold (mm, x steps_per_mm; 0=condition not used)'),
    'homing_filter_time':       (0x2130, 0x04, 2, 1,
                                 'collisionFilterTime (ms; both conditions must hold for this long)'),
    'homing_retract_distance':  (0x2130, 0x05, 4, lambda self: self.steps_per_mm,
                                 'retractDistance (mm, x steps_per_mm; auto-retract only for CiA-402 homing, klipper retract is done by klippy)'),

    # --- Load model (product specific, mutually exclusive: stp has no 0x2111, lin has no 0x2211) ---
    'load_inertia_ratio':     (0x2211, 0x00, 4, 1000,
                               'x2211 loadInertiaRatio (stp rotary motor, set 0.32 -> OD 320)'),
    'load_mass':              (0x2111, 0x00, 4, 1000,
                               'x2111 loadMass (lin linear motor, kg)'),

    # --- Other motor control options (optional; the firmware default is kept when not configured) ---
    'encoder_pll_bandwidth': (0x2115, 0x01, 2, 1,   'encoderPLLBandwidth'),
    'max_profile_velocity':  (0x607F, 0x00, 4, 1,   'maxProfileVelocity'),
    'max_motor_speed':       (0x6080, 0x00, 4, 1,   'maxMotorSpeed'),
}

# Defaults for the homing-on-block parameters (always sent). They are only a
# starting point and must be tuned on the actual motor and machine; with 0x2130
# all-zero the firmware does not report homed (klippy reports "No trigger").
DEFAULTS = {
    'homing_force_limit': 1.0,        # A (OD stores mA)
    'homing_current_threshold': 0.7,  # A (OD stores mA)
    'homing_following_error': 2.0,    # mm (x steps_per_mm -> user units)
    'homing_filter_time': 50.0,       # ms
    'homing_retract_distance': 3.0,   # mm (used by CiA-402 retract; Klipper does not read it)
}

# Removed homing options: fail loudly instead of silently ignoring them.
LEGACY_HOMING_KEYS = ('home_window', 'home_velocity_threshold')

# The three control loops: name -> whether the loop has a ki gain
# (position has no integral term).
CONTROL_LOOPS = (('position', False), ('velocity', True), ('current', True))

# Legacy `inertia` option -> load model candidates. The firmware skips indices
# that do not exist in this product; a value of 0 is not sent.
INERTIA_ALIAS = ('load_inertia_ratio', 'load_mass')

WDD_SET_CMD = "wdd_set oid=%c index=%u sub_index=%u value=%u"
WDD_GET_CMD = "wdd_get oid=%c index=%u sub_index=%u"
WDD_VALUE_CMD = "wdd_value oid=%c index=%u sub_index=%u value=%u status=%c"

# wdd_get response status codes.
WDD_GET_OK, WDD_GET_NO_INDEX, WDD_GET_NO_SUB, WDD_GET_BAD_LEN = range(4)
WDD_GET_STATUS_MSG = {
    WDD_GET_NO_INDEX: "OD index does not exist (not defined in this product, e.g. stp:0x2211 / lin:0x2111)",
    WDD_GET_NO_SUB:   "OD sub-index unavailable or not readable",
    WDD_GET_BAD_LEN:  "OD entry length out of range (must be 1..4 bytes)",
}

# Motor temperature (OD 0x2000:0C, INT16, 0.1 C). The firmware does the NTC
# lookup and filtering, and falls back to the MCU junction temperature on
# failure. Address and scale are fixed to avoid misconfiguration.
TEMP_INDEX, TEMP_SUB_INDEX, TEMP_SCALE = 0x2000, 0x0C, 0.1

# Poll period and timeout for a single response. Normal responses take
# milliseconds; an occasional bus or host scheduling jitter can make one reply
# time out, so leave some margin. Consecutive failures must reach
# TEMP_WARN_AFTER before a warning is logged; earlier single timeouts are only
# logged at debug level.
TEMP_POLL_TIME, TEMP_TIMEOUT = 0.3, 2.0
TEMP_WARN_AFTER = 3


def decode_motor_temperature(raw_value, scale=TEMP_SCALE):
    """Decode the zero-extended u32 returned by wdd_get as a signed 16-bit
    temperature in degrees Celsius."""
    v = raw_value & 0xFFFF
    if v >= 0x8000:
        v -= 0x10000
    return v * scale


def register_mcu_response(mcu_obj, callback, msgformat, oid):
    """Register an async MCU response handler, compatible with old and new Klipper.

    Newer Klipper provides ``MCU.register_serial_response(cb, msgformat, oid)``,
    which accepts the full message format and defers registration until the MCU
    data dictionary is ready. Older Klipper only has
    ``MCU.register_response(cb, name, oid)``, which takes the bare message name
    and registers immediately. A remote Klipper may still be on the old
    baseline, so try the new API first.
    """
    reg = getattr(mcu_obj, 'register_serial_response', None)
    if reg is not None:
        return reg(callback, msgformat, oid=oid)
    return mcu_obj.register_response(callback, msgformat.split()[0], oid=oid)


class MotorTemperature:
    """Klipper temperature sensor that periodically polls OD 0x2000:0C.

    It uses its own oid for the async wdd_get / wdd_value response handler:
    the WDD_GET gcode command temporarily registers and unregisters a handler
    for the same (message, oid) pair, so sharing an oid would clear it. Polling
    uses a non-query send() and resends on timeout, so it never blocks the
    reactor.
    """

    def __init__(self, printer, name, mcu_obj):
        self.printer = printer
        self.name = name
        self.mcu = mcu_obj
        self.logger = logging.getLogger('klipper.wdd')
        self._reactor = printer.get_reactor()
        self._callback = None
        self.min_temp = self.max_temp = 0.
        self.last_temp = 0.
        self._oid = mcu_obj.create_oid()
        # The command object must be looked up after klippy:connect: during
        # configuration (__init__) the MCU data dictionary is not loaded yet,
        # so lookup_command would fail with "Unknown command: wdd_get".
        self._cmd = None
        self._handler = register_mcu_response(
            mcu_obj, self._handle_response, WDD_VALUE_CMD, self._oid)
        self._timer = None
        self._pending = False
        self._pending_time = 0.
        self._fail_count = 0
        printer.register_event_handler("klippy:connect", self._handle_connect)
        printer.register_event_handler("klippy:disconnect",
                                       self._handle_disconnect)

    # --- Klipper temperature sensor interface ---
    def setup_minmax(self, min_temp, max_temp):
        # Over-temperature protection is handled by the firmware (0x2003),
        # so the host does not enforce a range.
        self.min_temp, self.max_temp = min_temp, max_temp

    def setup_callback(self, callback):
        self._callback = callback

    def get_report_time_delta(self):
        return TEMP_POLL_TIME

    def get_temp(self, eventtime):
        return self.last_temp, 0.

    def get_status(self, eventtime):
        return {'temperature': round(self.last_temp, 2)}

    # --- Polling ---
    def _handle_connect(self):
        # The MCU data dictionary is ready here.
        if self._cmd is None:
            self._cmd = self.mcu.lookup_command(WDD_GET_CMD)
        self._timer = self._reactor.register_timer(
            self._poll, self._reactor.monotonic() + TEMP_POLL_TIME)

    def _handle_disconnect(self):
        if self._timer is not None:
            self._reactor.unregister_timer(self._timer)
            self._timer = None

    def _poll(self, eventtime):
        if self._pending and eventtime - self._pending_time >= TEMP_TIMEOUT:
            self._pending = False
            self._note_failure("poll timeout")
        if not self._pending:
            self._cmd.send([self._oid, TEMP_INDEX, TEMP_SUB_INDEX])
            self._pending = True
            self._pending_time = eventtime
        return eventtime + TEMP_POLL_TIME

    def _note_failure(self, reason):
        self._fail_count += 1
        if self._fail_count < TEMP_WARN_AFTER:
            # An occasional single timeout (busy bus / host scheduling delay):
            # keep the last value without bothering the user.
            self.logger.debug(
                "wdd %s: motor temperature read timeout (%s), keeping last value %.2f",
                self.name, reason, self.last_temp)
        elif self._fail_count % TEMP_WARN_AFTER == 0:
            # Only warn on sustained failures, and rate-limit the message.
            self.logger.warning(
                "wdd %s: motor temperature read failed %d times in a row (%s), keeping last value %.2f",
                self.name, self._fail_count, reason, self.last_temp)

    def _handle_response(self, params):
        self._pending = False
        status = params.get('status', WDD_GET_OK)
        if status != WDD_GET_OK:
            reason = WDD_GET_STATUS_MSG.get(status, "unknown status %d" % status)
            self._note_failure(reason)
            return
        self._fail_count = 0
        self.last_temp = decode_motor_temperature(params['value'])
        if self._callback is not None:
            self._callback(params['#receive_time'], self.last_temp)


class _SensorRegistrationConfig:
    """Config proxy for heaters.register_sensor() that overrides get_name().

    register_sensor() records ``config.get_name()`` in
    heaters.available_sensors, which Moonraker uses as the temperature_store
    key (Fluidd reads history from it), while Fluidd names its graph series
    after the printer object name. If the two differ the graph shows a series
    with no data. This proxy keeps the registered name and the object name in
    sync.
    """

    def __init__(self, config, name):
        self._config = config
        self._name = name

    def get_name(self):
        return self._name

    def __getattr__(self, item):
        # Everything else is forwarded to the original config.
        return getattr(self._config, item)


class WDD:
    def __init__(self, config):
        self.printer = config.get_printer()
        self.logger = logging.getLogger('klipper.wdd')

        # Instance name, e.g. "stepper_x".
        self.name = config.get_name().split()[-1]

        stepper_section = config.getsection(self.name)

        # Microsteps and travel per revolution -> steps per mm.
        self.microsteps = stepper_section.getint('microsteps')
        self.rotation_distance = stepper_section.getfloat('rotation_distance')
        self.steps_per_mm = (self.microsteps * 200.0) / self.rotation_distance

        # Stepper MCU, taken from the step_pin prefix.
        step_pin = stepper_section.get('step_pin')
        mcu_name = step_pin.split(':', 1)[0] if ':' in step_pin else 'mcu'
        self.mcu = mcu.get_printer_mcu(self.printer, mcu_name)
        self.oid = self.mcu.create_oid()

        # Parse the OD parameters. This needs steps_per_mm for the homing_*
        # user-unit scaling, so it runs after the stepper is read above.
        self.od_params = self._read_od_params(config)

        self.cmd_wdd_set = None
        self.cmd_wdd_get = None  # command object is created in _build_config

        # Motor temperature sensor (optional). It must be read unconditionally
        # in __init__, otherwise Klipper's check_unused_options reports an
        # unused config option.
        self.motor_temperature_enabled = config.getboolean('motor_temperature',
                                                           False)
        self.motor_temperature_gcode_id = config.get(
            'motor_temperature_gcode_id', None)
        self.motor_temp_sensor = None
        if self.motor_temperature_enabled:
            # Pass the short name (self.name): the log template already adds the
            # "wdd " prefix.
            self.motor_temp_sensor = MotorTemperature(
                self.printer, self.name, self.mcu)
            # Fluidd only recognises printer objects whose name starts with
            # "temperature_sensor ", so register the sensor under that prefix as
            # well. The registered name and the printer object name must match
            # so Moonraker and Fluidd resolve the same series.
            sensor_name = "temperature_sensor wdd " + self.name
            self.printer.add_object(sensor_name, self.motor_temp_sensor)
            heaters = self.printer.load_object(config, 'heaters')
            heaters.register_sensor(
                _SensorRegistrationConfig(config, sensor_name),
                self.motor_temp_sensor,
                gcode_id=self.motor_temperature_gcode_id)

        # Register the config command.
        self.mcu.register_config_callback(self._build_config)

        # Runtime adjust / read commands.
        gcode = self.printer.lookup_object('gcode')
        gcode.register_mux_command('WDD_SET', 'STEPPER', self.name,
                                   self.cmd_WDD_SET, desc=self.cmd_WDD_SET_help)
        gcode.register_mux_command('WDD_GET', 'STEPPER', self.name,
                                   self.cmd_WDD_GET, desc=self.cmd_WDD_GET_help)

    # ------------------------------------------------------------------
    # OD parameter parsing: config option -> (index, sub_index, value)
    # ------------------------------------------------------------------
    def _pack(self, config, key, val, scale, size, index, sub_index):
        """Scale and range-check a value, returning the integer to send."""
        value = int(round(val * scale))
        limit = (1 << (8 * size)) - 1
        if not 0 <= value <= limit:
            raise config.error("[wdd %s] %s=%d out of OD u%d range (0x%04X.%02X)"
                               % (self.name, key, value, 8 * size, index, sub_index))
        return value

    def _read_od_params(self, config):
        params = {}
        skipped = set()  # loop gains ignored in bandwidth mode

        for legacy in LEGACY_HOMING_KEYS:
            if config.get(legacy, default=None) is not None:
                raise config.error(
                    "[wdd %s] %s is deprecated: firmware now uses 0x2130 homing-on-block; "
                    "use homing_force_limit / homing_current_threshold / "
                    "homing_following_error / homing_filter_time"
                    % (self.name, legacy))

        def put(key, val):
            index, sub_index, size, scale, note = OD_PARAM_TABLE[key]
            sc = scale(self) if callable(scale) else scale
            value = self._pack(config, key, val, sc, size, index, sub_index)
            params[key] = (index, sub_index, value)
            self.logger.info("wdd %s: %s -> OD 0x%04X.%02X = %d (%s)",
                             self.name, key, index, sub_index, value, note)

        # --- Control loops: bandwidth -> tuning mode 1, otherwise mode 0 + kp/ki ---
        for loop, has_ki in CONTROL_LOOPS:
            bw_key, kp_key, ki_key = loop + '_bandwidth', loop + '_kp', loop + '_ki'
            mode_key = loop + '_tuning_mode'
            bw = config.getfloat(bw_key, default=None, minval=0.)
            if bw is not None:
                # Bandwidth mode: kp / ki are ignored (and not processed by the
                # generic loop below).
                skipped.update((kp_key, ki_key))
                put(mode_key, 1)
                put(bw_key, bw)
                continue
            # kp / ki mode: kp is required; velocity and current also require ki.
            kp = config.getfloat(kp_key, default=None, minval=0.)
            ki = config.getfloat(ki_key, default=None, minval=0.) if has_ki else 0.0
            if kp is None or (has_ki and ki is None):
                need = kp_key + (" and " + ki_key if has_ki else "")
                raise config.error(
                    "[wdd %s] missing %s: %s mode requires %s (choose either bandwidth or kp/ki)"
                    % (self.name, bw_key, loop, need))
            put(mode_key, 0)
            put(kp_key, kp)
            if has_ki:
                put(ki_key, ki)

        # --- Legacy alias: inertia -> load model candidates ---
        # The firmware skips indices absent from this product. An explicitly
        # configured new name takes precedence.
        inertia = config.getfloat('inertia', default=0.0, minval=0.)
        alias_values = {}
        if inertia > 0:
            for key in INERTIA_ALIAS:
                if config.getfloat(key, default=None, minval=0.) is None:
                    alias_values[key] = inertia

        # --- Remaining parameters (protection / homing / load / PLL / speed limits) ---
        for key in OD_PARAM_TABLE:
            if key in params or key in skipped:
                continue
            if key in DEFAULTS:
                put(key, config.getfloat(key, default=DEFAULTS[key], minval=0.))
                continue
            # Optional: if not configured and no alias value, keep the firmware default.
            val = config.getfloat(key, default=None, minval=0.)
            if val is None:
                val = alias_values.get(key)
                if val is None:
                    continue
            put(key, val)
        return params

    def _build_config(self):
        # Configuration-phase command: only microsteps (the controller scale
        # factor, not an OD parameter).
        cmd = f"config_wdd oid={self.oid} microsteps={self.microsteps}"
        self.mcu.add_config_cmd(cmd)

        # Motor control OD parameters, sent one by one over wdd_set (covered by
        # the config CRC, so they are resent on reconnect).
        for key, (index, sub_index, value) in self.od_params.items():
            self.mcu.add_config_cmd(
                "wdd_set oid=%d index=%d sub_index=%d value=%d"
                % (self.oid, index, sub_index, value))

        # Command objects used by the runtime WDD_SET / WDD_GET commands.
        self.cmd_wdd_set = self.mcu.lookup_command(WDD_SET_CMD)
        self.cmd_wdd_get = self.mcu.lookup_query_command(
            WDD_GET_CMD, WDD_VALUE_CMD, oid=self.oid)

    cmd_WDD_SET_help = "Set motor control OD parameter (wdd_set)"

    @staticmethod
    def _int_any_base(gcmd, name, minval=0, maxval=None):
        """klippy's get_int only accepts decimal; OD addresses and raw values
        also accept 0x hex."""
        s = str(gcmd.get(name)).strip()
        try:
            val = int(s, 0)
        except ValueError:
            try:
                val = int(s, 10)  # accept leading-zero decimal (int(s, 0) rejects '012')
            except ValueError:
                raise gcmd.error("cannot parse %s='%s' (decimal or 0x hex accepted)" % (name, s))
        if val < minval or (maxval is not None and val > maxval):
            raise gcmd.error("%s=%d out of range [%d, %s]" % (name, val, minval, "inf" if maxval is None else maxval))
        return val

    def _resolve_od_param(self, gcmd):
        """Shared by WDD_SET / WDD_GET: look up PARAM in the table, or address
        INDEX / SUB_INDEX directly. Returns (key, index, sub_index, size, scale,
        note); key and note are None in INDEX mode."""
        param = gcmd.get('PARAM', None)
        if param is not None:
            key = param.lower()
            if key not in OD_PARAM_TABLE:
                raise gcmd.error("unknown parameter '%s', valid: %s" % (key, ', '.join(OD_PARAM_TABLE)))
            index, sub_index, size, scale, note = OD_PARAM_TABLE[key]
            return key, index, sub_index, size, scale, note
        index = self._int_any_base(gcmd, 'INDEX', minval=0, maxval=0xFFFF)
        sub_index = self._int_any_base(gcmd, 'SUB_INDEX', minval=0, maxval=0xFF)
        return None, index, sub_index, 4, 1, None

    cmd_WDD_GET_help = "Read current motor control OD parameter value (wdd_get)"

    def cmd_WDD_GET(self, gcmd):
        key, index, sub_index, size, scale, note = self._resolve_od_param(gcmd)
        params = self.cmd_wdd_get.send([self.oid, index, sub_index])
        status = params.get('status', WDD_GET_OK)
        if status != WDD_GET_OK:
            reason = WDD_GET_STATUS_MSG.get(status, "unknown status %d" % status)
            raise gcmd.error("WDD_GET 0x%04X.%02X failed: %s" % (index, sub_index, reason))
        raw = params['value']
        self.logger.info("WDD_GET %s: index=0x%04X sub_index=%d value=%d",
                         self.name, index, sub_index, raw)
        if key is None:
            gcmd.respond_info("wdd %s: 0x%04X.%02X = %d" % (self.name, index, sub_index, raw))
            return
        # PARAM mode: convert back to a physical value with the table scale
        # (symmetric with the int(round(v * scale)) sent on write).
        sc = scale(self) if callable(scale) else scale
        note = note or key
        if sc == 1:
            gcmd.respond_info("wdd %s: %s 0x%04X.%02X = %d (%s)"
                              % (self.name, key, index, sub_index, raw, note))
        else:
            physical = ('%.6f' % (raw / sc)).rstrip('0').rstrip('.')
            gcmd.respond_info("wdd %s: %s 0x%04X.%02X = %d -> %s (%s)"
                              % (self.name, key, index, sub_index, raw, physical, note))

    def cmd_WDD_SET(self, gcmd):
        key, index, sub_index, size, scale, note = self._resolve_od_param(gcmd)
        if key is not None:
            val = gcmd.get_float('VALUE', minval=0.)
            sc = scale(self) if callable(scale) else scale
            value = int(round(val * sc))
        else:
            value = self._int_any_base(gcmd, 'VALUE', minval=0, maxval=(1 << (8 * size)) - 1)
        limit = (1 << (8 * size)) - 1
        if not 0 <= value <= limit:
            raise gcmd.error("VALUE=%d out of u%d range" % (value, 8 * size))
        self.logger.info("WDD_SET %s: index=0x%04X sub_index=%d value=%d",
                         self.name, index, sub_index, value)
        self.cmd_wdd_set.send([self.oid, index, sub_index, value])

    def get_temp(self, eventtime):
        """TEMPERATURE_WAIT SENSOR="wdd <stepper>" looks up this object and
        calls this method."""
        if self.motor_temp_sensor is None:
            return 0., 0.
        return self.motor_temp_sensor.get_temp(eventtime)

    def get_status(self, eventtime):
        status = {
            'name': self.name,
            'steps_per_mm': self.steps_per_mm,
        }
        for key, (index, sub_index, value) in self.od_params.items():
            status[key] = value
        if self.motor_temp_sensor is not None:
            # The registered sensor is this object, not MotorTemperature, so
            # expose the standard 'temperature' key for Klipper / Moonraker.
            temp = round(self.motor_temp_sensor.last_temp, 2)
            status['temperature'] = temp
            status['motor_temperature'] = temp
        return status


def load_config_prefix(config):
    return WDD(config)
