Newer
Older
smart-home-server / webclient-vue / src / stores / devices.js
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { devicesApi } from "../api/modules/devices";
import { useAsyncRequest } from "../composables/useAsyncRequest";

const DEFAULT_STATE_CONCURRENCY = 4;

function getDeviceId(device) {
  return String(device?.id || "");
}

function makeDeviceStatePatch(device, patch) {
  return {
    deviceId: getDeviceId(device),
    status: "idle",
    message: "",
    response: null,
    connectionStatus: device?.connection_status || "unknown",
    updatedAt: null,
    ...patch,
  };
}

function normalizeStatusSuccess(device, result) {
  const payload = result.data?.data?.device || {};
  const response = payload.device_response || {};

  return makeDeviceStatePatch(device, {
    status: "ready",
    message: response.status || "ok",
    response,
    connectionStatus: "active",
    updatedAt: new Date().toISOString(),
  });
}

function normalizeStatusError(device, result) {
  const raw = result.error?.raw || {};
  const connectionStatus =
    raw?.data?.connection_status || device?.connection_status || "unknown";

  return makeDeviceStatePatch(device, {
    status: "error",
    message: result.error?.message || "Device state is unavailable",
    response: raw,
    connectionStatus,
    updatedAt: new Date().toISOString(),
  });
}

async function runLimited(items, concurrency, worker) {
  let nextIndex = 0;
  const poolSize = Math.max(1, Math.min(concurrency, items.length));

  async function runWorker() {
    while (nextIndex < items.length) {
      const item = items[nextIndex];
      nextIndex += 1;
      await worker(item);
    }
  }

  await Promise.all(Array.from({ length: poolSize }, runWorker));
}

export const useDevicesStore = defineStore("devices", () => {
  const devices = ref([]);
  const stateByDeviceId = ref({});
  const stateRunId = ref(0);
  const rebootingIds = ref(new Set());
  const currentDevice = ref(null);
  const currentDeviceStatus = ref(null);
  const lastLoadedAt = ref(null);
  const isLoadingStates = ref(false);
  const stateError = ref(null);

  const listRequest = useAsyncRequest();
  const detailRequest = useAsyncRequest();

  const total = computed(() => devices.value.length);
  const isRebooting = computed(() => (id) => rebootingIds.value.has(String(id)));

  async function loadDevices() {
    return listRequest.execute(async (signal) => {
      const result = await devicesApi.list({ signal });
      if (result.ok) {
        devices.value = result.data?.data?.devices || [];
        lastLoadedAt.value = new Date().toISOString();
      }
      return result;
    });
  }

  function setDeviceState(device, patch) {
    const deviceId = getDeviceId(device);
    if (!deviceId) {
      return;
    }
    stateByDeviceId.value = {
      ...stateByDeviceId.value,
      [deviceId]: makeDeviceStatePatch(device, patch),
    };
  }

  async function loadDeviceStates(options = {}) {
    const runId = stateRunId.value + 1;
    stateRunId.value = runId;
    isLoadingStates.value = true;
    stateError.value = null;
    stateByDeviceId.value = {};

    const targets = [];

    for (const device of devices.value) {
      if (device.connection_status === "lost") {
        setDeviceState(device, {
          status: "skipped",
          message: "Connection lost",
          connectionStatus: "lost",
        });
      } else {
        setDeviceState(device, {
          status: "loading",
          message: "Loading",
        });
        targets.push(device);
      }
    }

    try {
      await runLimited(targets, options.concurrency || DEFAULT_STATE_CONCURRENCY, async (device) => {
        const result = await devicesApi.status(device.id);

        if (stateRunId.value !== runId) {
          return;
        }

        stateByDeviceId.value = {
          ...stateByDeviceId.value,
          [getDeviceId(device)]: result.ok
            ? normalizeStatusSuccess(device, result)
            : normalizeStatusError(device, result),
        };
      });
    } catch (error) {
      if (stateRunId.value === runId) {
        stateError.value = {
          type: "state_loader_error",
          message: error?.message || "Device states loader failed",
        };
      }
    } finally {
      if (stateRunId.value === runId) {
        isLoadingStates.value = false;
      }
    }
  }

  async function rebootDevice(id) {
    const deviceId = String(id);
    rebootingIds.value = new Set(rebootingIds.value).add(deviceId);

    const result = await devicesApi.reboot(id);

    const next = new Set(rebootingIds.value);
    next.delete(deviceId);
    rebootingIds.value = next;
    return result;
  }

  async function loadDeviceDetail(id) {
    return detailRequest.execute(async (signal) => {
      const result = await devicesApi.detail(id, { signal });
      if (result.ok) {
        currentDevice.value = result.data?.data?.device || null;
      }
      return result;
    });
  }

  async function loadDeviceStatus(id) {
    const result = await devicesApi.status(id);

    if (!result.ok) {
      currentDeviceStatus.value = {
        ok: false,
        error: result.error,
        channels: [],
      };
      return result;
    }

    const payload = result.data?.data?.device || {};
    const response = payload.device_response || {};
    currentDeviceStatus.value = {
      ok: true,
      channels: response.channels || [],
      raw: response,
    };
    return result;
  }

  async function updateDeviceName(id, name) {
    const result = await devicesApi.updateName(id, name);
    if (result.ok && currentDevice.value) {
      currentDevice.value = { ...currentDevice.value, name };
    }
    return result;
  }

  async function updateDeviceDescription(id, description) {
    const result = await devicesApi.updateDescription(id, description);
    if (result.ok && currentDevice.value) {
      currentDevice.value = { ...currentDevice.value, description };
    }
    return result;
  }

  async function updateDeviceAlias(id, newAlias) {
    const result = await devicesApi.updateAlias(id, newAlias);
    if (result.ok && currentDevice.value) {
      currentDevice.value = { ...currentDevice.value, alias: newAlias };
    }
    return result;
  }

  async function removeDevice(id) {
    return devicesApi.remove(id);
  }

  async function unassignDevice(id) {
    const result = await devicesApi.unassign(id);
    if (result.ok && currentDevice.value) {
      currentDevice.value = { ...currentDevice.value, area_id: null };
    }
    return result;
  }

  async function assignToArea(id, areaId) {
    const result = await devicesApi.placeInArea({ target_id: id, place_in_area_id: areaId });
    if (result.ok && currentDevice.value) {
      currentDevice.value = { ...currentDevice.value, area_id: areaId };
    }
    return result;
  }

  function clearDeviceDetail() {
    currentDevice.value = null;
    currentDeviceStatus.value = null;
    detailRequest.clear();
  }

  return {
    devices,
    isLoading: listRequest.isLoading,
    error: listRequest.error,
    isLoadingStates,
    stateError,
    stateByDeviceId,
    stateRunId,
    rebootingIds,
    lastLoadedAt,
    currentDevice,
    currentDeviceStatus,
    isLoadingDetail: detailRequest.isLoading,
    errorDetail: detailRequest.error,
    total,
    isRebooting,
    loadDevices,
    setDeviceState,
    loadDeviceStates,
    rebootDevice,
    loadDeviceDetail,
    loadDeviceStatus,
    updateDeviceName,
    updateDeviceDescription,
    updateDeviceAlias,
    removeDevice,
    unassignDevice,
    assignToArea,
    clearDeviceDetail,
  };
});