diff --git a/app/(protected)/automation/[id].tsx b/app/(protected)/automation/[id].tsx index fd60752..6292d7c 100644 --- a/app/(protected)/automation/[id].tsx +++ b/app/(protected)/automation/[id].tsx @@ -1,74 +1,130 @@ -import { OFFICES_DATA } from '@/data/data'; -import type { OfficeItem } from '@/types/types'; -import { Activity, ChevronLeft, Lightbulb, Thermometer, Wifi, WifiOff, Zap, Plus } from 'lucide-react-native'; -import React from 'react'; -import { useRouter, useLocalSearchParams } from 'expo-router'; -import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; +import DeviceCard from '@/components/DeviceCard'; +import LoadingScreen from '@/components/LoadingScreen'; +import { HaArea, HaEntity } from '@/types/types'; +import { getHaEntitiesByArea, toggleHaEntity } from '@/utils/haApi'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { ChevronLeft, ServerOff, WifiOff } from 'lucide-react-native'; +import React, { useCallback, useEffect, useState } from 'react'; +import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native'; -export default function AutomationDetail() { +export default function AutomationDetailScreen() { const router = useRouter(); - const { id } = useLocalSearchParams<{ id: string }>(); + const params = useLocalSearchParams(); + const [area, setArea] = useState(null); + const [devices, setDevices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); - const selectedOffice: OfficeItem | undefined = OFFICES_DATA.find(o => o.id.toString() === id); - if (!selectedOffice) return Ufficio non trovato; + // Fetch dei devices dell'area + const fetchAreaDevices = useCallback(async (areaId: string, isRefreshing = false) => { + try { + if (!isRefreshing) setIsLoading(true); + + // Fetch Documenti + const response = await getHaEntitiesByArea(areaId); + const filteredDevices = response.filter(device => device.name); + setDevices(filteredDevices); + } catch (error) { + console.error('Errore nel recupero dei dispositivi dell\'area:', error); + } finally { + setIsLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + if (params.areaData) { + try { + const jsonString = Array.isArray(params.areaData) ? params.areaData[0] : params.areaData; + const parsedArea = JSON.parse(jsonString); + setArea(parsedArea); + } catch (error) { + console.error('Errore nel parsing dei dati del cantiere:', error); + } + } + }, [params.areaData]); + + useEffect(() => { + if (params.id) { + setIsLoading(true); + const areaId = Array.isArray(params.id) ? params.id[0] : params.id; + fetchAreaDevices(areaId); + } + }, [params.id, fetchAreaDevices]); + + const onRefresh = () => { + setRefreshing(true); + const areaId = Array.isArray(params.id) ? params.id[0] : params.id; + fetchAreaDevices(areaId, true); + }; + + const onToggle = useCallback((entityId: string) => { + // Optimistic UI update + setDevices(prevDevices => + prevDevices.map(d => + d.entity_id === entityId + ? { ...d, state: d.state === 'on' ? 'off' : 'on' } + : d + ) + ); + // Fire and forget the API call + toggleHaEntity(entityId).catch(() => { + // Revert on error + console.log("Toggle failed, reverting UI."); + const areaId = Array.isArray(params.id) ? params.id[0] : params.id; + if (areaId) fetchAreaDevices(areaId); + }); + }, [fetchAreaDevices, params.id]); + + if (isLoading) { + return ; + } + + if (!area) { + return ( + + + Area non trovata + L'area che stai cercando non esiste o è stata rimossa. + router.back()} className="mt-4 bg-[#099499] px-6 py-3 rounded-xl active:scale-95"> + Torna Indietro + + + ); + } return ( - {/* Header Dettaglio */} - - router.back()} - className="mr-4 p-3 rounded-full bg-gray-50 active:bg-gray-200" - > + + router.back()} className='rounded-full active:bg-gray-100'> - {selectedOffice.name} + {area.name} - {/* Status Dot */} - + - - - {/* Lights Card Grande */} - - - - {/* Switch UI Grande - FIXED: Rimossa 'transition-colors' che causava il crash */} - - - - - Luci - {selectedOffice.lights ? 'Accese - 80%' : 'Spente'} - - - {/* Temp Card Grande */} - - - Clima - - {selectedOffice.temp} - °C - - - - - {/* Chart Card Grande */} - - - - Consumo Oggi - - - {[40, 65, 30, 80, 55, 90, 45].map((h, i) => ( - - - + 0 ? 'justify-start' : 'justify-center'}`} + refreshControl={} + > + {devices.length > 0 ? ( + + {devices.map(device => ( + ))} - + ) : ( + + + Nessun dispositivo + + Non ci sono dispositivi in quest'area. + + + )} + ); diff --git a/app/(protected)/automation/index.tsx b/app/(protected)/automation/index.tsx index 91df11e..8ac4449 100644 --- a/app/(protected)/automation/index.tsx +++ b/app/(protected)/automation/index.tsx @@ -1,71 +1,209 @@ -import { OFFICES_DATA } from '@/data/data'; -import type { OfficeItem } from '@/types/types'; +import type { HaArea } from '@/types/types'; +import { getHaAreas, testHaConnection } from '@/utils/haApi'; import { useRouter } from 'expo-router'; -import { Activity, ChevronLeft, Lightbulb, Thermometer, Wifi, WifiOff, Zap, Plus } from 'lucide-react-native'; -import React, { useState } from 'react'; -import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; +import { Archive, Briefcase, DoorOpen, Grid2X2, Lightbulb, Plus, Users, WifiOff } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native'; +import LoadingScreen from '@/components/LoadingScreen'; + +const AREA_STYLES = [ + { icon: Users, bgColor: 'bg-indigo-100' }, + { icon: Briefcase, bgColor: 'bg-slate-100' }, + { icon: Lightbulb, bgColor: 'bg-yellow-100' }, + { icon: DoorOpen, bgColor: 'bg-green-100' }, + { icon: Archive, bgColor: 'bg-orange-100' }, + { icon: Grid2X2, bgColor: 'bg-sky-100' }, +]; export default function AutomationScreen() { const router = useRouter(); - const [selectedOffice, setSelectedOffice] = useState(null); + const [allAreas, setAllAreas] = useState([]); + const [connectionStatus, setConnectionStatus] = useState<{ success: boolean; message: string }>({ success: false, message: 'Connecting...' }); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const [floors, setFloors] = useState(['Tutti']); + const [selectedFloor, setSelectedFloor] = useState('Tutti'); + + const initialize = useCallback(async () => { + const connResult = await testHaConnection(); + setConnectionStatus(connResult); + + if (connResult.success) { + const areasResult = await getHaAreas(); + const areas = areasResult.map(area => ({ + ...area, + floor_name: area.floor_name || 'Non assegnato', + device_count: area.device_count || 0, + })); + setAllAreas(areas); + + const uniqueFloors = Array.from(new Set(areas.map(area => area.floor_name || 'Non assegnato'))); + setFloors(['Tutti', ...uniqueFloors]); + } + }, []); + + useEffect(() => { + const load = async () => { + setIsLoading(true); + await initialize(); + setIsLoading(false); + }; + + load(); + }, [initialize]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await initialize(); + setRefreshing(false); + }, [initialize]); + + const filteredAreas = useMemo(() => { + if (selectedFloor === 'Tutti') { + return allAreas; + } + return allAreas.filter(area => area.floor_name === selectedFloor); + }, [allAreas, selectedFloor]); + + if (isLoading) { + return ; + } + + if (!connectionStatus.success) { + return ( + + + + + Domotica + Controlla gli ambienti + + + + {connectionStatus.success ? 'ONLINE' : 'OFFLINE'} + + + + {connectionStatus.success && ( + + {floors.map(floor => ( + setSelectedFloor(floor)} + className={`px-5 py-2.5 rounded-xl ${selectedFloor === floor ? 'bg-[#099499]' : 'bg-gray-100 active:bg-gray-200'}`} + > + {floor} + + ))} + + )} + + } + > + + Errore di connessione + + {connectionStatus.message} + + + Riprova + + + + ); + } - // --- LIST VIEW (INGRANDITA) --- return ( - - - Domotica - Controlla gli ambienti - - - ONLINE + + + + Domotica + Controlla gli ambienti + + + + {connectionStatus.success ? 'ONLINE' : 'OFFLINE'} + + + {connectionStatus.success && ( + + {floors.map(floor => ( + setSelectedFloor(floor)} + className={`px-5 py-2.5 rounded-xl ${selectedFloor === floor ? 'bg-[#099499]' : 'bg-gray-100 active:bg-gray-200'}`} + > + {floor} + + ))} + + )} - - {OFFICES_DATA.map((office) => ( - router.push(`/automation/${office.id}`)} - className="bg-white rounded-3xl p-6 shadow-sm flex-row items-center justify-between border border-gray-100 active:border-[#099499]/30 active:scale-[0.98]" - > - - - {office.status === 'online' ? - : - - } - - - {office.name} - - - - {office.temp}°C + } + > + {filteredAreas.length > 0 ? ( + + {filteredAreas.map((area, index) => { + const { icon: IconComponent, bgColor } = AREA_STYLES[index % AREA_STYLES.length]; + return ( + router.push({ + pathname: '/(protected)/automation/[id]', + params: { + id: area.name, + areaData: JSON.stringify(area), + } + })} + className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 active:scale-[0.98] w-[48%] mb-4 aspect-square" + > + + + + + + + {area.name} + + + {area.device_count} dispositivi + + - - - - {office.power}W - - - - - {/* Status Dot */} - - - ))} - {/* Spacer finale per la navbar */} + + ) + })} + + ) : ( + + + Nessuna area trovata + + {`Nessuna area per il piano "${selectedFloor}"`} + + + )} - {/* FAB */} - alert('Aggiungi nuovo collegamento')} className="absolute bottom-8 right-6 w-16 h-16 bg-[#099499] rounded-full shadow-lg items-center justify-center active:scale-90" > - + */} ); } \ No newline at end of file diff --git a/app/(protected)/sites/[id].tsx b/app/(protected)/sites/[id].tsx index 4184553..649f9c3 100644 --- a/app/(protected)/sites/[id].tsx +++ b/app/(protected)/sites/[id].tsx @@ -1,4 +1,4 @@ -import { Download, FileText, Plus, Search, Calendar as CalendarIcon, ArrowLeft } from 'lucide-react-native'; +import { Download, FileText, Plus, Search, Calendar as CalendarIcon, ChevronLeft } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; import { RangePickerModal } from '@/components/RangePickerModal'; import { Alert, RefreshControl, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native'; @@ -130,7 +130,7 @@ export default function SiteDocumentsScreen() { {/* Header */} router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100"> - + diff --git a/components/DeviceCard.tsx b/components/DeviceCard.tsx new file mode 100644 index 0000000..a3e53fe --- /dev/null +++ b/components/DeviceCard.tsx @@ -0,0 +1,83 @@ +import { HaEntity } from '@/types/types'; +import { Lightbulb, Power, Cpu, Lock, Zap, Fan, Cctv, CloudSun } from 'lucide-react-native'; +import React from 'react'; +import { Text, TouchableOpacity, View } from 'react-native'; + +const DeviceCard = ({ device, onToggle }: { device: HaEntity; onToggle: (entityId: string) => void; }) => { + const getIcon = () => { + const domain = device.entity_id.split('.')[0]; + + switch (domain) { + case 'light': + return ; + case 'switch': + return ; + case 'sensor': + return ; + case 'lock': + return ; + case 'fan': + return ; + case 'camera': + return ; + case 'weather': + return ; + default: + return ; + } + }; + + const isToggleable = ['light', 'switch', 'fan', 'input_boolean'].includes(device.entity_id.split('.')[0]); + const isOn = device.state === 'on'; + + const getDeviceState = (state: string) => { + switch (state) { + case 'on': + return 'Acceso'; + case 'off': + return 'Spento'; + case 'idle': + return 'Inattivo'; + case 'locked': + return 'Bloccato'; + case 'unlocked': + return 'Sbloccato'; + case 'unavailable': + return 'Non disponibile'; + case 'unknown': + return 'Sconosciuto'; + default: + return state; + } + } + + return ( + + + + {getIcon()} + {isToggleable && ( + onToggle(device.entity_id)} + className={`w-12 h-7 rounded-full p-1 ${isOn ? 'bg-[#099499]' : 'bg-gray-200'} active:scale-[0.9]`} + > + + + )} + + + + {device.name} + + {getDeviceState(device.state)} + + + + + ); +}; + +export default DeviceCard; diff --git a/data/data.ts b/data/data.ts deleted file mode 100644 index 3769ed1..0000000 --- a/data/data.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { OfficeItem } from '@/types/types'; - -export const OFFICES_DATA: OfficeItem[] = [ - { id: 1, name: "Ufficio Tecnico", status: "online", temp: 22, lights: true, power: 450 }, - { id: 2, name: "Sala Riunioni", status: "offline", temp: 19, lights: false, power: 0 }, - { id: 3, name: "Amministrazione", status: "online", temp: 24, lights: true, power: 320 }, - { id: 4, name: "Magazzino", status: "online", temp: 18, lights: false, power: 120 }, -]; \ No newline at end of file diff --git a/types/types.ts b/types/types.ts index c687359..d0eeb3b 100644 --- a/types/types.ts +++ b/types/types.ts @@ -35,15 +35,6 @@ export interface DocumentItem { updated_at: string; } -export interface OfficeItem { - id: number; - name: string; - status: 'online' | 'offline'; - temp: number; - lights: boolean; - power: number; -} - export interface TimeOffRequestType { id: number; name: string; @@ -72,4 +63,18 @@ export interface ConstructionSite { name: string; code: string; attachments_count: number; -} \ No newline at end of file +} + +export interface HaArea { + id: string; + name: string; + floor_id: string | null; + floor_name: string | null; + device_count?: number; +} + +export interface HaEntity { + entity_id: string; + name: string; + state: string; +} diff --git a/utils/haApi.ts b/utils/haApi.ts new file mode 100644 index 0000000..98735a6 --- /dev/null +++ b/utils/haApi.ts @@ -0,0 +1,142 @@ +import axios, { AxiosError } from 'axios'; +import { HaArea, HaEntity } from '@/types/types'; + +// CONFIGURAZIONE +const HA_API_URL = process.env.EXPO_PUBLIC_HA_API_URL; +const HA_TOKEN = process.env.EXPO_PUBLIC_HA_TOKEN; + +// Crea un'istanza di axios per Home Assistant +const haApi = axios.create({ + baseURL: HA_API_URL, + headers: { + 'Authorization': `Bearer ${HA_TOKEN}`, + 'Content-Type': 'application/json', + }, + timeout: 5000, // 5 secondi di timeout +}); + +haApi.interceptors.request.use((config) => { + console.log(`[HOME ASSISTANT API REQUEST] ${config.method?.toUpperCase()} ${config.url}`); + return config; +}); + +/* + * Connection test +*/ +export const testHaConnection = async (): Promise<{ success: boolean; message: string }> => { + // Controlla se le variabili d'ambiente sono caricate + if (!HA_API_URL || !HA_TOKEN) { + console.error("Variabili d'ambiente per Home Assistant non trovate. Assicurati che EXPO_PUBLIC_HA_API_URL and EXPO_PUBLIC_HA_TOKEN siano definite nel file .env"); + return { success: false, message: "Configurazione API per Home Assistant mancante." }; + } + + try { + const response = await haApi.get('/'); + // Se la risposta è OK, HA restituisce un JSON con 'message' + if (response.status === 200 && response.data.message) { + return { success: true, message: response.data.message }; + } + return { success: false, message: "Risposta inattesa dal server Home Assistant." }; + + } catch (error) { + const axiosError = error as AxiosError; + + if (axiosError.code === 'ECONNABORTED' || axiosError.message.includes('timeout')) { + return { success: false, message: "Timeout: Il server non risponde (IP errato o server offline)." }; + } + + if (axiosError.response) { + // Errori con una risposta dal server (es. 401, 404) + if (axiosError.response.status === 401) { + return { success: false, message: "Errore 401: Token non autorizzato. Controlla il Long-Lived Token." }; + } + if (axiosError.response.status === 404) { + return { success: false, message: "Errore 404: Verifica la configurazione di Home Assistant." }; + } + return { success: false, message: `Errore server: Status ${axiosError.response.status}` }; + } else if (axiosError.request) { + // Errori di rete (la richiesta è partita ma non ha ricevuto risposta) + return { success: false, message: `Errore di rete: Impossibile raggiungere ${HA_API_URL}` }; + } else { + // Errore generico + return { success: false, message: `Errore sconosciuto: ${axiosError.message}` }; + } + } +}; + +// Fetch Home Assistant Areas +export const getHaAreas = async (): Promise => { + try { + const response = await haApi.post('/template', { + "template": ` + [ + {%- for area_id in areas() %} + {%- set area_name = area_name(area_id) %} + {%- set f_id = floor_id(area_name) %} + {%- set f_name = floor_name(f_id) %} + { + "id": "{{ area_id }}", + "name": "{{ area_name }}", + "floor_id": {% if f_id != None %}"{{ f_id }}"{% else %}null{% endif %}, + "floor_name": {% if f_name != None %}"{{ f_name }}"{% else %}null{% endif %}, + "device_count": {{area_devices(area_id)|count}} + }{% if not loop.last %},{% endif %} + {%- endfor %} + ] + ` + }); + + const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + // console.log("Aree recuperate da Home Assistant:", data); + return data; + + } catch (error) { + console.error("Errore recupero aree:", error); + return []; // Restituisce un array vuoto in caso di errore + } +}; + +// Fetch Home Assistant Entities by Area +export const getHaEntitiesByArea = async (areaId: string): Promise => { + try { + const response = await haApi.post('/template', { + "template": ` + [ + {%- set area_entities = area_entities('${areaId}') %} + {%- for entity_id in area_entities %} + { + "entity_id": {{ entity_id | tojson }}, + "name": {{ state_attr(entity_id, 'friendly_name') | tojson }}, + "state": {{ states(entity_id) | tojson }} + }{% if not loop.last %},{% endif %} + {%- endfor %} + ] + ` + }); + + const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + // console.log(`Entità recuperate per l'area ${areaId}:`, data); + return data; + + } catch (error) { + console.error(`Errore recupero entità per l'area ${areaId}:`, error); + return []; // Restituisce un array vuoto in caso di errore + } +}; + +// Toggle Home Assistant Entity +export const toggleHaEntity = async (entityId: string): Promise => { + try { + const domain = entityId.split('.')[0]; + + await haApi.post('/services/' + domain + '/toggle', { + entity_id: entityId + }); + + return true; + } catch (error) { + console.error(`Errore toggle ${entityId}:`, error); + return false; + } +}; +