diff --git a/specs/001-modbus-relay-control/tasks.org b/specs/001-modbus-relay-control/tasks.org index b82d16e..826ab75 100644 --- a/specs/001-modbus-relay-control/tasks.org +++ b/specs/001-modbus-relay-control/tasks.org @@ -891,8 +891,8 @@ CLOSED: [2026-05-14 jeu. 20:09] - *File*: =src/presentation/api/relay_api.rs= - *Complexity*: Low | *Uncertainty*: Low -*** TODO Frontend Implementation [0/2] -- [ ] *T052* [P] [US1] [TDD] Create =RelayDto= TypeScript interface +*** TODO Frontend Implementation [1/2] +- [X] *T052* [P] [US1] [TDD] Create =RelayDto= TypeScript interface - Generate from OpenAPI spec or manually define - *File*: =frontend/src/types/relay.ts= - *Complexity*: Low | *Uncertainty*: Low diff --git a/src/composables/useRelay.ts b/src/composables/useRelay.ts new file mode 100644 index 0000000..e62e4e9 --- /dev/null +++ b/src/composables/useRelay.ts @@ -0,0 +1,37 @@ +import { ref } from 'vue'; +import { apiClient } from '../api/client'; +import { relayDtoToDomain } from '../types/mappers/relayDtoMapper'; +import type { Relay, RelayDto } from '../types/relay'; + +export function useRelay() { + const isLoading = ref(false); + const error = ref(null); + const response = ref(null); + + const toggleRelay = async (id: number) => { + isLoading.value = true; + try { + const { data } = await apiClient.POST('/api/relays/{id}/toggle', { + params: { + path: { + id, + }, + }, + }); + error.value = null; + response.value = relayDtoToDomain(data as RelayDto); + } catch (err: any) { + console.error(`Failed to toggle relay ${id}:`, err); + error.value = err.message || `Failed to toggle relay ${id}`; + } finally { + isLoading.value = false; + } + }; + + return { + toggleRelay, + isLoading, + error, + response, + }; +} diff --git a/src/composables/useRelayPolling.ts b/src/composables/useRelayPolling.ts new file mode 100644 index 0000000..3a5e8c9 --- /dev/null +++ b/src/composables/useRelayPolling.ts @@ -0,0 +1,51 @@ +import { onMounted, onUnmounted, ref } from 'vue'; + +import { apiClient } from '../api/client'; +import { relayDtoToDomain } from '../types/mappers/relayDtoMapper'; +import type { Relay } from '../types/relay'; +import { isNil } from '../utils/isNil'; + +export function useRelayPolling(intervalMs: number = 2000) { + const relays = ref([]); + const isLoading = ref(false); + const error = ref(null); + + let pollingInterval: number | null = null; + + const fetchData = async () => { + isLoading.value = true; + try { + const { data } = await apiClient.GET('/api/relays'); + relays.value = data?.map(relayDtoToDomain) ?? []; + error.value = null; + } catch (err: any) { + console.error('Polling error:', err); + error.value = err.message || 'Failed to fetch data'; + } finally { + isLoading.value = false; + } + }; + + const startPolling = () => { + fetchData(); + pollingInterval = window.setInterval(fetchData, intervalMs); + }; + + const stopPolling = () => { + if (isNil(pollingInterval)) { + return; + } + clearInterval(pollingInterval); + pollingInterval = null; + }; + + onMounted(startPolling); + onUnmounted(stopPolling); + + return { + relays, + isLoading, + error, + refresh: fetchData, + }; +} diff --git a/src/main.ts b/src/main.ts index 3c9bfeb..3a0e429 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ -import { createApp } from "vue"; -import "./style.css"; -import App from "./App.vue"; +import { createApp } from 'vue'; -createApp(App).mount("#app"); +import './style.css'; +import App from './App.vue'; + +createApp(App).mount('#app'); diff --git a/src/types/mappers/relayDtoMapper.ts b/src/types/mappers/relayDtoMapper.ts new file mode 100644 index 0000000..6a5157c --- /dev/null +++ b/src/types/mappers/relayDtoMapper.ts @@ -0,0 +1,13 @@ +import { isNil } from '../../utils/isNil'; +import { RelayState, Relay, type RelayDto } from '../relay'; + +const relayStateToDomain = (dto: string | null): RelayState => { + if (isNil(dto) || dto.trim() === '') { + return RelayState.Off; + } + return dto.trim().toLowerCase() === 'on' ? RelayState.On : RelayState.Off; +}; + +export const relayDtoToDomain = (dto: RelayDto): Relay => { + return new Relay(dto.id, relayStateToDomain(dto.state), dto.label); +}; diff --git a/src/types/relay.ts b/src/types/relay.ts new file mode 100644 index 0000000..3b595da --- /dev/null +++ b/src/types/relay.ts @@ -0,0 +1,20 @@ +import type { components } from '../api/schema'; + +export type RelayDto = components['schemas']['RelayDto']; + +export enum RelayState { + On = 'on', + Off = 'off', +} + +export class Relay { + id: number; + state: RelayState; + label: string; + + constructor(id: number, state: RelayState, label: string) { + this.id = id; + this.state = state; + this.label = label; + } +} diff --git a/src/utils/isNil.ts b/src/utils/isNil.ts new file mode 100644 index 0000000..bd50ad1 --- /dev/null +++ b/src/utils/isNil.ts @@ -0,0 +1,2 @@ +export const isNil = (value: unknown | null | undefined): value is null | undefined => + value === null || value === undefined; diff --git a/tsconfig.app.json b/tsconfig.app.json index 8d16e42..24fca7a 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -8,9 +8,10 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "noUncheckedIndexedAccess": true, }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 8a67f62..62eb1d4 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -18,9 +18,10 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "noUncheckedIndexedAccess": true, }, "include": ["vite.config.ts"] }