@ecopoesis/homebridge-dmx 0.2.0 → 0.4.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/CLAUDE.md +1 -0
- package/ENCRYPTION.md +207 -0
- package/PROTOCOL.md +277 -0
- package/README.md +22 -0
- package/config.schema.json +8 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/controller.d.ts +14 -3
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +41 -5
- package/dist/controller.js.map +1 -1
- package/dist/platform.d.ts +1 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +8 -6
- package/dist/platform.js.map +1 -1
- package/dist/platformAccessory.d.ts +5 -5
- package/dist/platformAccessory.d.ts.map +1 -1
- package/dist/platformAccessory.js +58 -29
- package/dist/platformAccessory.js.map +1 -1
- package/dist/settings.d.ts +9 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +9 -0
- package/dist/settings.js.map +1 -1
- package/dist/stateRegistry.d.ts +21 -0
- package/dist/stateRegistry.d.ts.map +1 -0
- package/dist/stateRegistry.js +65 -0
- package/dist/stateRegistry.js.map +1 -0
- package/dist/zoneAccessory.d.ts +20 -4
- package/dist/zoneAccessory.d.ts.map +1 -1
- package/dist/zoneAccessory.js +103 -40
- package/dist/zoneAccessory.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +10 -1
- package/src/controller.ts +39 -5
- package/src/platform.ts +8 -6
- package/src/platformAccessory.ts +53 -31
- package/src/settings.ts +11 -0
- package/src/stateRegistry.ts +71 -0
- package/src/zoneAccessory.ts +106 -40
- package/tools/stick-power-cycle.py +121 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// StateRegistry — central store of every fixture's HomeKit-visible state.
|
|
2
|
+
//
|
|
3
|
+
// Every StickFixture and ZoneFixture reads/writes through here. Whenever a
|
|
4
|
+
// fixture's state changes, listeners fire so zones can refresh their own
|
|
5
|
+
// HomeKit characteristics (and vice versa: setting a zone updates each
|
|
6
|
+
// member's state in the registry, which fires events, which prompt the
|
|
7
|
+
// per-fixture accessories to update their HomeKit-displayed state).
|
|
8
|
+
//
|
|
9
|
+
// Majority rule for zone state: see pickMajority() — count occurrences of
|
|
10
|
+
// each value across (alphabetically-sorted) members; largest count wins;
|
|
11
|
+
// ties are broken in favour of the value held by the alphabetically-first
|
|
12
|
+
// member.
|
|
13
|
+
|
|
14
|
+
import { HomeKitLightState } from './color/types.js';
|
|
15
|
+
|
|
16
|
+
export type StateChangeListener = (fixtureId: string) => void;
|
|
17
|
+
|
|
18
|
+
const DEFAULT_STATE: HomeKitLightState = { on: false, brightness: 100 };
|
|
19
|
+
|
|
20
|
+
export class StateRegistry {
|
|
21
|
+
private states = new Map<string, HomeKitLightState>();
|
|
22
|
+
private listeners = new Set<StateChangeListener>();
|
|
23
|
+
|
|
24
|
+
get(id: string): HomeKitLightState {
|
|
25
|
+
return this.states.get(id) ?? { ...DEFAULT_STATE };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Replace a fixture's state and fire change listeners. */
|
|
29
|
+
set(id: string, state: HomeKitLightState): void {
|
|
30
|
+
this.states.set(id, { ...state });
|
|
31
|
+
for (const l of this.listeners) l(id);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Merge a partial update into a fixture's state, returning the new full
|
|
35
|
+
* state. Fires listeners. */
|
|
36
|
+
update(id: string, patch: Partial<HomeKitLightState>): HomeKitLightState {
|
|
37
|
+
const next: HomeKitLightState = { ...this.get(id), ...patch };
|
|
38
|
+
this.set(id, next);
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
onChange(cb: StateChangeListener): () => void {
|
|
43
|
+
this.listeners.add(cb);
|
|
44
|
+
return () => this.listeners.delete(cb);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Bucket members' values; return the value with the largest bucket. Ties
|
|
49
|
+
* break in favour of the value held by the first member in
|
|
50
|
+
* alphabetical-id order. */
|
|
51
|
+
export function pickMajority<T>(items: Array<{ id: string; v: T }>): T | undefined {
|
|
52
|
+
if (items.length === 0) return undefined;
|
|
53
|
+
const sorted = items.slice().sort((a, b) => a.id.localeCompare(b.id));
|
|
54
|
+
const counts = new Map<T, { count: number; firstIdx: number }>();
|
|
55
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
56
|
+
const v = sorted[i].v;
|
|
57
|
+
const rec = counts.get(v);
|
|
58
|
+
if (rec) rec.count++;
|
|
59
|
+
else counts.set(v, { count: 1, firstIdx: i });
|
|
60
|
+
}
|
|
61
|
+
let bestV: T = sorted[0].v;
|
|
62
|
+
let bestRec = counts.get(bestV)!;
|
|
63
|
+
for (const [v, rec] of counts) {
|
|
64
|
+
if (rec.count > bestRec.count ||
|
|
65
|
+
(rec.count === bestRec.count && rec.firstIdx < bestRec.firstIdx)) {
|
|
66
|
+
bestV = v;
|
|
67
|
+
bestRec = rec;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return bestV;
|
|
71
|
+
}
|
package/src/zoneAccessory.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
// ZoneFixture — virtual HomeKit Lightbulb that
|
|
2
|
-
//
|
|
3
|
-
// zone's local state, then calls controller.setFixture for each member
|
|
4
|
-
// using the zone's current state. The single controller debounce
|
|
5
|
-
// coalesces all member updates into one subprocess transaction.
|
|
1
|
+
// ZoneFixture — virtual HomeKit Lightbulb that aggregates a set of member
|
|
2
|
+
// fixtures.
|
|
6
3
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// Reads: each .onGet returns the MAJORITY value of that characteristic
|
|
5
|
+
// across the zone's members. Ties are broken in favour of the value held
|
|
6
|
+
// by the alphabetically-first member. This gives a deterministic display
|
|
7
|
+
// even when members are in split states.
|
|
8
|
+
//
|
|
9
|
+
// Writes: each .onSet updates every member's state in the registry (which
|
|
10
|
+
// fires per-member change events so the individual fixture accessories
|
|
11
|
+
// refresh their own HomeKit displays), then triggers the controller for
|
|
12
|
+
// each member so the DMX wire reflects the change.
|
|
13
|
+
//
|
|
14
|
+
// When ANY member's state changes (by any path), the zone's HomeKit
|
|
15
|
+
// characteristics push the new majority — so zones containing overlapping
|
|
16
|
+
// members stay coherent with each other.
|
|
10
17
|
|
|
11
18
|
import type {
|
|
12
19
|
CharacteristicValue,
|
|
@@ -14,21 +21,24 @@ import type {
|
|
|
14
21
|
Service,
|
|
15
22
|
} from 'homebridge';
|
|
16
23
|
|
|
17
|
-
import { Zone } from './config.js';
|
|
24
|
+
import { Fixture, Zone } from './config.js';
|
|
18
25
|
import { HKCharacteristic, HomeKitLightState } from './color/types.js';
|
|
19
26
|
import { StickController } from './controller.js';
|
|
20
27
|
import type { DmxPlatform } from './platform.js';
|
|
21
28
|
import { CHARACTERISTIC_UPDATE_DELAY_MS } from './settings.js';
|
|
29
|
+
import { StateRegistry, pickMajority } from './stateRegistry.js';
|
|
22
30
|
|
|
23
31
|
export class ZoneFixture {
|
|
24
|
-
private state: HomeKitLightState = { on: false, brightness: 100 };
|
|
25
32
|
private pending: NodeJS.Timeout | null = null;
|
|
33
|
+
private refreshPending: NodeJS.Timeout | null = null;
|
|
34
|
+
private bulb: Service;
|
|
26
35
|
|
|
27
36
|
constructor(
|
|
28
37
|
private readonly platform: DmxPlatform,
|
|
29
38
|
accessory: PlatformAccessory,
|
|
30
39
|
private readonly zone: Zone,
|
|
31
40
|
private readonly controllers: Map<string, StickController>,
|
|
41
|
+
private readonly registry: StateRegistry,
|
|
32
42
|
) {
|
|
33
43
|
const C = platform.Characteristic;
|
|
34
44
|
const info = accessory.getService(platform.Service.AccessoryInformation);
|
|
@@ -36,53 +46,83 @@ export class ZoneFixture {
|
|
|
36
46
|
?.setCharacteristic(C.Model, `Zone (${zone.members.length} fixtures)`)
|
|
37
47
|
?.setCharacteristic(C.SerialNumber, `zone-${zone.id}`);
|
|
38
48
|
|
|
39
|
-
|
|
49
|
+
this.bulb =
|
|
40
50
|
accessory.getService(platform.Service.Lightbulb)
|
|
41
51
|
?? accessory.addService(platform.Service.Lightbulb, zone.name);
|
|
42
52
|
|
|
43
|
-
bulb.setCharacteristic(C.Name, zone.name);
|
|
44
|
-
|
|
45
|
-
// Always expose On.
|
|
46
|
-
bulb.getCharacteristic(C.On)
|
|
47
|
-
.onGet(() => this.state.on)
|
|
48
|
-
.onSet((v) => { this.state.on = Boolean(v); this.schedule(); });
|
|
53
|
+
this.bulb.setCharacteristic(C.Name, zone.name);
|
|
49
54
|
|
|
50
55
|
const has = (k: HKCharacteristic): boolean => zone.characteristics.includes(k);
|
|
51
56
|
|
|
57
|
+
this.bulb.getCharacteristic(C.On)
|
|
58
|
+
.onGet(() => this.majorityState().on)
|
|
59
|
+
.onSet((v) => this.applyToMembers({ on: Boolean(v) }));
|
|
60
|
+
|
|
52
61
|
if (has('Brightness')) {
|
|
53
|
-
bulb.getCharacteristic(C.Brightness)
|
|
54
|
-
.onGet(() => this.
|
|
55
|
-
.onSet((v: CharacteristicValue) => {
|
|
56
|
-
this.state.brightness = Number(v);
|
|
57
|
-
this.schedule();
|
|
58
|
-
});
|
|
62
|
+
this.bulb.getCharacteristic(C.Brightness)
|
|
63
|
+
.onGet(() => this.majorityState().brightness)
|
|
64
|
+
.onSet((v: CharacteristicValue) => this.applyToMembers({ brightness: Number(v) }));
|
|
59
65
|
}
|
|
60
66
|
if (has('Hue')) {
|
|
61
|
-
bulb.getCharacteristic(C.Hue)
|
|
62
|
-
.onGet(() => this.
|
|
63
|
-
.onSet((v) => {
|
|
67
|
+
this.bulb.getCharacteristic(C.Hue)
|
|
68
|
+
.onGet(() => this.majorityState().hue ?? 0)
|
|
69
|
+
.onSet((v) => this.applyToMembers({ hue: Number(v) }));
|
|
64
70
|
}
|
|
65
71
|
if (has('Saturation')) {
|
|
66
|
-
bulb.getCharacteristic(C.Saturation)
|
|
67
|
-
.onGet(() => this.
|
|
68
|
-
.onSet((v) => {
|
|
72
|
+
this.bulb.getCharacteristic(C.Saturation)
|
|
73
|
+
.onGet(() => this.majorityState().saturation ?? 0)
|
|
74
|
+
.onSet((v) => this.applyToMembers({ saturation: Number(v) }));
|
|
69
75
|
}
|
|
70
76
|
if (has('ColorTemperature')) {
|
|
71
|
-
bulb.getCharacteristic(C.ColorTemperature)
|
|
72
|
-
.onGet(() => this.
|
|
73
|
-
.onSet((v) => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
this.bulb.getCharacteristic(C.ColorTemperature)
|
|
78
|
+
.onGet(() => this.majorityState().colorTemperatureMireds ?? 200)
|
|
79
|
+
.onSet((v) => this.applyToMembers({
|
|
80
|
+
colorTemperatureMireds: Number(v),
|
|
81
|
+
saturation: 0,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Refresh on any member change.
|
|
86
|
+
const memberIds = new Set(zone.members.map((m) => m.id));
|
|
87
|
+
registry.onChange((id) => {
|
|
88
|
+
if (memberIds.has(id)) this.scheduleRefresh();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Compute majority state across members, per characteristic. */
|
|
93
|
+
private majorityState(): HomeKitLightState {
|
|
94
|
+
const ss = this.zone.members.map((m) => ({ id: m.id, state: this.registry.get(m.id) }));
|
|
95
|
+
return {
|
|
96
|
+
on: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.on }))) ?? false,
|
|
97
|
+
brightness: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.brightness }))) ?? 100,
|
|
98
|
+
hue: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.hue }))),
|
|
99
|
+
saturation: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.saturation }))),
|
|
100
|
+
colorTemperatureMireds: pickMajority(ss.map((s) => ({ id: s.id, v: s.state.colorTemperatureMireds }))),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A HomeKit set on the zone: build the new state from the current
|
|
105
|
+
* majority + the patch the user is applying, write it into every
|
|
106
|
+
* member, and trigger the controller after a small debounce so
|
|
107
|
+
* multi-characteristic dispatch coalesces. */
|
|
108
|
+
private applyToMembers(patch: Partial<HomeKitLightState>): void {
|
|
109
|
+
const next = { ...this.majorityState(), ...patch };
|
|
110
|
+
// Push the next state into every member's registry entry. This fires
|
|
111
|
+
// per-fixture change events so the individual StickFixture accessories
|
|
112
|
+
// refresh their own HomeKit displays.
|
|
113
|
+
for (const m of this.zone.members) {
|
|
114
|
+
this.registry.update(m.id, next);
|
|
78
115
|
}
|
|
116
|
+
this.scheduleDispatch();
|
|
79
117
|
}
|
|
80
118
|
|
|
81
|
-
|
|
119
|
+
/** Debounced controller dispatch. Sends current registry state for each
|
|
120
|
+
* member to the appropriate StickController. */
|
|
121
|
+
private scheduleDispatch(): void {
|
|
82
122
|
if (this.pending) clearTimeout(this.pending);
|
|
83
123
|
this.pending = setTimeout(() => {
|
|
84
124
|
this.pending = null;
|
|
85
|
-
const s = this.
|
|
125
|
+
const s = this.majorityState();
|
|
86
126
|
this.platform.log.info(
|
|
87
127
|
`[zone:${this.zone.id}] HK set: on=${s.on} br=${s.brightness}` +
|
|
88
128
|
(s.hue != null ? ` h=${s.hue}` : '') +
|
|
@@ -90,15 +130,41 @@ export class ZoneFixture {
|
|
|
90
130
|
(s.colorTemperatureMireds != null ? ` ct=${s.colorTemperatureMireds}` : '') +
|
|
91
131
|
` → ${this.zone.members.length} members`,
|
|
92
132
|
);
|
|
93
|
-
// Dispatch the zone's state to each member via that member's controller.
|
|
94
133
|
for (const m of this.zone.members) {
|
|
95
134
|
const c = this.controllers.get(m.controller.id);
|
|
96
135
|
if (!c) {
|
|
97
136
|
this.platform.log.error(`zone "${this.zone.id}": no controller for "${m.id}"`);
|
|
98
137
|
continue;
|
|
99
138
|
}
|
|
100
|
-
c.setFixture(m, this.
|
|
139
|
+
c.setFixture(m, this.registry.get(m.id));
|
|
140
|
+
}
|
|
141
|
+
}, CHARACTERISTIC_UPDATE_DELAY_MS);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Debounced refresh: when members change, push the new majority to
|
|
145
|
+
* the zone's HomeKit characteristics. */
|
|
146
|
+
private scheduleRefresh(): void {
|
|
147
|
+
if (this.refreshPending) clearTimeout(this.refreshPending);
|
|
148
|
+
this.refreshPending = setTimeout(() => {
|
|
149
|
+
this.refreshPending = null;
|
|
150
|
+
const s = this.majorityState();
|
|
151
|
+
const C = this.platform.Characteristic;
|
|
152
|
+
this.bulb.updateCharacteristic(C.On, s.on);
|
|
153
|
+
if (this.zone.characteristics.includes('Brightness')) {
|
|
154
|
+
this.bulb.updateCharacteristic(C.Brightness, s.brightness);
|
|
155
|
+
}
|
|
156
|
+
if (this.zone.characteristics.includes('Hue') && s.hue != null) {
|
|
157
|
+
this.bulb.updateCharacteristic(C.Hue, s.hue);
|
|
158
|
+
}
|
|
159
|
+
if (this.zone.characteristics.includes('Saturation') && s.saturation != null) {
|
|
160
|
+
this.bulb.updateCharacteristic(C.Saturation, s.saturation);
|
|
161
|
+
}
|
|
162
|
+
if (this.zone.characteristics.includes('ColorTemperature') && s.colorTemperatureMireds != null) {
|
|
163
|
+
this.bulb.updateCharacteristic(C.ColorTemperature, s.colorTemperatureMireds);
|
|
101
164
|
}
|
|
102
165
|
}, CHARACTERISTIC_UPDATE_DELAY_MS);
|
|
103
166
|
}
|
|
104
167
|
}
|
|
168
|
+
|
|
169
|
+
// Silence "Fixture imported but unused" — kept for potential future use.
|
|
170
|
+
export type _MemberRef = Fixture;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# stick-power-cycle — hard power-cycle the Nicolaudie Stick-DE3 by turning
|
|
3
|
+
# PoE off/on on its switch port (sw01 port 15, via PoE splitter).
|
|
4
|
+
#
|
|
5
|
+
# Why: the Stick can wedge into a state where TCP sessions and the crypto
|
|
6
|
+
# handshake succeed but live DMX output is blackout (seen 2026-08-22..24);
|
|
7
|
+
# only a real power removal recovers it. The UniFi UI "Power Cycle" bounce
|
|
8
|
+
# is too short — the splitter rides through it. This holds power off for
|
|
9
|
+
# OFF_SECONDS, then restores, then waits for the Stick to answer ping.
|
|
10
|
+
#
|
|
11
|
+
# Credentials: read at runtime from the homebridge config.json (same local
|
|
12
|
+
# UniFi user the AP-RGB plugin uses). Nothing secret is stored here.
|
|
13
|
+
#
|
|
14
|
+
# Scheduled via systemd user timer (host has no cron):
|
|
15
|
+
# ~/.config/systemd/user/stick-power-cycle.{service,timer} — daily 08:30 UTC
|
|
16
|
+
# (= 4:30am EDT; lights are always off then). Lingering enabled for miker so
|
|
17
|
+
# the timer fires without a login session. Logs: ~/stick-power-cycle.log
|
|
18
|
+
# Manual run: systemctl --user start stick-power-cycle.service
|
|
19
|
+
|
|
20
|
+
import json, socket, ssl, sys, time, urllib.request, http.cookiejar
|
|
21
|
+
|
|
22
|
+
CONTROLLER = 'https://192.168.1.1'
|
|
23
|
+
CONFIG_JSON = '/opt/containers/homebridge/config.json'
|
|
24
|
+
SWITCH_MAC = '94:2a:6f:94:f1:da' # sw01
|
|
25
|
+
PORT_IDX = 15 # Stick-DE3 PoE splitter, "dmx" VLAN 96
|
|
26
|
+
STICK_IP = '192.168.96.2'
|
|
27
|
+
OFF_SECONDS = 30
|
|
28
|
+
BOOT_WAIT_S = 180
|
|
29
|
+
|
|
30
|
+
def log(*a):
|
|
31
|
+
print(time.strftime('%Y-%m-%d %H:%M:%S%z'), *a, flush=True)
|
|
32
|
+
|
|
33
|
+
def load_creds():
|
|
34
|
+
cfg = json.load(open(CONFIG_JSON))
|
|
35
|
+
p = next(x for x in cfg['platforms'] if x.get('platform') == 'UnifiAPLight')
|
|
36
|
+
return p['username'], p['password']
|
|
37
|
+
|
|
38
|
+
class Unifi:
|
|
39
|
+
def __init__(self):
|
|
40
|
+
ctx = ssl.create_default_context()
|
|
41
|
+
ctx.check_hostname = False
|
|
42
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
43
|
+
self.op = urllib.request.build_opener(
|
|
44
|
+
urllib.request.HTTPSHandler(context=ctx),
|
|
45
|
+
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
|
46
|
+
self.csrf = ''
|
|
47
|
+
|
|
48
|
+
def req(self, method, path, body=None):
|
|
49
|
+
rq = urllib.request.Request(CONTROLLER + path, method=method,
|
|
50
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
51
|
+
headers={'Content-Type': 'application/json'})
|
|
52
|
+
if self.csrf:
|
|
53
|
+
rq.add_header('x-csrf-token', self.csrf)
|
|
54
|
+
r = self.op.open(rq, timeout=15)
|
|
55
|
+
self.csrf = r.headers.get('x-csrf-token', self.csrf)
|
|
56
|
+
return json.load(r)
|
|
57
|
+
|
|
58
|
+
def login(self, user, pw):
|
|
59
|
+
self.req('POST', '/api/auth/login', {'username': user, 'password': pw})
|
|
60
|
+
|
|
61
|
+
def get_switch(self):
|
|
62
|
+
data = self.req('GET', '/proxy/network/api/s/default/stat/device')['data']
|
|
63
|
+
return next(d for d in data if d.get('mac') == SWITCH_MAC)
|
|
64
|
+
|
|
65
|
+
def set_poe(self, poe_mode):
|
|
66
|
+
# port_overrides is replaced wholesale on PUT — merge, never clobber.
|
|
67
|
+
sw = self.get_switch()
|
|
68
|
+
overrides = sw.get('port_overrides', [])
|
|
69
|
+
entry = next((o for o in overrides if o.get('port_idx') == PORT_IDX), None)
|
|
70
|
+
if entry is None:
|
|
71
|
+
entry = {'port_idx': PORT_IDX}
|
|
72
|
+
overrides.append(entry)
|
|
73
|
+
entry['poe_mode'] = poe_mode
|
|
74
|
+
self.req('PUT', f"/proxy/network/api/s/default/rest/device/{sw['_id']}",
|
|
75
|
+
{'port_overrides': overrides})
|
|
76
|
+
log(f'port {PORT_IDX} poe_mode -> {poe_mode}')
|
|
77
|
+
|
|
78
|
+
def stick_up():
|
|
79
|
+
# TCP probe of the Stick's own control port — proves the device booted,
|
|
80
|
+
# not just that the link is up. (The host has no ping binary anyway.)
|
|
81
|
+
try:
|
|
82
|
+
socket.create_connection((STICK_IP, 2431), timeout=2).close()
|
|
83
|
+
return True
|
|
84
|
+
except OSError:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def main():
|
|
88
|
+
log(f'power-cycling Stick-DE3 (sw {SWITCH_MAC} port {PORT_IDX}, {OFF_SECONDS}s off)')
|
|
89
|
+
api = Unifi()
|
|
90
|
+
api.login(*load_creds())
|
|
91
|
+
api.set_poe('off')
|
|
92
|
+
try:
|
|
93
|
+
time.sleep(OFF_SECONDS)
|
|
94
|
+
finally:
|
|
95
|
+
# restore is sacred: retry hard so the port can never stay dark
|
|
96
|
+
for attempt in range(5):
|
|
97
|
+
try:
|
|
98
|
+
api.set_poe('auto')
|
|
99
|
+
break
|
|
100
|
+
except Exception as e:
|
|
101
|
+
log(f'restore attempt {attempt + 1} failed: {e}')
|
|
102
|
+
time.sleep(5)
|
|
103
|
+
try:
|
|
104
|
+
api.login(*load_creds())
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
else:
|
|
108
|
+
log('FATAL: could not restore PoE — port may be off!')
|
|
109
|
+
sys.exit(2)
|
|
110
|
+
|
|
111
|
+
deadline = time.time() + BOOT_WAIT_S
|
|
112
|
+
while time.time() < deadline:
|
|
113
|
+
if stick_up():
|
|
114
|
+
log(f'Stick back up ({STICK_IP}:2431 accepting connections)')
|
|
115
|
+
return
|
|
116
|
+
time.sleep(5)
|
|
117
|
+
log(f'WARNING: Stick not reachable within {BOOT_WAIT_S}s of power restore')
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
if __name__ == '__main__':
|
|
121
|
+
main()
|