homebridge-adaptive-home 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +148 -0
- package/config.schema.json +62 -0
- package/homebridge-ui/public/index.html +340 -0
- package/homebridge-ui/server.js +47 -0
- package/index.js +10 -0
- package/package.json +53 -0
- package/src/accessories/mode-switch.js +23 -0
- package/src/accessories/routine-sensor.js +42 -0
- package/src/analyzer.js +190 -0
- package/src/event-logger.js +95 -0
- package/src/platform.js +251 -0
- package/src/suggestions.js +112 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.0.0] - 2026-05-17
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release
|
|
7
|
+
- Automatic behavior pattern learning from all HomeKit accessories
|
|
8
|
+
- Statistical pattern analysis with configurable confidence threshold
|
|
9
|
+
- Suggestion dashboard with approve/reject workflow
|
|
10
|
+
- 5 virtual HomeKit accessories: Adaptive Learning switch, Morning/Evening/Sleep routine contact sensors, Away Mode occupancy sensor
|
|
11
|
+
- 7-day activity heatmap in the Homebridge UI dashboard
|
|
12
|
+
- Rolling 30-day event log with configurable retention
|
|
13
|
+
- Correlated device detection (finds devices that change together)
|
|
14
|
+
- Configurable polling interval, analysis schedule, and confidence threshold
|
|
15
|
+
- No extra hardware required — works with all existing HomeKit devices
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# homebridge-adaptive-home
|
|
2
|
+
|
|
3
|
+
> **Your home learns from you. Not the other way around.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/homebridge-adaptive-home)
|
|
6
|
+
[](https://www.npmjs.com/package/homebridge-adaptive-home)
|
|
7
|
+
[](https://homebridge.io)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## The Problem
|
|
13
|
+
|
|
14
|
+
Every smart home platform — HomeKit included — requires you to manually program every automation. You decide the rules. You set the schedules. You maintain everything when your life changes.
|
|
15
|
+
|
|
16
|
+
That's not smart. That's just remote control.
|
|
17
|
+
|
|
18
|
+
## The Solution
|
|
19
|
+
|
|
20
|
+
**homebridge-adaptive-home** silently watches how you use your home. After a few days, it starts recognising your patterns — when you wake up, when you wind down, when you leave. Then it suggests automations for you to approve with a single click.
|
|
21
|
+
|
|
22
|
+
**No new hardware. No cloud. 100% local. 100% private.**
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## How It Works
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Week 1: 🔍 Plugin observes all your HomeKit device activity
|
|
30
|
+
Week 2: 💡 "Every morning at 7:15, your kitchen lights turn on. Automate it?"
|
|
31
|
+
Week 3: ✅ Your home runs the routines you approved — and keeps learning
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **Zero hardware requirements** — works with every HomeKit device you already own
|
|
39
|
+
- **Fully local** — no cloud, no AI subscription, no data leaves your home
|
|
40
|
+
- **Statistical pattern detection** — finds recurring time-based routines with configurable confidence thresholds
|
|
41
|
+
- **Correlated device detection** — discovers which devices you use together (TV on + lights dim = movie routine)
|
|
42
|
+
- **5 virtual HomeKit accessories** — use detected routines directly in your HomeKit automations
|
|
43
|
+
- **Interactive dashboard** — approve or dismiss suggestions from the Homebridge UI
|
|
44
|
+
- **Activity heatmap** — visualise your weekly device usage patterns
|
|
45
|
+
- **Adaptive** — adjusts as your behaviour changes over time
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Virtual Accessories
|
|
50
|
+
|
|
51
|
+
The plugin creates 5 accessories in HomeKit that you can use in any automation:
|
|
52
|
+
|
|
53
|
+
| Accessory | Type | Active when |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| **Adaptive Learning** | Switch | ON = learning enabled |
|
|
56
|
+
| **Morning Routine** | Contact Sensor | Open during your detected morning window |
|
|
57
|
+
| **Evening Routine** | Contact Sensor | Open during your detected evening window |
|
|
58
|
+
| **Sleep Time** | Contact Sensor | Open during your detected sleep window |
|
|
59
|
+
| **Away Mode** | Occupancy Sensor | Occupied = away pattern detected |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
### Via Homebridge UI (recommended)
|
|
66
|
+
1. Open Homebridge → **Plugins** tab
|
|
67
|
+
2. Search for `homebridge-adaptive-home`
|
|
68
|
+
3. Click **Install**
|
|
69
|
+
|
|
70
|
+
### Via CLI
|
|
71
|
+
```bash
|
|
72
|
+
npm install -g homebridge-adaptive-home
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
Add to your `config.json` under `platforms`:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"platform": "AdaptiveHome",
|
|
84
|
+
"name": "Adaptive Home"
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Full options
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"platform": "AdaptiveHome",
|
|
93
|
+
"name": "Adaptive Home",
|
|
94
|
+
"uiPort": 8581,
|
|
95
|
+
"uiToken": "",
|
|
96
|
+
"analysisIntervalHours": 24,
|
|
97
|
+
"minConfidence": 60,
|
|
98
|
+
"retentionDays": 30,
|
|
99
|
+
"pollIntervalSeconds": 60
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Field | Default | Description |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `uiPort` | `8581` | Homebridge Config UI X port |
|
|
106
|
+
| `uiToken` | auto | Auth token — leave blank to auto-discover |
|
|
107
|
+
| `analysisIntervalHours` | `24` | How often to run pattern analysis |
|
|
108
|
+
| `minConfidence` | `60` | Minimum confidence (%) to show a suggestion |
|
|
109
|
+
| `retentionDays` | `30` | Days of event history to keep |
|
|
110
|
+
| `pollIntervalSeconds` | `60` | How often to check for device changes |
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Dashboard
|
|
115
|
+
|
|
116
|
+
Open the plugin in Homebridge UI to access the dashboard:
|
|
117
|
+
|
|
118
|
+
- **Status bar** — learning on/off, total events, days observed, routines found
|
|
119
|
+
- **Suggestions** — approve or dismiss detected routine suggestions
|
|
120
|
+
- **Detected Routines** — all routines with confidence scores and peak times
|
|
121
|
+
- **Activity Heatmap** — 7-day × 48-bucket visualisation of your device usage
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Requirements
|
|
126
|
+
|
|
127
|
+
- Homebridge ≥ 1.6.0
|
|
128
|
+
- Node.js ≥ 18.0.0
|
|
129
|
+
- Homebridge Config UI X (for the dashboard and accessory polling)
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Privacy
|
|
134
|
+
|
|
135
|
+
All data is stored locally in your Homebridge storage directory (`~/.homebridge/adaptive-home/`). Nothing is sent to any server. The plugin only communicates with your own Homebridge instance on localhost.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Support
|
|
140
|
+
|
|
141
|
+
- **Issues:** [github.com/azadaydinli/homebridge-adaptive-home/issues](https://github.com/azadaydinli/homebridge-adaptive-home/issues)
|
|
142
|
+
- **Buy me a coffee:** [ko-fi.com/azadaydinli](https://ko-fi.com/azadaydinli)
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT © [Azad Aydınlı](https://github.com/azadaydinli)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "AdaptiveHome",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"customUi": true,
|
|
5
|
+
"headerDisplay": "Adaptive Home — Your home learns from you.",
|
|
6
|
+
"schema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"title": "Platform Name",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"default": "Adaptive Home"
|
|
13
|
+
},
|
|
14
|
+
"uiPort": {
|
|
15
|
+
"title": "Homebridge UI Port",
|
|
16
|
+
"description": "Port of the Homebridge Config UI X. Default is 8581.",
|
|
17
|
+
"type": "integer",
|
|
18
|
+
"default": 8581,
|
|
19
|
+
"minimum": 1,
|
|
20
|
+
"maximum": 65535
|
|
21
|
+
},
|
|
22
|
+
"uiToken": {
|
|
23
|
+
"title": "Homebridge UI Auth Token",
|
|
24
|
+
"description": "Optional. Leave blank to auto-discover from Homebridge storage.",
|
|
25
|
+
"type": "string"
|
|
26
|
+
},
|
|
27
|
+
"analysisIntervalHours": {
|
|
28
|
+
"title": "Analysis Interval (hours)",
|
|
29
|
+
"description": "How often to run pattern analysis. Default is every 24 hours.",
|
|
30
|
+
"type": "integer",
|
|
31
|
+
"default": 24,
|
|
32
|
+
"minimum": 1,
|
|
33
|
+
"maximum": 168
|
|
34
|
+
},
|
|
35
|
+
"minConfidence": {
|
|
36
|
+
"title": "Minimum Confidence (%)",
|
|
37
|
+
"description": "Only show suggestions above this confidence threshold. Default is 60%.",
|
|
38
|
+
"type": "integer",
|
|
39
|
+
"default": 60,
|
|
40
|
+
"minimum": 10,
|
|
41
|
+
"maximum": 100
|
|
42
|
+
},
|
|
43
|
+
"retentionDays": {
|
|
44
|
+
"title": "Event Retention (days)",
|
|
45
|
+
"description": "How many days of events to keep for analysis. Default is 30 days.",
|
|
46
|
+
"type": "integer",
|
|
47
|
+
"default": 30,
|
|
48
|
+
"minimum": 7,
|
|
49
|
+
"maximum": 365
|
|
50
|
+
},
|
|
51
|
+
"pollIntervalSeconds": {
|
|
52
|
+
"title": "Poll Interval (seconds)",
|
|
53
|
+
"description": "How often to poll Homebridge for accessory state changes. Default is 60 seconds.",
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"default": 60,
|
|
56
|
+
"minimum": 10,
|
|
57
|
+
"maximum": 300
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"required": ["name"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
:root {
|
|
3
|
+
--bg: #fff;
|
|
4
|
+
--text: #1a1a2e;
|
|
5
|
+
--muted: #6c757d;
|
|
6
|
+
--border: #e0e0e0;
|
|
7
|
+
--card: #f8f9fa;
|
|
8
|
+
--input-bg: #fff;
|
|
9
|
+
--accent: #5c6bc0;
|
|
10
|
+
--accent2: #26a69a;
|
|
11
|
+
--success: #43a047;
|
|
12
|
+
--danger: #e53935;
|
|
13
|
+
--warn: #fb8c00;
|
|
14
|
+
--morning: #ff9800;
|
|
15
|
+
--evening: #5c6bc0;
|
|
16
|
+
--sleep: #7c4dff;
|
|
17
|
+
--away: #26a69a;
|
|
18
|
+
--radius: 12px;
|
|
19
|
+
}
|
|
20
|
+
@media (prefers-color-scheme: dark) {
|
|
21
|
+
:root {
|
|
22
|
+
--bg: transparent;
|
|
23
|
+
--text: #e8eaf6;
|
|
24
|
+
--muted: #9e9e9e;
|
|
25
|
+
--border: #333;
|
|
26
|
+
--card: rgba(255,255,255,0.06);
|
|
27
|
+
--input-bg: rgba(255,255,255,0.08);
|
|
28
|
+
--accent: #7986cb;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
32
|
+
body {
|
|
33
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
34
|
+
color: var(--text); background: var(--bg); font-size: 14px; line-height: 1.5;
|
|
35
|
+
}
|
|
36
|
+
.page { padding: 0 0 32px; }
|
|
37
|
+
|
|
38
|
+
/* Hero */
|
|
39
|
+
.hero {
|
|
40
|
+
background: linear-gradient(135deg, #3f51b5 0%, #7c4dff 100%);
|
|
41
|
+
color: #fff; padding: 24px 20px 20px; border-radius: 0 0 var(--radius) var(--radius);
|
|
42
|
+
margin-bottom: 20px;
|
|
43
|
+
}
|
|
44
|
+
.hero h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
|
|
45
|
+
.hero p { font-size: 13px; opacity: 0.85; }
|
|
46
|
+
.stats-row {
|
|
47
|
+
display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap;
|
|
48
|
+
}
|
|
49
|
+
.stat-chip {
|
|
50
|
+
background: rgba(255,255,255,0.18); border-radius: 20px;
|
|
51
|
+
padding: 4px 14px; font-size: 12px; font-weight: 600;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Sections */
|
|
55
|
+
.section { margin: 0 16px 20px; }
|
|
56
|
+
.section-title {
|
|
57
|
+
font-size: 13px; font-weight: 700; text-transform: uppercase;
|
|
58
|
+
letter-spacing: .5px; color: var(--muted); margin-bottom: 10px;
|
|
59
|
+
display: flex; align-items: center; gap: 6px;
|
|
60
|
+
}
|
|
61
|
+
.badge {
|
|
62
|
+
background: var(--accent); color: #fff;
|
|
63
|
+
border-radius: 10px; padding: 1px 8px; font-size: 11px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Cards */
|
|
67
|
+
.card {
|
|
68
|
+
background: var(--card); border: 1px solid var(--border);
|
|
69
|
+
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 10px;
|
|
70
|
+
}
|
|
71
|
+
.card-header {
|
|
72
|
+
display: flex; justify-content: space-between; align-items: flex-start;
|
|
73
|
+
margin-bottom: 6px;
|
|
74
|
+
}
|
|
75
|
+
.card-title { font-weight: 600; font-size: 14px; }
|
|
76
|
+
.card-body { font-size: 13px; color: var(--muted); line-height: 1.6; }
|
|
77
|
+
|
|
78
|
+
/* Confidence bar */
|
|
79
|
+
.conf-wrap { margin-top: 10px; }
|
|
80
|
+
.conf-label { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
81
|
+
.conf-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
|
|
82
|
+
.conf-fill { height: 100%; border-radius: 3px; transition: width .4s; }
|
|
83
|
+
|
|
84
|
+
/* Type badges */
|
|
85
|
+
.type-pill {
|
|
86
|
+
font-size: 11px; font-weight: 700; padding: 2px 10px;
|
|
87
|
+
border-radius: 12px; text-transform: uppercase; letter-spacing: .3px;
|
|
88
|
+
}
|
|
89
|
+
.type-morning { background: rgba(255,152,0,.15); color: var(--morning); }
|
|
90
|
+
.type-evening { background: rgba(92,107,192,.15); color: var(--evening); }
|
|
91
|
+
.type-sleep { background: rgba(124,77,255,.15); color: var(--sleep); }
|
|
92
|
+
.type-away { background: rgba(38,166,154,.15); color: var(--away); }
|
|
93
|
+
|
|
94
|
+
/* Suggestion actions */
|
|
95
|
+
.actions { display: flex; gap: 8px; margin-top: 12px; }
|
|
96
|
+
.btn {
|
|
97
|
+
padding: 7px 16px; border: none; border-radius: 8px;
|
|
98
|
+
font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity .15s;
|
|
99
|
+
}
|
|
100
|
+
.btn:hover { opacity: .82; }
|
|
101
|
+
.btn-approve { background: var(--success); color: #fff; }
|
|
102
|
+
.btn-reject { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
|
|
103
|
+
|
|
104
|
+
/* Devices row */
|
|
105
|
+
.devices-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
106
|
+
.device-chip {
|
|
107
|
+
background: var(--border); border-radius: 10px;
|
|
108
|
+
padding: 2px 10px; font-size: 11px; color: var(--muted);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Heatmap */
|
|
112
|
+
.heatmap { display: grid; grid-template-columns: 36px repeat(48, 1fr); gap: 2px; margin-top: 8px; }
|
|
113
|
+
.heatmap-label { font-size: 10px; color: var(--muted); display: flex; align-items: center; justify-content: flex-end; padding-right: 4px; }
|
|
114
|
+
.heatmap-cell { height: 14px; border-radius: 2px; background: var(--border); }
|
|
115
|
+
.heatmap-hours { display: grid; grid-template-columns: 36px repeat(12, 4fr); gap: 2px; margin-top: 2px; }
|
|
116
|
+
.heatmap-hour-label { font-size: 10px; color: var(--muted); text-align: center; }
|
|
117
|
+
|
|
118
|
+
/* Empty state */
|
|
119
|
+
.empty {
|
|
120
|
+
text-align: center; padding: 32px 20px; color: var(--muted);
|
|
121
|
+
}
|
|
122
|
+
.empty .icon { font-size: 40px; margin-bottom: 12px; }
|
|
123
|
+
.empty p { font-size: 13px; line-height: 1.7; }
|
|
124
|
+
|
|
125
|
+
/* Status pill */
|
|
126
|
+
.status-dot {
|
|
127
|
+
display: inline-block; width: 8px; height: 8px;
|
|
128
|
+
border-radius: 50%; margin-right: 6px;
|
|
129
|
+
}
|
|
130
|
+
.dot-green { background: var(--success); }
|
|
131
|
+
.dot-grey { background: var(--muted); }
|
|
132
|
+
|
|
133
|
+
/* Loading */
|
|
134
|
+
.loading { text-align: center; padding: 40px; color: var(--muted); }
|
|
135
|
+
|
|
136
|
+
/* Meta row */
|
|
137
|
+
.meta-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
|
|
138
|
+
.meta-item { font-size: 12px; color: var(--muted); }
|
|
139
|
+
.meta-item strong { color: var(--text); }
|
|
140
|
+
</style>
|
|
141
|
+
|
|
142
|
+
<div class="page" id="app">
|
|
143
|
+
<div class="loading">Loading Adaptive Home...</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<script>
|
|
147
|
+
(async () => {
|
|
148
|
+
const app = document.getElementById('app');
|
|
149
|
+
let status = null;
|
|
150
|
+
|
|
151
|
+
function confColor(c) {
|
|
152
|
+
if (c >= 0.8) return '#43a047';
|
|
153
|
+
if (c >= 0.6) return '#fb8c00';
|
|
154
|
+
return '#e53935';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fmtTime(h, m) {
|
|
158
|
+
return `${String(h).padStart(2,'0')}:${String(m || 0).padStart(2,'0')}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function typePill(type) {
|
|
162
|
+
return `<span class="type-pill type-${type}">${type}</span>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderHero(s) {
|
|
166
|
+
const dot = s.learningEnabled
|
|
167
|
+
? `<span class="status-dot dot-green"></span>Learning Active`
|
|
168
|
+
: `<span class="status-dot dot-grey"></span>Learning Paused`;
|
|
169
|
+
return `
|
|
170
|
+
<div class="hero">
|
|
171
|
+
<h1>Adaptive Home</h1>
|
|
172
|
+
<p>Your home learns your routines and suggests automations.</p>
|
|
173
|
+
<div class="stats-row">
|
|
174
|
+
<span class="stat-chip">${dot}</span>
|
|
175
|
+
<span class="stat-chip">${s.eventsLogged} events logged</span>
|
|
176
|
+
<span class="stat-chip">${s.daysObserved} days observed</span>
|
|
177
|
+
<span class="stat-chip">${(s.routines || []).length} routines detected</span>
|
|
178
|
+
</div>
|
|
179
|
+
</div>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderRoutines(routines) {
|
|
183
|
+
if (!routines || routines.length === 0) {
|
|
184
|
+
return `
|
|
185
|
+
<div class="section">
|
|
186
|
+
<div class="section-title">Detected Routines</div>
|
|
187
|
+
<div class="empty">
|
|
188
|
+
<div class="icon">🔍</div>
|
|
189
|
+
<p>No routines detected yet.<br>Keep Homebridge running for a few days<br>and check back here.</p>
|
|
190
|
+
</div>
|
|
191
|
+
</div>`;
|
|
192
|
+
}
|
|
193
|
+
const cards = routines.map(r => {
|
|
194
|
+
const conf = Math.round(r.confidence * 100);
|
|
195
|
+
const corrHtml = r.correlatedDevices && r.correlatedDevices.length
|
|
196
|
+
? `<div class="devices-row">${r.correlatedDevices.map(d => `<span class="device-chip">${d}</span>`).join('')}</div>`
|
|
197
|
+
: '';
|
|
198
|
+
return `
|
|
199
|
+
<div class="card">
|
|
200
|
+
<div class="card-header">
|
|
201
|
+
<span class="card-title">${r.accessory}</span>
|
|
202
|
+
${typePill(r.type)}
|
|
203
|
+
</div>
|
|
204
|
+
<div class="card-body">${r.label}</div>
|
|
205
|
+
<div class="meta-row">
|
|
206
|
+
<span class="meta-item">Peak: <strong>${fmtTime(r.peakHour, r.peakMinute)}</strong></span>
|
|
207
|
+
<span class="meta-item">Seen: <strong>${r.daysDetected} days</strong></span>
|
|
208
|
+
<span class="meta-item">Events: <strong>${r.occurrences}</strong></span>
|
|
209
|
+
</div>
|
|
210
|
+
${corrHtml}
|
|
211
|
+
<div class="conf-wrap">
|
|
212
|
+
<div class="conf-label"><span>Confidence</span><span>${conf}%</span></div>
|
|
213
|
+
<div class="conf-bar"><div class="conf-fill" style="width:${conf}%;background:${confColor(r.confidence)}"></div></div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>`;
|
|
216
|
+
}).join('');
|
|
217
|
+
return `
|
|
218
|
+
<div class="section">
|
|
219
|
+
<div class="section-title">Detected Routines <span class="badge">${routines.length}</span></div>
|
|
220
|
+
${cards}
|
|
221
|
+
</div>`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderSuggestions(suggestions) {
|
|
225
|
+
if (!suggestions || suggestions.length === 0) {
|
|
226
|
+
return `
|
|
227
|
+
<div class="section">
|
|
228
|
+
<div class="section-title">Suggestions</div>
|
|
229
|
+
<div class="empty">
|
|
230
|
+
<div class="icon">💡</div>
|
|
231
|
+
<p>No suggestions yet.<br>When a routine reaches the confidence<br>threshold, it will appear here.</p>
|
|
232
|
+
</div>
|
|
233
|
+
</div>`;
|
|
234
|
+
}
|
|
235
|
+
const cards = suggestions.map(s => {
|
|
236
|
+
const conf = Math.round(s.confidence * 100);
|
|
237
|
+
return `
|
|
238
|
+
<div class="card" id="sug-${s.id}">
|
|
239
|
+
<div class="card-header">
|
|
240
|
+
<span class="card-title">${s.accessory}</span>
|
|
241
|
+
${typePill(s.type)}
|
|
242
|
+
</div>
|
|
243
|
+
<div class="card-body">${s.suggestion}</div>
|
|
244
|
+
<div class="conf-wrap">
|
|
245
|
+
<div class="conf-label"><span>Confidence</span><span>${conf}%</span></div>
|
|
246
|
+
<div class="conf-bar"><div class="conf-fill" style="width:${conf}%;background:${confColor(s.confidence)}"></div></div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="actions">
|
|
249
|
+
<button class="btn btn-approve" data-id="${s.id}" onclick="handleApprove('${s.id}')">Approve</button>
|
|
250
|
+
<button class="btn btn-reject" data-id="${s.id}" onclick="handleReject('${s.id}')">Dismiss</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>`;
|
|
253
|
+
}).join('');
|
|
254
|
+
return `
|
|
255
|
+
<div class="section">
|
|
256
|
+
<div class="section-title">Suggestions <span class="badge">${suggestions.length}</span></div>
|
|
257
|
+
${cards}
|
|
258
|
+
</div>`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function renderHeatmap(events) {
|
|
262
|
+
if (!events || events.length === 0) return '';
|
|
263
|
+
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
|
264
|
+
const BUCKETS = 48;
|
|
265
|
+
const grid = Array.from({ length: 7 }, () => new Array(BUCKETS).fill(0));
|
|
266
|
+
for (const e of events) {
|
|
267
|
+
const b = Math.floor((e.hour * 60 + e.min) / 30);
|
|
268
|
+
if (e.dow >= 0 && e.dow < 7 && b >= 0 && b < BUCKETS) grid[e.dow][b]++;
|
|
269
|
+
}
|
|
270
|
+
const max = Math.max(1, ...grid.flat());
|
|
271
|
+
|
|
272
|
+
const rows = grid.map((row, d) => {
|
|
273
|
+
const cells = row.map(v => {
|
|
274
|
+
const opacity = v > 0 ? 0.15 + (v / max) * 0.85 : 0;
|
|
275
|
+
const bg = v > 0 ? `rgba(92,107,192,${opacity.toFixed(2)})` : 'var(--border)';
|
|
276
|
+
return `<div class="heatmap-cell" style="background:${bg}" title="${v} events"></div>`;
|
|
277
|
+
}).join('');
|
|
278
|
+
return `<div class="heatmap-label">${days[d]}</div>${cells}`;
|
|
279
|
+
}).join('');
|
|
280
|
+
|
|
281
|
+
const hourLabels = ['','3','','6','','9','','12','','15','','18','','21',''].join('</div><div class="heatmap-hour-label">');
|
|
282
|
+
|
|
283
|
+
return `
|
|
284
|
+
<div class="section">
|
|
285
|
+
<div class="section-title">Activity Heatmap (30-min buckets)</div>
|
|
286
|
+
<div class="card">
|
|
287
|
+
<div class="heatmap">${rows}</div>
|
|
288
|
+
<div class="heatmap-hours">
|
|
289
|
+
<div></div>
|
|
290
|
+
<div class="heatmap-hour-label">0</div>
|
|
291
|
+
<div class="heatmap-hour-label">3</div>
|
|
292
|
+
<div class="heatmap-hour-label">6</div>
|
|
293
|
+
<div class="heatmap-hour-label">9</div>
|
|
294
|
+
<div class="heatmap-hour-label">12</div>
|
|
295
|
+
<div class="heatmap-hour-label">15</div>
|
|
296
|
+
<div class="heatmap-hour-label">18</div>
|
|
297
|
+
<div class="heatmap-hour-label">21</div>
|
|
298
|
+
<div class="heatmap-hour-label">24</div>
|
|
299
|
+
<div class="heatmap-hour-label"></div>
|
|
300
|
+
<div class="heatmap-hour-label"></div>
|
|
301
|
+
<div class="heatmap-hour-label"></div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
window.handleApprove = async (id) => {
|
|
308
|
+
try {
|
|
309
|
+
await homebridge.request('/suggestion/approve', { id });
|
|
310
|
+
await refresh();
|
|
311
|
+
} catch(e) { console.error(e); }
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
window.handleReject = async (id) => {
|
|
315
|
+
try {
|
|
316
|
+
await homebridge.request('/suggestion/reject', { id });
|
|
317
|
+
await refresh();
|
|
318
|
+
} catch(e) { console.error(e); }
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
async function refresh() {
|
|
322
|
+
try {
|
|
323
|
+
status = await homebridge.request('/status', {});
|
|
324
|
+
} catch(e) {
|
|
325
|
+
status = { learningEnabled: false, eventsLogged: 0, daysObserved: 0, routines: [], suggestions: [] };
|
|
326
|
+
}
|
|
327
|
+
const eventsForHeatmap = status.recentEvents || [];
|
|
328
|
+
app.innerHTML =
|
|
329
|
+
renderHero(status) +
|
|
330
|
+
renderSuggestions(status.suggestions) +
|
|
331
|
+
renderRoutines(status.routines) +
|
|
332
|
+
renderHeatmap(eventsForHeatmap);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await refresh();
|
|
336
|
+
|
|
337
|
+
// Auto-refresh every 60 seconds
|
|
338
|
+
setInterval(refresh, 60000);
|
|
339
|
+
})();
|
|
340
|
+
</script>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Custom UI server — handles homebridge.request() calls from the dashboard
|
|
4
|
+
module.exports = (api) => {
|
|
5
|
+
api.onRequest('/status', async (body) => {
|
|
6
|
+
const platform = getPlatform(api);
|
|
7
|
+
if (!platform) return emptyStatus();
|
|
8
|
+
const s = platform.getStatus();
|
|
9
|
+
s.recentEvents = platform.eventLogger.getEvents(7);
|
|
10
|
+
return s;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
api.onRequest('/suggestion/approve', async (body) => {
|
|
14
|
+
const platform = getPlatform(api);
|
|
15
|
+
if (!platform) return { ok: false };
|
|
16
|
+
return { ok: platform.approveSuggestion(body.id) };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
api.onRequest('/suggestion/reject', async (body) => {
|
|
20
|
+
const platform = getPlatform(api);
|
|
21
|
+
if (!platform) return { ok: false };
|
|
22
|
+
return { ok: platform.rejectSuggestion(body.id) };
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function getPlatform(api) {
|
|
27
|
+
try {
|
|
28
|
+
const platforms = api.homebridgePluginManager
|
|
29
|
+
&& api.homebridgePluginManager.getInstalledPlugins
|
|
30
|
+
&& api.homebridgePluginManager.getInstalledPlugins();
|
|
31
|
+
// Access via global homebridge instance stored by the platform on init
|
|
32
|
+
if (global._adaptiveHomePlatform) return global._adaptiveHomePlatform;
|
|
33
|
+
} catch {}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function emptyStatus() {
|
|
38
|
+
return {
|
|
39
|
+
learningEnabled: false,
|
|
40
|
+
eventsLogged: 0,
|
|
41
|
+
daysObserved: 0,
|
|
42
|
+
lastAnalysis: null,
|
|
43
|
+
suggestions: [],
|
|
44
|
+
routines: [],
|
|
45
|
+
recentEvents: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AdaptiveHomePlatform } = require('./src/platform');
|
|
4
|
+
|
|
5
|
+
const PLUGIN_NAME = 'homebridge-adaptive-home';
|
|
6
|
+
const PLATFORM_NAME = 'AdaptiveHome';
|
|
7
|
+
|
|
8
|
+
module.exports = (api) => {
|
|
9
|
+
api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, AdaptiveHomePlatform);
|
|
10
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-adaptive-home",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The first Homebridge plugin that learns your behavior patterns and automatically suggests HomeKit automations. No new hardware required — works with all your existing HomeKit devices.",
|
|
5
|
+
"homepage": "https://github.com/azadaydinli/homebridge-adaptive-home",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"author": "Azad Aydınlı",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"lint": "echo \"No linter configured\"",
|
|
11
|
+
"test": "echo \"No tests configured\""
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/azadaydinli/homebridge-adaptive-home/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"homebridge-plugin",
|
|
18
|
+
"homebridge",
|
|
19
|
+
"homekit",
|
|
20
|
+
"adaptive",
|
|
21
|
+
"learning",
|
|
22
|
+
"automation",
|
|
23
|
+
"smart-home",
|
|
24
|
+
"ai",
|
|
25
|
+
"pattern",
|
|
26
|
+
"routine",
|
|
27
|
+
"behavior",
|
|
28
|
+
"intelligent",
|
|
29
|
+
"machine-learning",
|
|
30
|
+
"home-automation",
|
|
31
|
+
"virtual",
|
|
32
|
+
"sensor",
|
|
33
|
+
"schedule"
|
|
34
|
+
],
|
|
35
|
+
"funding": [
|
|
36
|
+
{
|
|
37
|
+
"type": "ko_fi",
|
|
38
|
+
"url": "https://ko-fi.com/azadaydinli"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"type": "github",
|
|
42
|
+
"url": "https://github.com/sponsors/azadaydinli"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/azadaydinli/homebridge-adaptive-home.git"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18.0.0",
|
|
51
|
+
"homebridge": ">=1.6.0"
|
|
52
|
+
}
|
|
53
|
+
}
|