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

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", {
  state: () => ({
    devices: [],
    isLoading: false,
    error: null,
    isLoadingStates: false,
    stateError: null,
    stateByDeviceId: {},
    stateRunId: 0,
    rebootingIds: new Set(),
    _listAbortController: null,
    lastLoadedAt: null,
    currentDevice: null,
    currentDeviceStatus: null,
    isLoadingDetail: false,
    errorDetail: null,
    _detailAbortController: null,
  }),

  getters: {
    total(state) {
      return state.devices.length;
    },
    isRebooting: (state) => (id) => state.rebootingIds.has(id),
  },

  actions: {
    async loadDevices() {
      this._listAbortController?.abort();
      const controller = new AbortController();
      this._listAbortController = controller;

      this.isLoading = true;
      this.error = null;

      const result = await devicesApi.list({ signal: controller.signal });
      this._listAbortController = null;
      this.isLoading = false;

      if (!result.ok) {
        if (result.error?.type === "timeout") {
          return result;
        }
        this.error = result.error;
        return result;
      }

      this.devices = result.data?.data?.devices || [];
      this.lastLoadedAt = new Date().toISOString();
      return result;
    },

    setDeviceState(device, patch) {
      const deviceId = getDeviceId(device);

      if (!deviceId) {
        return;
      }

      this.stateByDeviceId = {
        ...this.stateByDeviceId,
        [deviceId]: makeDeviceStatePatch(device, patch),
      };
    },

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

      const devices = this.devices.slice();
      const targets = [];

      for (const device of devices) {
        if (device.connection_status === "lost") {
          this.setDeviceState(device, {
            status: "skipped",
            message: "Connection lost",
            connectionStatus: "lost",
          });
        } else {
          this.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 (this.stateRunId !== runId) {
            return;
          }

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

    async rebootDevice(id) {
      const deviceId = String(id);
      this.rebootingIds.add(deviceId);

      const result = await devicesApi.reboot(id);

      this.rebootingIds.delete(deviceId);
      return result;
    },

    async loadDeviceDetail(id) {
      this._detailAbortController?.abort();
      const controller = new AbortController();
      this._detailAbortController = controller;

      this.isLoadingDetail = true;
      this.errorDetail = null;
      this.currentDevice = null;
      this.currentDeviceStatus = null;

      const result = await devicesApi.detail(id);
      this._detailAbortController = null;
      this.isLoadingDetail = false;

      if (!result.ok) {
        if (result.error?.type === "timeout") {
          return result;
        }
        this.errorDetail = result.error;
        return result;
      }

      this.currentDevice = result.data?.data?.device || null;
      return result;
    },

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

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

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

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

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

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

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

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

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

    clearDeviceDetail() {
      this.currentDevice = null;
      this.currentDeviceStatus = null;
      this.errorDetail = null;
      this._detailAbortController?.abort();
      this._detailAbortController = null;
    },
  },
});