diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml
index 3c2a15a..b15ad75 100644
--- a/.github/workflows/ci-build.yaml
+++ b/.github/workflows/ci-build.yaml
@@ -37,7 +37,7 @@ jobs:
- name: Run ESLint
run: npm run lint
- tests:
+ test:
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -49,13 +49,9 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
- - name: Install Playwright Browsers
- run: npx playwright install --with-deps
- - name: Start Storybook
- run: |
- npm run storybook &
- npx wait-on http://127.0.0.1:6006
- - name: Run Storybook tests (CI)
- run: npm run test:stories:ci
+ - name: Run ESLint
+ run: npm run lint
+ - name: Run tests
+ run: npm run test
diff --git a/README.md b/README.md
index cb4c76f..2404e56 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,97 @@
-# library-react
\ No newline at end of file
+# scadable
+
+A React library for consuming real-time telemetry data through WebSocket connections.
+
+## Features
+
+- 🔌 **WebSocket Integration**: Seamless connection to telemetry data streams.
+- ⚛️ **React Hooks**: Simple `useLiveTelemetry` hook for real-time data consumption.
+- 🛡️ **TypeScript Support**: Full type safety with comprehensive TypeScript definitions.
+- 🧪 **Comprehensive Tests**: Unit and integration tests with WebSocket mocks.
+- 🔧 **Easy Setup**: Simple API with minimal configuration required.
+
+## Installation
+
+```bash
+npm install scadable
+````
+
+## Quick Start
+
+```tsx
+import React from "react";
+import { Facility, Device } from "scadable";
+import { useLiveTelemetry } from "scadable/hooks";
+import { TelemetryDisplay } from "scadable/components";
+
+// Initialize facility and device
+const facility = new Facility("your-api-key");
+const device = new Device(facility, "your-device-id");
+
+function App() {
+ return ;
+}
+```
+
+## API Reference
+
+### `Facility`
+
+Manages the API key for WebSocket authentication.
+
+```tsx
+const facility = new Facility("your-api-key");
+```
+
+#### Methods
+
+- `connect(deviceId: string)`: Creates and returns a WebSocket instance.
+
+### `Device`
+
+Manages the device ID and WebSocket connection.
+
+```tsx
+const device = new Device(facility, "device-id");
+```
+
+#### Methods
+
+- `connect()`: Establishes the WebSocket connection.
+- `disconnect()`: Closes the WebSocket connection.
+- `onMessage(handler)`: Subscribes to telemetry messages.
+- `onError(handler)`: Subscribes to connection errors.
+- `onStatusChange(handler)`: Subscribes to connection status changes.
+
+### `useLiveTelemetry` Hook
+
+A React hook for consuming real-time telemetry data.
+
+```tsx
+const { telemetry, isConnected, error } = useLiveTelemetry(device);
+```
+
+## Development
+
+### Running Storybook
+
+To see a live example of the library in action, run Storybook:
+
+```bash
+npm run storybook
+```
+
+This will open a new browser window with the `TelemetryDisplay` component, showcasing the `useLiveTelemetry` hook with a mock data stream.
+
+### Building
+
+```bash
+npm run build
+```
+
+### Testing
+
+```bash
+npm run test
+```
+
diff --git a/__tests__/Device.test.ts b/__tests__/Device.test.ts
new file mode 100644
index 0000000..8a65121
--- /dev/null
+++ b/__tests__/Device.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { Facility, Device, ConnectionStatus } from '../src/index';
+
+// WebSocket is mocked in setupTests.ts
+
+describe('Device', () => {
+ let facility: Facility;
+ let device: Device;
+
+ beforeEach(() => {
+ facility = new Facility('test-api-key');
+ device = new Device(facility, 'test-device-id');
+ });
+
+ it('should create a device with valid facility and device ID', () => {
+ expect(device.getDeviceId()).toBe('test-device-id');
+ expect(device.getConnectionStatus()).toBe(ConnectionStatus.DISCONNECTED);
+ });
+
+ it('should throw error for invalid facility', () => {
+ expect(() => new Device(null as any, 'device-id')).toThrow('Facility instance is required');
+ expect(() => new Device(undefined as any, 'device-id')).toThrow('Facility instance is required');
+ });
+
+ it('should throw error for invalid device ID', () => {
+ expect(() => new Device(facility, '')).toThrow('Device ID must be a non-empty string');
+ expect(() => new Device(facility, null as any)).toThrow('Device ID must be a non-empty string');
+ });
+
+ it('should build correct WebSocket URL', () => {
+ device.connect();
+ // The URL should contain token and deviceid parameters
+ expect(device.getConnectionStatus()).toBe(ConnectionStatus.CONNECTING);
+ });
+
+ it('should handle message subscription and unsubscription', () => {
+ const messageHandler = vi.fn();
+ const unsubscribe = device.onMessage(messageHandler);
+ expect(typeof unsubscribe).toBe('function');
+ unsubscribe();
+ });
+
+ it('should handle error subscription and unsubscription', () => {
+ const errorHandler = vi.fn();
+ const unsubscribe = device.onError(errorHandler);
+ expect(typeof unsubscribe).toBe('function');
+ unsubscribe();
+ });
+
+ it('should handle status change subscription and unsubscription', () => {
+ const statusHandler = vi.fn();
+ const unsubscribe = device.onStatusChange(statusHandler);
+ expect(typeof unsubscribe).toBe('function');
+ unsubscribe();
+ });
+
+ it('should connect and disconnect properly', () => {
+ device.connect();
+ expect(device.getConnectionStatus()).toBe(ConnectionStatus.CONNECTING);
+ device.disconnect();
+ expect(device.getConnectionStatus()).toBe(ConnectionStatus.DISCONNECTED);
+ });
+
+ it('should not connect if already connected', () => {
+ device.connect();
+ const initialStatus = device.getConnectionStatus();
+ device.connect();
+ expect(device.getConnectionStatus()).toBe(initialStatus);
+ });
+
+ it('should parse JSON messages correctly', () => {
+ const messageHandler = vi.fn();
+ device.onMessage(messageHandler);
+ const jsonMessage = { temperature: 25.5, humidity: 60 };
+ (device as any).handleMessage(JSON.stringify(jsonMessage));
+ expect(messageHandler).toHaveBeenCalledWith(jsonMessage);
+ });
+
+ it('should handle non-JSON messages as raw strings', () => {
+ const messageHandler = vi.fn();
+ device.onMessage(messageHandler);
+ const rawMessage = 'raw telemetry data';
+ (device as any).handleMessage(rawMessage);
+ expect(messageHandler).toHaveBeenCalledWith(rawMessage);
+ });
+
+ it('should check connection status correctly', () => {
+ expect(device.isConnected()).toBe(false);
+ device.connect();
+ expect(typeof device.isConnected).toBe('function');
+ });
+});
diff --git a/__tests__/Facility.test.ts b/__tests__/Facility.test.ts
new file mode 100644
index 0000000..7a71a1f
--- /dev/null
+++ b/__tests__/Facility.test.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect } from 'vitest';
+import { Facility } from '../src';
+
+describe('Facility', () => {
+ it('should create a facility with a valid API key', () => {
+ const facility = new Facility('test-api-key');
+ expect(facility.getApiKey()).toBe('test-api-key');
+ expect(facility.isValid()).toBe(true);
+ });
+
+ it('should throw error for empty API key', () => {
+ expect(() => new Facility('')).toThrow('API key must be a non-empty string');
+ });
+
+ it('should throw error for non-string API key', () => {
+ expect(() => new Facility(null as any)).toThrow('API key must be a non-empty string');
+ expect(() => new Facility(undefined as any)).toThrow('API key must be a non-empty string');
+ expect(() => new Facility(123 as any)).toThrow('API key must be a non-empty string');
+ });
+
+ it('should return true for valid facility', () => {
+ const facility = new Facility('valid-key');
+ expect(facility.isValid()).toBe(true);
+ });
+});
diff --git a/__tests__/integration.test.tsx b/__tests__/integration.test.tsx
new file mode 100644
index 0000000..2a9aff6
--- /dev/null
+++ b/__tests__/integration.test.tsx
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { TelemetryDisplay } from '../src/components/TelemetryDisplay';
+import {Device, Facility} from "../src";
+
+
+// WebSocket is mocked in setupTests.ts
+
+describe('Integration Tests', () => {
+ it('should render the TelemetryDisplay component with Scadable Stream title', () => {
+ const facility = new Facility('test-key');
+ const device = new Device(facility, 'test-device');
+ render();
+ expect(screen.getByText('Scadable Stream')).toBeInTheDocument();
+ });
+
+ it('should show connection status', async () => {
+ const facility = new Facility('test-key');
+ const device = new Device(facility, 'test-device');
+ render();
+ expect(screen.getByText('Disconnected')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('Connected')).toBeInTheDocument();
+ }, { timeout: 1000 });
+ });
+
+ it('should show "--" for temperature when no temperature data is available', () => {
+ const facility = new Facility('test-key');
+ const device = new Device(facility, 'test-device');
+ render();
+ expect(screen.getByText('--')).toBeInTheDocument();
+ });
+
+ it('should show "No data received" when no telemetry data is available', () => {
+ const facility = new Facility('test-key');
+ const device = new Device(facility, 'test-device');
+ render();
+ expect(screen.getByText('No data received')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/__tests__/setupTests.ts b/__tests__/setupTests.ts
new file mode 100644
index 0000000..0ff959d
--- /dev/null
+++ b/__tests__/setupTests.ts
@@ -0,0 +1,52 @@
+import '@testing-library/jest-dom';
+
+// Mock WebSocket globally
+class MockWebSocket {
+ // Define static properties to avoid relying on the global WebSocket object
+ static CONNECTING = 0;
+ static OPEN = 1;
+ static CLOSING = 2;
+ static CLOSED = 3;
+
+ public readyState: number = MockWebSocket.CONNECTING;
+ public onopen: ((_event: Event) => void) | null = null;
+ public onmessage: ((_event: MessageEvent) => void) | null = null;
+ public onerror: ((_event: Event) => void) | null = null;
+ public onclose: ((_event: CloseEvent) => void) | null = null;
+ public url: string;
+
+ constructor(url: string) {
+ this.url = url;
+ // Simulate connection after a short delay
+ setTimeout(() => {
+ this.readyState = MockWebSocket.OPEN; // Use the static property
+ if (this.onopen) {
+ this.onopen(new Event('open'));
+ }
+ }, 10);
+ }
+
+ close() {
+ this.readyState = MockWebSocket.CLOSED; // Use the static property
+ if (this.onclose) {
+ this.onclose(new CloseEvent('close'));
+ }
+ }
+
+ send() {
+ // No-op for this mock
+ }
+}
+
+// Mock global WebSocket
+global.WebSocket = MockWebSocket as any;
+
+// Mock Event classes
+global.Event = class Event {
+ constructor(type: string) {
+ // @ts-ignore
+ this.type = type;
+ }
+} as any;
+global.MessageEvent = class MessageEvent extends Event {} as any;
+global.CloseEvent = class CloseEvent extends Event {} as any;
\ No newline at end of file
diff --git a/__tests__/useLiveTelemetry.test.tsx b/__tests__/useLiveTelemetry.test.tsx
new file mode 100644
index 0000000..667f197
--- /dev/null
+++ b/__tests__/useLiveTelemetry.test.tsx
@@ -0,0 +1,113 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { Facility, Device, useLiveTelemetry, ConnectionStatus } from '../src/index';
+
+// WebSocket is mocked in setupTests.ts
+
+describe('useLiveTelemetry', () => {
+ let facility: Facility;
+ let device: Device;
+
+ beforeEach(() => {
+ facility = new Facility('test-api-key');
+ device = new Device(facility, 'test-device-id');
+ });
+
+ it('should initialize with null telemetry and disconnected state', () => {
+ const { result } = renderHook(() => useLiveTelemetry(device));
+ expect(result.current.telemetry).toBe(null);
+ expect(result.current.isConnected).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle device connection and status updates', async () => {
+ const { result } = renderHook(() => useLiveTelemetry(device));
+ expect(result.current.isConnected).toBe(false);
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 20));
+ });
+ expect(result.current.isConnected).toBe(true);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle telemetry message updates', async () => {
+ const { result } = renderHook(() => useLiveTelemetry(device));
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 20));
+ });
+ const testData = { temperature: 25.5, humidity: 60 };
+ await act(() => {
+ (device as any).handleMessage(JSON.stringify(testData));
+ });
+ expect(result.current.telemetry).toEqual(testData);
+ });
+
+ it('should handle raw string messages', async () => {
+ const { result } = renderHook(() => useLiveTelemetry(device));
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 20));
+ });
+ const rawMessage = 'raw telemetry data';
+ await act(() => {
+ (device as any).handleMessage(rawMessage);
+ });
+ expect(result.current.telemetry).toBe(rawMessage);
+ });
+
+ it('should handle connection errors', async () => {
+ const { result } = renderHook(() => useLiveTelemetry(device));
+ await act(() => {
+ (device as any).notifyErrorHandlers('Connection failed');
+ });
+ expect(result.current.error).toBe('Connection failed');
+ });
+
+ it('should clear errors on successful message', async () => {
+ const { result } = renderHook(() => useLiveTelemetry(device));
+ await act(() => {
+ (device as any).notifyErrorHandlers('Connection failed');
+ });
+ expect(result.current.error).toBe('Connection failed');
+ await act(() => {
+ (device as any).handleMessage(JSON.stringify({ temperature: 25 }));
+ });
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle device disconnection on unmount', () => {
+ const disconnectSpy = vi.spyOn(device, 'disconnect');
+ const { unmount } = renderHook(() => useLiveTelemetry(device));
+ unmount();
+ expect(disconnectSpy).toHaveBeenCalled();
+ });
+
+ it('should handle invalid device gracefully', () => {
+ const { result } = renderHook(() => useLiveTelemetry(null as any));
+ expect(result.current.error).toBe('Device instance is required');
+ });
+
+ it('should update connection status when device status changes', async () => {
+ const { result } = renderHook(() => useLiveTelemetry(device));
+ expect(result.current.isConnected).toBe(false);
+ await act(() => {
+ (device as any).updateConnectionStatus(ConnectionStatus.CONNECTED);
+ });
+ expect(result.current.isConnected).toBe(true);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle multiple message updates', async () => {
+ const { result } = renderHook(() => useLiveTelemetry(device));
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 20));
+ });
+ await act(() => {
+ (device as any).handleMessage(JSON.stringify({ temperature: 20 }));
+ });
+ expect(result.current.telemetry).toEqual({ temperature: 20 });
+ await act(() => {
+ (device as any).handleMessage(JSON.stringify({ temperature: 25, humidity: 60 }));
+ });
+ expect(result.current.telemetry).toEqual({ temperature: 25, humidity: 60 });
+ });
+});
diff --git a/eslint.config.js b/eslint.config.js
index b944a58..6f360dd 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -18,6 +18,16 @@ export default [
document: 'readonly',
window: 'readonly',
navigator: 'readonly',
+ console: 'readonly',
+ WebSocket: 'readonly',
+ URL: 'readonly',
+ setTimeout: 'readonly',
+ global: 'readonly',
+ Event: 'readonly',
+ MessageEvent: 'readonly',
+ CloseEvent: 'readonly',
+ setInterval: 'readonly',
+ clearInterval: 'readonly',
},
},
plugins: {
@@ -30,11 +40,18 @@ export default [
...reactPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
+ 'no-unused-vars': ['error', { 'argsIgnorePattern': '^_', 'varsIgnorePattern': '^_' }],
},
settings: {
react: { version: 'detect' },
},
},
+ {
+ files: ['src/types.ts'],
+ rules: {
+ 'no-unused-vars': 'off',
+ },
+ },
{
ignores: ['dist/', 'node_modules/', '.storybook/'],
},
diff --git a/index.html b/index.html
index 14aea48..e385df5 100644
--- a/index.html
+++ b/index.html
@@ -7,7 +7,7 @@
-
+