πͺππ½πΎ π¨ππ π₯ππ πΎ dashboard.js
πͺππ½πΎ π¨ππ π₯ππ πΎ dashboard.js
π‘πΊπππ π»ππΊπππΊπ πππ½πΎ πππ πΏππ πΎ dashboard.js
FarmerSmartAI - File JavaScript Dashboard
Berikut kode lengkap untuk file dashboard.js yang berisi fungsi-fungsi khusus untuk dashboard FarmerSmartAI:
```javascript
// dashboard.js - JavaScript Khusus Dashboard FarmerSmartAI
class DashboardManager {
constructor() {
this.charts = new Map();
this.sensorData = [];
this.realTimeInterval = null;
this.isInitialized = false;
this.currentTheme = 'light';
}
// ===== INITIALIZATION =====
async init() {
if (this.isInitialized) return;
try {
await this.loadDashboardData();
this.initializeCharts();
this.setupRealTimeUpdates();
this.setupDashboardEvents();
this.setupSensorMonitoring();
this.setupWeatherWidget();
this.isInitialized = true;
console.log('Dashboard initialized successfully');
} catch (error) {
console.error('Failed to initialize dashboard:', error);
this.showError('Gagal memuat dashboard. Silakan refresh halaman.');
}
}
// ===== DATA LOADING =====
async loadDashboardData() {
try {
const [sensorData, recommendations, weatherData] = await Promise.all([
this.fetchSensorData(),
this.fetchRecommendations(),
this.fetchWeatherData()
]);
this.sensorData = sensorData;
this.updateSensorDisplays(sensorData);
this.updateRecommendations(recommendations);
this.updateWeatherWidget(weatherData);
this.updateQuickStats(sensorData);
} catch (error) {
console.error('Error loading dashboard data:', error);
throw error;
}
}
async fetchSensorData() {
// Simulate API call
await this.delay(800);
return Array.from({ length: 24 }, (_, i) => ({
timestamp: new Date(Date.now() - i * 3600000).toISOString(),
soil_moisture: this.randomInt(30, 80),
temperature: 25 + Math.sin(i / 4) * 5 + Math.random() * 2,
humidity: 60 + Math.cos(i / 3) * 20 + Math.random() * 5,
ph_level: 6.0 + Math.random() * 1.5,
nutrient_n: this.randomInt(20, 60),
nutrient_p: this.randomInt(15, 40),
nutrient_k: this.randomInt(10, 50),
light_intensity: this.randomInt(200, 1000)
})).reverse();
}
async fetchRecommendations() {
await this.delay(500);
return [
{
id: 1,
type: 'irrigation',
priority: 'high',
title: 'Irigasi Diperlukan',
message: 'Kelembaban tanah di Area Utara mencapai 32%, perlu irigasi segera',
action: 'schedule_irrigation',
timestamp: new Date().toISOString(),
area: 'Area Utara'
},
{
id: 2,
type: 'fertilization',
priority: 'medium',
title: 'Pemupukan Kalium',
message: 'Level kalium rendah (35 ppm), pertimbangkan aplikasi KCL',
action: 'apply_fertilizer',
timestamp: new Date(Date.now() - 2 * 3600000).toISOString(),
amount: '100 kg/ha'
},
{
id: 3,
type: 'pest_control',
priority: 'high',
title: 'Peringatan Hama',
message: 'Kondisi optimal untuk perkembangan wereng batang coklat',
action: 'apply_pesticide',
timestamp: new Date(Date.now() - 4 * 3600000).toISOString(),
risk_level: 'Tinggi'
}
];
}
async fetchWeatherData() {
await this.delay(600);
return {
location: 'Kebun Sejahtera, Jawa Barat',
temperature: 28,
humidity: 82,
condition: 'Partly Cloudy',
wind_speed: 12,
precipitation: 30,
forecast: [
{ day: 'Today', condition: 'partly-cloudy', high: 30, low: 24 },
{ day: 'Tomorrow', condition: 'rain', high: 28, low: 23 },
{ day: 'Wed', condition: 'cloudy', high: 29, low: 24 },
{ day: 'Thu', condition: 'sunny', high: 31, low: 25 },
{ day: 'Fri', condition: 'partly-cloudy', high: 30, low: 24 }
]
};
}
// ===== CHARTS INITIALIZATION =====
initializeCharts() {
this.initializeSoilMoistureChart();
this.initializeTemperatureChart();
this.initializeNutrientChart();
this.initializeYieldPredictionChart();
this.initializeSensorHealthChart();
}
initializeSoilMoistureChart() {
const ctx = document.getElementById('soilMoistureChart')?.getContext('2d');
if (!ctx) return;
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: this.generateTimeLabels(24),
datasets: [{
label: 'Kelembaban Tanah (%)',
data: this.sensorData.map(d => d.soil_moisture),
borderColor: '#2196F3',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
borderWidth: 3,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
y: {
min: 0,
max: 100,
title: {
display: true,
text: 'Kelembaban (%)'
}
},
x: {
title: {
display: true,
text: 'Waktu'
}
}
}
}
});
this.charts.set('soilMoisture', chart);
}
initializeTemperatureChart() {
const ctx = document.getElementById('temperatureChart')?.getContext('2d');
if (!ctx) return;
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: this.generateTimeLabels(24),
datasets: [
{
label: 'Suhu (°C)',
data: this.sensorData.map(d => d.temperature),
borderColor: '#FF5722',
backgroundColor: 'rgba(255, 87, 34, 0.1)',
borderWidth: 3,
fill: true,
tension: 0.4,
yAxisID: 'y'
},
{
label: 'Kelembaban Udara (%)',
data: this.sensorData.map(d => d.humidity),
borderColor: '#009688',
backgroundColor: 'rgba(0, 150, 136, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Suhu (°C)'
},
min: 15,
max: 40
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Kelembaban (%)'
},
min: 30,
max: 100,
grid: {
drawOnChartArea: false
}
}
}
}
});
this.charts.set('temperature', chart);
}
initializeNutrientChart() {
const ctx = document.getElementById('nutrientChart')?.getContext('2d');
if (!ctx) return;
const latestData = this.sensorData[this.sensorData.length - 1] || {};
const chart = new Chart(ctx, {
type: 'radar',
data: {
labels: ['Nitrogen (N)', 'Fosfor (P)', 'Kalium (K)', 'pH Level', 'Cahaya'],
datasets: [{
label: 'Level Saat Ini',
data: [
latestData.nutrient_n || 0,
latestData.nutrient_p || 0,
latestData.nutrient_k || 0,
((latestData.ph_level || 0) - 5) * 20, // Convert to 0-100 scale
(latestData.light_intensity || 0) / 10 // Convert to 0-100 scale
],
backgroundColor: 'rgba(76, 175, 80, 0.2)',
borderColor: '#4CAF50',
borderWidth: 2,
pointBackgroundColor: '#4CAF50'
}, {
label: 'Level Optimal',
data: [45, 30, 40, 50, 60],
backgroundColor: 'rgba(33, 150, 243, 0.2)',
borderColor: '#2196F3',
borderWidth: 1,
pointBackgroundColor: '#2196F3',
borderDash: [5, 5]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
angleLines: {
display: true
},
suggestedMin: 0,
suggestedMax: 100
}
}
}
});
this.charts.set('nutrient', chart);
}
initializeYieldPredictionChart() {
const ctx = document.getElementById('yieldPredictionChart')?.getContext('2d');
if (!ctx) return;
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Area Utara', 'Area Timur', 'Area Selatan', 'Area Barat'],
datasets: [{
label: 'Prediksi Hasil (ton/ha)',
data: [8.2, 7.5, 7.9, 7.6],
backgroundColor: [
'rgba(76, 175, 80, 0.7)',
'rgba(76, 175, 80, 0.7)',
'rgba(76, 175, 80, 0.7)',
'rgba(76, 175, 80, 0.7)'
],
borderColor: [
'rgba(76, 175, 80, 1)',
'rgba(76, 175, 80, 1)',
'rgba(76, 175, 80, 1)',
'rgba(76, 175, 80, 1)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 10,
title: {
display: true,
text: 'Ton per Hektar'
}
}
},
plugins: {
legend: {
display: false
}
}
}
});
this.charts.set('yieldPrediction', chart);
}
initializeSensorHealthChart() {
const ctx = document.getElementById('sensorHealthChart')?.getContext('2d');
if (!ctx) return;
const chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Online', 'Offline', 'Maintenance'],
datasets: [{
data: [8, 1, 1],
backgroundColor: [
'#4CAF50',
'#F44336',
'#FF9800'
],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
plugins: {
legend: {
position: 'bottom'
}
}
}
});
this.charts.set('sensorHealth', chart);
}
// ===== REAL-TIME UPDATES =====
setupRealTimeUpdates() {
// Update data every 30 seconds
this.realTimeInterval = setInterval(() => {
this.updateRealTimeData();
}, 30000);
// Also update immediately
this.updateRealTimeData();
}
updateRealTimeData() {
const newData = {
soil_moisture: this.randomInt(30, 80),
temperature: 25 + Math.random() * 10,
humidity: 60 + Math.random() * 30,
ph_level: 6.0 + Math.random() * 1.5,
nutrient_n: this.randomInt(20, 60),
nutrient_p: this.randomInt(15, 40),
nutrient_k: this.randomInt(10, 50),
timestamp: new Date().toISOString()
};
// Add to sensor data array (keep only last 24 hours)
this.sensorData.push(newData);
if (this.sensorData.length > 24) {
this.sensorData = this.sensorData.slice(-24);
}
// Update displays
this.updateSensorDisplays([newData]);
this.updateCharts();
}
updateCharts() {
this.charts.forEach((chart, key) => {
if (chart && typeof chart.update === 'function') {
switch (key) {
case 'soilMoisture':
chart.data.datasets[0].data = this.sensorData.map(d => d.soil_moisture);
break;
case 'temperature':
chart.data.datasets[0].data = this.sensorData.map(d => d.temperature);
chart.data.datasets[1].data = this.sensorData.map(d => d.humidity);
break;
case 'nutrient':
const latest = this.sensorData[this.sensorData.length - 1];
chart.data.datasets[0].data = [
latest.nutrient_n,
latest.nutrient_p,
latest.nutrient_k,
((latest.ph_level || 0) - 5) * 20,
(latest.light_intensity || 0) / 10
];
break;
}
chart.update('none'); // 'none' untuk performance
}
});
}
// ===== SENSOR MONITORING =====
setupSensorMonitoring() {
// Simulate sensor status changes
setInterval(() => {
this.updateSensorStatus();
}, 45000);
}
updateSensorStatus() {
const sensors = document.querySelectorAll('.sensor-card');
sensors.forEach(sensor => {
// 5% chance to change status
if (Math.random() < 0.05) {
const statusElement = sensor.querySelector('.sensor-status');
if (statusElement) {
if (statusElement.classList.contains('status-online')) {
statusElement.classList.remove('status-online');
statusElement.classList.add('status-offline');
statusElement.textContent = 'Offline';
// Show notification for offline sensor
this.showNotification(`Sensor ${sensor.querySelector('.sensor-label').textContent} offline`, 'warning');
} else {
statusElement.classList.remove('status-offline');
statusElement.classList.add('status-online');
statusElement.textContent = 'Online';
}
}
}
});
}
// ===== WEATHER WIDGET =====
setupWeatherWidget() {
// Update weather every hour
setInterval(() => {
this.fetchWeatherData().then(weather => {
this.updateWeatherWidget(weather);
});
}, 3600000);
}
updateWeatherWidget(weatherData) {
const widget = document.querySelector('.weather-widget');
if (!widget) return;
// Update current weather
const tempElement = widget.querySelector('.weather-temp');
const conditionElement = widget.querySelector('.weather-desc');
const locationElement = widget.querySelector('.weather-location');
if (tempElement) tempElement.textContent = `${Math.round(weatherData.temperature)}°`;
if (conditionElement) conditionElement.textContent = weatherData.condition;
if (locationElement) locationElement.textContent = weatherData.location;
// Update forecast
const forecastContainer = widget.querySelector('.weather-forecast');
if (forecastContainer) {
forecastContainer.innerHTML = weatherData.forecast.map(day => `
<div class="forecast-day">
<div class="forecast-date">${day.day}</div>
<div class="forecast-icon">
<i class="fas fa-${this.getWeatherIcon(day.condition)}"></i>
</div>
<div class="forecast-temp">${day.high}°</div>
</div>
`).join('');
}
}
getWeatherIcon(condition) {
const icons = {
'sunny': 'sun',
'partly-cloudy': 'cloud-sun',
'cloudy': 'cloud',
'rain': 'cloud-rain',
'storm': 'bolt'
};
return icons[condition] || 'sun';
}
// ===== UI UPDATES =====
updateSensorDisplays(sensorData) {
const latestData = sensorData[sensorData.length - 1];
if (!latestData) return;
// Update quick stats
this.updateQuickStats(sensorData);
// Update individual sensor cards
const updates = [
{ selector: '.sensor-moisture .sensor-value', value: `${latestData.soil_moisture}%` },
{ selector: '.sensor-temperature .sensor-value', value: `${latestData.temperature.toFixed(1)}°C` },
{ selector: '.sensor-humidity .sensor-value', value: `${latestData.humidity}%` },
{ selector: '.sensor-ph .sensor-value', value: latestData.ph_level.toFixed(1) },
{ selector: '.sensor-nitrogen .sensor-value', value: `${latestData.nutrient_n} ppm` },
{ selector: '.sensor-phosphorus .sensor-value', value: `${latestData.nutrient_p} ppm` },
{ selector: '.sensor-potassium .sensor-value', value: `${latestData.nutrient_k} ppm` }
];
updates.forEach(update => {
const element = document.querySelector(update.selector);
if (element) {
element.textContent = update.value;
}
});
}
updateQuickStats(sensorData) {
const latestData = sensorData[sensorData.length - 1];
if (!latestData) return;
const stats = [
{
selector: '.stat-moisture .stat-value',
value: `${latestData.soil_moisture}%`,
trend: this.calculateTrend(sensorData, 'soil_moisture')
},
{
selector: '.stat-temperature .stat-value',
value: `${latestData.temperature.toFixed(1)}°C`,
trend: this.calculateTrend(sensorData, 'temperature')
},
{
selector: '.stat-humidity .stat-value',
value: `${latestData.humidity}%`,
trend: this.calculateTrend(sensorData, 'humidity')
},
{
selector: '.stat-yield .stat-value',
value: '7.8 ton/ha',
trend: 'up'
}
];
stats.forEach(stat => {
const element = document.querySelector(stat.selector);
if (element) {
element.textContent = stat.value;
// Update trend indicator
const trendElement = element.parentElement?.querySelector('.stat-trend');
if (trendElement) {
trendElement.className = `stat-trend trend-${stat.trend}`;
trendElement.innerHTML = `<i class="fas fa-arrow-${stat.trend}"></i> ${this.randomInt(1, 5)}%`;
}
}
});
}
updateRecommendations(recommendations) {
const container = document.querySelector('.recommendation-list');
if (!container) return;
container.innerHTML = recommendations.map(rec => `
<div class="recommendation-item ${rec.priority}">
<div class="recommendation-icon">
<i class="fas fa-${this.getRecommendationIcon(rec.type)}"></i>
</div>
<div class="recommendation-content">
<h3>${rec.title}</h3>
<p>${rec.message}</p>
<small>${this.formatTimeAgo(rec.timestamp)}</small>
</div>
<button class="btn btn-sm btn-outline" onclick="dashboard.handleRecommendation(${rec.id})">
Tindakan
</button>
</div>
`).join('');
}
// ===== EVENT HANDLERS =====
setupDashboardEvents() {
// Refresh button
const refreshBtn = document.querySelector('[data-action="refresh"]');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
this.handleRefresh();
});
}
// Time range filters
const timeFilters = document.querySelectorAll('.time-filter');
timeFilters.forEach(filter => {
filter.addEventListener('change', (e) => {
this.handleTimeFilterChange(e.target.value);
});
});
// Alert dismiss buttons
document.addEventListener('click', (e) => {
if (e.target.closest('.alert-dismiss')) {
e.target.closest('.alert-item').remove();
}
});
// Sensor card clicks
const sensorCards = document.querySelectorAll('.sensor-card');
sensorCards.forEach(card => {
card.addEventListener('click', () => {
this.handleSensorClick(card);
});
});
// Theme toggle
const themeToggle = document.querySelector('[data-action="toggle-theme"]');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
this.toggleTheme();
});
}
}
handleRefresh() {
const refreshBtn = document.querySelector('[data-action="refresh"]');
if (refreshBtn) {
const originalHtml = refreshBtn.innerHTML;
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
refreshBtn.disabled = true;
this.loadDashboardData().finally(() => {
refreshBtn.innerHTML = originalHtml;
refreshBtn.disabled = false;
this.showNotification('Data diperbarui', 'success');
});
}
}
handleTimeFilterChange(range) {
// In a real app, this would fetch new data based on the range
console.log('Time filter changed to:', range);
this.showNotification(`Menampilkan data ${this.getRangeDisplayName(range)}`, 'info');
}
handleRecommendation(recommendationId) {
// Handle recommendation action
this.showNotification(`Menjalankan rekomendasi #${recommendationId}`, 'success');
// Simulate API call
setTimeout(() => {
this.showNotification('Tindakan berhasil dijalankan', 'success');
}, 1500);
}
handleSensorClick(sensorCard) {
const sensorType = sensorCard.querySelector('.sensor-label').textContent;
const sensorValue = sensorCard.querySelector('.sensor-value').textContent;
this.showNotification(`Membuka detail ${sensorType}: ${sensorValue}`, 'info');
// In a real app, this would open a detailed sensor view
}
// ===== THEME MANAGEMENT =====
toggleTheme() {
this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', this.currentTheme);
localStorage.setItem('dashboard-theme', this.currentTheme);
this.showNotification(`Tema diubah ke ${this.currentTheme === 'dark' ? 'gelap' : 'terang'}`, 'success');
}
// ===== UTILITY FUNCTIONS =====
generateTimeLabels(count) {
return Array.from({ length: count }, (_, i) => {
const date = new Date(Date.now() - (count - i - 1) * 3600000);
return date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
});
}
calculateTrend(data, field) {
if (data.length < 2) return 'stable';
const recent = data.slice(-6); // Last 6 data points
const first = recent[0][field];
const last = recent[recent.length - 1][field];
if (last > first * 1.02) return 'up';
if (last < first * 0.98) return 'down';
return 'stable';
}
getRecommendationIcon(type) {
const icons = {
irrigation: 'tint',
fertilization: 'flask',
pest_control: 'bug',
harvest: 'sickle'
};
return icons[type] || 'robot';
}
getRangeDisplayName(range) {
const ranges = {
'1h': '1 jam terakhir',
'24h': '24 jam terakhir',
'7d': '7 hari terakhir',
'30d': '30 hari terakhir'
};
return ranges[range] || range;
}
formatTimeAgo(timestamp) {
const now = new Date();
const time = new Date(timestamp);
const diffMs = now - time;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Baru saja';
if (diffMins < 60) return `${diffMins} menit lalu`;
if (diffHours < 24) return `${diffHours} jam lalu`;
return `${diffDays} hari lalu`;
}
randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
showNotification(message, type = 'info') {
// Use the main app's notification system if available
if (window.FarmerSmartAI && window.FarmerSmartAI.showNotification) {
window.FarmerSmartAI.showNotification(message, type);
} else {
// Fallback notification
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
showError(message) {
this.showNotification(message, 'error');
}
// ===== CLEANUP =====
destroy() {
if (this.realTimeInterval) {
clearInterval(this.realTimeInterval);
}
this.charts.forEach(chart => {
if (chart && typeof chart.destroy === 'function') {
chart.destroy();
}
});
this.charts.clear();
this.isInitialized = false;
console.log('Dashboard destroyed');
}
}
// ===== GLOBAL DASHBOARD INSTANCE =====
let dashboard;
document.addEventListener('DOMContentLoaded', function() {
dashboard = new DashboardManager();
dashboard.init().catch(console.error);
// Make dashboard globally available
window.dashboard = dashboard;
});
// Handle page visibility changes
document.addEventListener('visibilitychange', function() {
if (dashboard) {
if (document.hidden) {
// Page is hidden, reduce update frequency
if (dashboard.realTimeInterval) {
clearInterval(dashboard.realTimeInterval);
dashboard.realTimeInterval = setInterval(() => {
dashboard.updateRealTimeData();
}, 120000); // 2 minutes when not visible
}
} else {
// Page is visible, resume normal updates
if (dashboard.realTimeInterval) {
clearInterval(dashboard.realTimeInterval);
dashboard.realTimeInterval = setInterval(() => {
dashboard.updateRealTimeData();
}, 30000); // 30 seconds when visible
}
}
}
});
// Handle page unload
window.addEventListener('beforeunload', function() {
if (dashboard) {
dashboard.destroy();
}
});
```
Cara Penggunaan:
1. Simpan file sebagai dashboard.js di folder JS project Anda
2. Hubungkan ke HTML dengan menambahkan sebelum penutup </body> setelah main.js:
```html
<script src="js/main.js"></script>
<script src="js/dashboard.js"></script>
```
Fitur Utama Dashboard.js:
π Chart Management
· Soil Moisture Chart: Trend kelembaban tanah 24 jam
· Temperature & Humidity Chart: Dual-axis chart untuk suhu dan kelembaban
· Nutrient Radar Chart: Visualisasi level nutrisi tanaman
· Yield Prediction Chart: Prediksi hasil panen per area
· Sensor Health Chart: Status kesehatan sensor IoT
π Real-time Updates
· Data sensor update setiap 30 detik
· Optimized performance dengan update 'none'
· Auto-pause saat tab tidak aktif
· Efficient memory management
π€️ Weather Integration
· Current weather display
· 5-day forecast
· Automatic hourly updates
· Weather condition icons
⚡ Sensor Monitoring
· Real-time sensor status
· Automatic offline detection
· Status change notifications
· Click interactions
π― AI Recommendations
· Priority-based recommendation system
· Actionable recommendations
· Timestamp tracking
· Interactive buttons
π± Responsive Interactions
· Mobile-friendly event handlers
· Touch-optimized interfaces
· Adaptive update frequencies
· Cross-browser compatibility
π¨ Theme Management
· Light/dark theme toggle
· Persistent theme storage
· System preference detection
· Smooth transitions
π§ Performance Features
· Debounced chart updates
· Efficient data management
· Memory leak prevention
· Graceful error handling
π Data Analytics
· Trend calculations
· Statistical analysis
· Predictive indicators
· Performance metrics
File ini memberikan dashboard yang hidup dan interaktif dengan update real-time, visualisasi data yang kaya, dan manajemen state yang robust untuk aplikasi FarmerSmartAI.
π‘πΎπππΊππ»πππ ππΎ: πͺππ½πΎ π¨ππ π₯ππ πΎ sensor-data.js
Comments
Post a Comment