Skip to content

Commit

Permalink
Merge pull request #3 from martin308/martin308/server-sent-event-stream
Browse files Browse the repository at this point in the history
server sent event stream
  • Loading branch information
martin308 authored Dec 29, 2022
2 parents afa1648 + 22329d7 commit 964ecd4
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 25 deletions.
56 changes: 56 additions & 0 deletions src/eventsource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { EventEmitter } from 'stream';
import { request, ClientRequest, IncomingMessage } from 'http';
import { URL } from 'url';

declare interface EventSource {
on(event: 'event', listener: (arg0: object) => void): this;
}

class EventSource extends EventEmitter {
private req: ClientRequest | undefined;

constructor(private readonly url: URL) {
super();
}

close() {
this.removeAllListeners();

if(this.req) {
this.req.removeAllListeners();
this.req.destroy();
}
}

connect() {
this.req = request(this.url, this.handleResponse.bind(this));
this.req.on('error', (err) => this.emit('error', err));
this.req.on('abort', () => this.emit('error', 'abort'));
this.req.on('close', this.handleClose.bind(this));
this.req.end();
}

private handleClose() {
if(this.req) {
this.req.removeAllListeners();
}

setTimeout(this.connect.bind(this), 500);
}

private handleResponse(res: IncomingMessage) {
res.setEncoding('utf8');
res.on('data', this.handleData.bind(this));
}

private handleData(chunk: string) {
try {
const json = JSON.parse(chunk);
this.emit('event', json);
} catch (error) {
this.emit('error', error);
}
}
}

export default EventSource;
132 changes: 132 additions & 0 deletions src/hub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import fetch, { Response } from 'node-fetch';
import { URL } from 'url';
import EventSource from './eventsource';

interface Event {
evt: string;
}

function isAnEvent(obj: object): obj is Event {
return obj && 'evt' in obj;
}

type JSONShade = {
id: number;
ptName: string;
positions: {
primary: number;
};
};

interface MotionStoppedEvent {
evt: string;
id: number;
currentPositions: Position;
}

interface MotionStartedEvent {
evt: string;
id: number;
currentPositions: Position;
targetPositions?: Position;
}

type Position = {
primary: number;
};

class Shade implements Shade {
currentPositions: Position;
targetPositions: Position;

constructor(private readonly hub: Hub, readonly id: number, readonly name: string, position: Position) {
this.currentPositions = position;
this.targetPositions = position;
}

setTargetPosition(position: Position): void {
this.hub.setShades(position, this.id);
}
}

class Hub {
private readonly events: EventSource;
private readonly shades: Map<number, Shade> = new Map();

constructor(private readonly host: URL) {
const events = new URL('/home/shades/events', host);
this.events = new EventSource(events);
this.events.on('event', this.handleEvent.bind(this));
this.events.connect();
}

close() {
this.events.close();
}

async setShades(position: Position, ...ids: number[]): Promise<Response> {
const url = new URL('/home/shades/postitions', this.host);
url.search = `ids=${ids.join(',')}`;
return fetch(url, {
method: 'PUT',
body: JSON.stringify({ positions: { primary: position } }),
headers: {'Content-Type': 'application/json'},
});
}

async getShades(): Promise<Array<Shade>> {
const url = new URL('/home/shades', this.host);
const json = await fetch(url)
.then(response => response.json())
.then(response => response as Array<JSONShade>);

const shades = json.map(j => {
const shade = new Shade(this, j.id, j.ptName, j.positions);

this.shades.set(j.id, shade);

return shade;
});

return shades;
}

private handleEvent(obj: object) {
if(isAnEvent(obj)) {
switch (obj.evt) {
case 'motion-started':
this.handleMotionStartedEvent(obj as MotionStartedEvent);
break;
case 'motion-stopped':
this.handleMotionStoppedEvent(obj as MotionStoppedEvent);
break;
case 'shade-offline':
case 'shade-online':
case 'battery-alert':
break;
}
}
}

private handleMotionStartedEvent(event: MotionStartedEvent) {
const shade = this.shades.get(event.id);

if(shade) {
shade.currentPositions = event.currentPositions;
if (event.targetPositions) {
shade.targetPositions = event.targetPositions;
}
}
}

private handleMotionStoppedEvent(event: MotionStoppedEvent) {
const shade = this.shades.get(event.id);

if(shade) {
shade.currentPositions = event.currentPositions;
}
}
}

export { Shade };
export default Hub;
21 changes: 13 additions & 8 deletions src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, Service, Charact

import { PowerViewConfig } from './config';
import { PLATFORM_NAME, PLUGIN_NAME } from './settings';
import { Shade } from './shade';
import { getShades } from './powerview';
import Hub from './hub';
import { URL } from 'url';
import { ShadeAccessory } from './shadeAccessory';

