Skip to content
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

Merged
merged 1 commit into from
Apr 28, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions app/css/components/service-list.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@

.service-list__item {
display: flex;
padding: 0;
position: relative;
}

.service-list__item[data-icon] {
padding-left: 4rem;

background-size: 2.5rem;
Expand Down
24 changes: 21 additions & 3 deletions app/css/views/services-list.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Member Author

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.

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 {
Copy link
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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;
}
150 changes: 150 additions & 0 deletions app/js/lib/foxbox/api.js
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'),
});

/**
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Copy link
Member Author

Choose a reason for hiding this comment

The 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) {
Copy link
Member Author

Choose a reason for hiding this comment

The 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);
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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 isSimilar method from ```services (it will be moved to a common place and maybe improved even more).

What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I was thinking of reusing isSimilar too.
Sounds good to me for now. If only temporary, maybe add a comment to remember to take another look when values like that are used.

Copy link
Member Author

Choose a reason for hiding this comment

The 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);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I keep entire value container (e.g. { OpenClosed: "Open" } instead of just "Open") for two reasons:

  • To have the same event object format as returned value in BaseService.get;
  • To be more restrictive in comparing old and new value to make sure type of the value hasn't changed (it's not 100% protection, but still something).

this[p.watchEventBus].emit(getter.id, getter.value);
}
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,23 @@
* obj.emit("somethinghappened", 123);
*/

function assertValidEventName(eventName) {
const assertValidEventName = function(eventName) {
if (!eventName || typeof eventName !== 'string') {
throw new Error('Event name should be a valid non-empty string!');
}
}
};

function assertValidHandler(handler) {
const assertValidHandler = function(handler) {
if (typeof handler !== 'function') {
throw new Error('Handler should be a function!');
}
}
};

function assertAllowedEventName(allowedEvents, eventName) {
const assertAllowedEventName = function(allowedEvents, eventName) {
if (allowedEvents && allowedEvents.indexOf(eventName) < 0) {
throw new Error(`Event "${eventName}" is not allowed!`);
}
}
};

const p = Object.freeze({
allowedEvents: Symbol('allowedEvents'),
Expand Down
98 changes: 98 additions & 0 deletions app/js/lib/foxbox/common/sequential-timer.js
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);
}
}
2 changes: 1 addition & 1 deletion app/js/lib/foxbox/db.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

import Defer from './defer';
import Defer from './common/defer';

// Private members.
const p = Object.freeze({
Expand Down
Loading