-
Notifications
You must be signed in to change notification settings - Fork 6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement Watchers (fixes #107, #111). r=gmarty #129
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,10 +6,28 @@ | |
background-image: url('services-list/ip-camera.svg'); | ||
} | ||
|
||
[data-icon="motion-sensor"] { | ||
[data-icon="door-lock"] { | ||
background-image: url('services-list/door-lock.svg'); | ||
} | ||
|
||
@keyframes motion-sensor-animation { | ||
0%, 90% { | ||
background-size: 2.7rem; | ||
} | ||
|
||
5%, 95% { | ||
background-size: 2.3rem; | ||
} | ||
|
||
10%, 85%, 100% { | ||
background-size: 2.5rem; | ||
} | ||
} | ||
|
||
.motion-sensor-item { | ||
background-image: url('services-list/motion-sensor.svg'); | ||
} | ||
|
||
[data-icon="door-lock"] { | ||
background-image: url('services-list/door-lock.svg'); | ||
.motion-sensor-item--motion-detected { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's just something for intermediate period to see that sensor is activated. But at some point we'll definitely need to implement all these shiny icons provided by VD. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I prefer not to spend too much time on the design/UX while the scope of the project is still not defined. |
||
animation: motion-sensor-animation 1.5s linear infinite both; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,23 @@ | ||
'use strict'; | ||
|
||
import EventDispatcher from './common/event-dispatcher'; | ||
import SequentialTimer from './common/sequential-timer'; | ||
|
||
const p = Object.freeze({ | ||
settings: Symbol('settings'), | ||
net: Symbol('net'), | ||
|
||
watchTimer: Symbol('watchTimer'), | ||
watchEventBus: Symbol('watchEventBus'), | ||
watchGetters: Symbol('getters'), | ||
|
||
// Private methods. | ||
getURL: Symbol('getURL'), | ||
onceOnline: Symbol('onceOnline'), | ||
onceAuthenticated: Symbol('onceAuthenticated'), | ||
onceReady: Symbol('onceReady'), | ||
fetchGetterValues: Symbol('fetchGetterValues'), | ||
updateGetterValue: Symbol('updateGetterValue'), | ||
}); | ||
|
||
/** | ||
|
@@ -23,6 +32,14 @@ export default class API { | |
constructor(net, settings) { | ||
this[p.net] = net; | ||
this[p.settings] = settings; | ||
|
||
this[p.watchTimer] = new SequentialTimer(this[p.settings].watchInterval); | ||
this[p.watchEventBus] = new EventDispatcher(); | ||
this[p.watchGetters] = new Map(); | ||
|
||
this[p.fetchGetterValues] = this[p.fetchGetterValues].bind(this); | ||
|
||
Object.freeze(this); | ||
} | ||
|
||
/** | ||
|
@@ -90,6 +107,63 @@ export default class API { | |
}); | ||
} | ||
|
||
/** | ||
* Registers watcher for the getter with specified id. | ||
* | ||
* @todo We may need to accept getter kind in the future too, to validate | ||
* getter value type. | ||
* | ||
* @param {string} getterId Id of the getter we'd like to watch. | ||
* @param {function} handler Handler to be executed once watched value is | ||
* changed. | ||
*/ | ||
watch(getterId, handler) { | ||
this[p.watchEventBus].on(getterId, handler); | ||
|
||
if (this[p.watchGetters].has(getterId)) { | ||
return; | ||
} | ||
|
||
this[p.watchGetters].set(getterId, { | ||
id: getterId, | ||
// Using null as initial value, some getters can return null when value | ||
// is not yet available, so it perfectly fits our case. | ||
value: null, | ||
}); | ||
|
||
// We automatically start watching if at least one getter is requested to | ||
// be watched. | ||
if (!this[p.watchTimer].started) { | ||
this[p.watchTimer].start(this[p.fetchGetterValues]); | ||
} | ||
} | ||
|
||
/** | ||
* Unregisters watcher for the getter with specified id. | ||
* | ||
* @param {string} getterId Id of the getter we'd like to not watch anymore. | ||
* @param {function} handler Handler function that has been used with "watch" | ||
* previously. | ||
*/ | ||
unwatch(getterId, handler) { | ||
if (!this[p.watchGetters].has(getterId)) { | ||
console.warn('Getter with id "%s" is not watched.', getterId); | ||
return; | ||
} | ||
|
||
this[p.watchEventBus].off(getterId, handler); | ||
|
||
// If there is no more listeners, we should not watch this getter anymore. | ||
if (!this[p.watchEventBus].hasListeners(getterId)) { | ||
this[p.watchGetters].delete(getterId); | ||
} | ||
|
||
// If no more getters are watched let's stop watching. | ||
if (this[p.watchGetters].size === 0) { | ||
this[p.watchTimer].stop(); | ||
} | ||
} | ||
|
||
/** | ||
* Creates a fully qualified API URL based on predefined base origin, API | ||
* version and specified resource path. | ||
|
@@ -148,4 +222,80 @@ export default class API { | |
|
||
return new Promise((resolve) => settings.once('session', () => resolve())); | ||
} | ||
|
||
/** | ||
* Fetches values for the set of getters. | ||
* | ||
* @return {Promise} | ||
* @private | ||
*/ | ||
[p.fetchGetterValues]() { | ||
// It may happen that all watchers have been unregistered in the meantime, | ||
// so let's return early in this case. | ||
if (this[p.watchGetters].size === 0) { | ||
return Promise.resolve(); | ||
} | ||
|
||
const selectors = Array.from(this[p.watchGetters].values()).map( | ||
({ id }) => ({ id }) | ||
); | ||
|
||
return this.put('channels/get', selectors) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a great feature of taxonomy we can group all watched getters into one request! |
||
.then((response) => { | ||
Object.keys(response).forEach((key) => { | ||
const getter = this[p.watchGetters].get(key); | ||
if (!getter) { | ||
return; | ||
} | ||
|
||
this[p.updateGetterValue](getter, response[key]); | ||
}); | ||
}); | ||
} | ||
|
||
/** | ||
* Updates getter value if needed. If value has changed, appropriate event is | ||
* fired. | ||
* | ||
* @param {{ id: string, value: Object }} getter Getter to update value for. | ||
* @param {Object} getterValue Getter value returned from the server. | ||
* | ||
* @private | ||
*/ | ||
[p.updateGetterValue](getter, getterValue) { | ||
let valueChanged = false; | ||
|
||
if (!getterValue || !getter.value) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a special case when state of the value is not known yet (happens for OpenZWave pretty frequently) |
||
valueChanged = getterValue !== getter.value; | ||
} else { | ||
const [valueKind] = Object.keys(getterValue); | ||
if (valueKind === 'Error') { | ||
console.error( | ||
'Failed to retrieve value for getter (%s): %o', | ||
getter.id, | ||
getterValue[valueKind] | ||
); | ||
|
||
return; | ||
} | ||
|
||
const newValue = getterValue[valueKind]; | ||
const oldValue = getter.value[valueKind]; | ||
|
||
if (newValue && oldValue && typeof newValue === 'object') { | ||
// @todo If value is a non-null object, we use their JSON representation | ||
// to compare values. It's not performant and not reliable at all, but | ||
// this OK until we have such values, once we support them we should | ||
// have dedicated utility function for deep comparing objects. | ||
valueChanged = JSON.stringify(newValue) !== JSON.stringify(oldValue); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm sure there are more efficient method to do deep object comparison. Also if the properties order is not the same, the comparison will fail. As per the es spec, the objects property order shouldn't be relied on. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Definitely, the main reason why I don't really care much - we don't have such values currently as far as I know, so I didn't want to spend time on something that we don't use - just a basic placeholder. If one day we have such values (or when we have more time) - I think we can reuse What do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes I was thinking of reusing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, will add comment! |
||
} else { | ||
valueChanged = newValue !== oldValue; | ||
} | ||
} | ||
|
||
if (valueChanged) { | ||
getter.value = Object.freeze(getterValue); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I keep entire value container (e.g.
|
||
this[p.watchEventBus].emit(getter.id, getter.value); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
'use strict'; | ||
|
||
const p = Object.freeze({ | ||
started: Symbol('started'), | ||
nextTickHandle: Symbol('nextTickHandle'), | ||
|
||
// Private methods. | ||
scheduleTick: Symbol('scheduleTick'), | ||
onTick: Symbol('onTick'), | ||
}); | ||
|
||
export default class SequentialTimer { | ||
/** | ||
* Creates new SequentialTimer instance. | ||
* @param {number} interval Minimum interval between two consequent ticks. | ||
*/ | ||
constructor(interval) { | ||
this.interval = interval; | ||
|
||
this[p.started] = false; | ||
this[p.nextTickHandle] = null; | ||
this[p.onTick] = null; | ||
|
||
Object.seal(this); | ||
} | ||
|
||
/** | ||
* Indicates whether timer started or not. | ||
* | ||
* @return {boolean} | ||
*/ | ||
get started() { | ||
return this[p.started]; | ||
} | ||
|
||
/** | ||
* Starts timer. If timer has already been started nothing happens. | ||
* @param {function} onTick Function that will be called on every tick. | ||
*/ | ||
start(onTick) { | ||
if (this[p.started]) { | ||
console.warn('Timer has been already started.'); | ||
return; | ||
} | ||
|
||
if (typeof onTick !== 'function') { | ||
throw new Error('onTick handler should be a valid function.'); | ||
} | ||
|
||
this[p.started] = true; | ||
this[p.onTick] = onTick; | ||
|
||
this[p.scheduleTick](); | ||
} | ||
|
||
/** | ||
* Stops timer. If timer has not been started yet nothing happens. | ||
*/ | ||
stop() { | ||
if (!this[p.started]) { | ||
console.warn('Timer has not been started yet.'); | ||
return; | ||
} | ||
|
||
this[p.started] = false; | ||
|
||
clearTimeout(this[p.nextTickHandle]); | ||
this[p.nextTickHandle] = null; | ||
this[p.onTick] = null; | ||
} | ||
|
||
/** | ||
* Schedules next tick. | ||
* | ||
* @private | ||
*/ | ||
[p.scheduleTick]() { | ||
if (!this[p.started] || this[p.nextTickHandle]) { | ||
return; | ||
} | ||
|
||
this[p.nextTickHandle] = setTimeout(() => { | ||
// Use Promise constructor to handle all possible results e.g. promises, | ||
// unexpected exceptions and any other non-promise values. | ||
(new Promise((resolve) => resolve(this[p.onTick]()))) | ||
.catch((error) => { | ||
console.error( | ||
'onTick handler failed, scheduling next tick anyway: %o', | ||
error | ||
); | ||
}) | ||
.then(() => { | ||
this[p.nextTickHandle] = null; | ||
this[p.scheduleTick](); | ||
}); | ||
}, this.interval); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trying to use more BEMy approach.