/**
* HomebridgePlatform
Expand All @@ -17,14 +18,18 @@ export class PowerViewHomebridgePlatform implements DynamicPlatformPlugin {
// this is used to track restored cached accessories
public readonly accessories: PlatformAccessory[] = [];

private config: PowerViewConfig;
private readonly config: PowerViewConfig;

private readonly hub: Hub;

constructor(
public readonly log: Logger,
config: PlatformConfig,
public readonly api: API,
) {
this.config = config as PowerViewConfig;
const url = new URL(this.config.Host);
this.hub = new Hub(url);
this.log.debug('Finished initializing platform:', this.config.name);

// When this event is fired it means Homebridge has restored all cached accessories from disk.
Expand Down Expand Up @@ -59,7 +64,7 @@ export class PowerViewHomebridgePlatform implements DynamicPlatformPlugin {
// EXAMPLE ONLY
// A real plugin you would discover accessories from the local network, cloud services
// or a user-defined array in the platform config.
const shades = await getShades(this.config.Host);
const shades = await this.hub.getShades();

// loop over the discovered devices and register each one if it has not already been registered
for (const shade of shades) {
Expand All @@ -83,26 +88,26 @@ export class PowerViewHomebridgePlatform implements DynamicPlatformPlugin {

// create the accessory handler for the restored accessory
// this is imported from `platformAccessory.ts`
new Shade(this, existingAccessory, shade);
new ShadeAccessory(this, existingAccessory, shade);

// it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.:
// remove platform accessories when no longer present
// this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
// this.log.info('Removing existing accessory from cache:', existingAccessory.displayName);
} else {
// the accessory does not yet exist, so we need to create it
this.log.info('Adding new accessory:', shade.ptName);
this.log.info('Adding new accessory:', shade.name);

// create a new accessory
const accessory = new this.api.platformAccessory(shade.ptName, uuid);
const accessory = new this.api.platformAccessory(shade.name, uuid);

// store a copy of the device object in the `accessory.context`
// the `context` property can be used to store any data about the accessory you may need
accessory.context.device = shade;

// create the accessory handler for the newly create accessory
// this is imported from `platformAccessory.ts`
new Shade(this, accessory, shade);
new ShadeAccessory(this, accessory, shade);

// link the accessory to your platform
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
Expand Down
42 changes: 25 additions & 17 deletions src/shade.ts → src/shadeAccessory.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,68 @@
import { Service, PlatformAccessory, CharacteristicValue } from 'homebridge';
import { Shade } from './hub';

import { PowerViewHomebridgePlatform } from './platform';
import { WebShade, setShade, getShade } from './powerview';

export class Shade {
private service: Service;
export class ShadeAccessory {
private windowCoveringService: Service;

constructor(
private readonly platform: PowerViewHomebridgePlatform,
private readonly accessory: PlatformAccessory,
private webShade: WebShade,
private shade: Shade,
) {

// set accessory information
this.accessory.getService(this.platform.Service.AccessoryInformation)!
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'Default-Manufacturer')
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'Hunter Douglas')
.setCharacteristic(this.platform.Characteristic.Model, 'Default-Model')
.setCharacteristic(this.platform.Characteristic.SerialNumber, 'Default-Serial');

this.service = this.accessory.getService(this.platform.Service.WindowCovering) ||
this.windowCoveringService = this.accessory.getService(this.platform.Service.WindowCovering) ||
this.accessory.addService(this.platform.Service.WindowCovering);

this.service.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.exampleDisplayName);
this.windowCoveringService.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.exampleDisplayName);

this.service.getCharacteristic(this.platform.Characteristic.CurrentPosition)
this.windowCoveringService.getCharacteristic(this.platform.Characteristic.CurrentPosition)
.onGet(this.getCurrentPosition.bind(this));

this.service.getCharacteristic(this.platform.Characteristic.PositionState)
this.windowCoveringService.getCharacteristic(this.platform.Characteristic.PositionState)
.onGet(this.getPositionState.bind(this));

this.service.getCharacteristic(this.platform.Characteristic.TargetPosition)
this.windowCoveringService.getCharacteristic(this.platform.Characteristic.TargetPosition)
.onGet(this.getTargetPosition.bind(this))
.onSet(this.setTargetPosition.bind(this));
}

async getCurrentPosition(): Promise<CharacteristicValue> {
this.platform.log.debug('Triggered GET CurrentPosition');

this.webShade = await getShade(this.webShade);

return this.webShade.positions.primary;
return this.shade.currentPositions.primary * 100;
}

async getPositionState(): Promise<CharacteristicValue> {
this.platform.log.debug('Triggered GET PositionState');

return this.platform.Characteristic.PositionState.STOPPED;
switch (true) {
case this.shade.currentPositions.primary > this.shade.targetPositions.primary:
return this.platform.Characteristic.PositionState.INCREASING;
break;
case this.shade.currentPositions.primary < this.shade.targetPositions.primary:
return this.platform.Characteristic.PositionState.DECREASING;
break;
default:
return this.platform.Characteristic.PositionState.STOPPED;
break;
}
}

async getTargetPosition(): Promise<CharacteristicValue> {
this.platform.log.debug('Triggered GET TargetPosition');

return 0;
return this.shade.targetPositions.primary;
}

async setTargetPosition(value: CharacteristicValue) {
setTargetPosition(value: CharacteristicValue) {
// in homekit land
// 100 is open
// 0 is closed?
Expand All @@ -66,7 +74,7 @@ export class Shade {

if (typeof value === 'number') {
const newValue = value / 100;
await setShade(this.webShade, newValue);
this.shade.setTargetPosition({ primary: newValue });
}
}
}

0 comments on commit 964ecd4

Please sign in to comment.