diff --git a/.gitignore b/.gitignore index 4ac1605459..e034b4100b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.tgz *.DS_Store *.idea +*.sass-cache # External dependencies diff --git a/example/generator/src/SinewaveTelemetryProvider.js b/example/generator/src/SinewaveTelemetryProvider.js index d111bafd3d..4bb20a7354 100644 --- a/example/generator/src/SinewaveTelemetryProvider.js +++ b/example/generator/src/SinewaveTelemetryProvider.js @@ -13,6 +13,7 @@ define( * @constructor */ function SinewaveTelemetryProvider($q, $timeout) { + var subscriptions = []; // function matchesSource(request) { @@ -43,8 +44,48 @@ define( }, 0); } + function handleSubscriptions() { + subscriptions.forEach(function (subscription) { + var requests = subscription.requests; + subscription.callback(doPackage( + requests.filter(matchesSource).map(generateData) + )); + }); + } + + function startGenerating() { + $timeout(function () { + handleSubscriptions(); + if (subscriptions.length > 0) { + startGenerating(); + } + }, 1000); + } + + function subscribe(callback, requests) { + var subscription = { + callback: callback, + requests: requests + }; + + function unsubscribe() { + subscriptions = subscriptions.filter(function (s) { + return s !== subscription; + }); + } + + subscriptions.push(subscription); + + if (subscriptions.length === 1) { + startGenerating(); + } + + return unsubscribe; + } + return { - requestTelemetry: requestTelemetry + requestTelemetry: requestTelemetry, + subscribe: subscribe }; } diff --git a/example/taxonomy/bundle.json b/example/taxonomy/bundle.json new file mode 100644 index 0000000000..167cadc055 --- /dev/null +++ b/example/taxonomy/bundle.json @@ -0,0 +1,29 @@ +{ + "name": "Example taxonomy", + "description": "Example illustrating the addition of a static top-level hierarchy", + "extensions": { + "roots": [ + { + "id": "exampleTaxonomy", + "model": { + "type": "folder", + "name": "Stub Subsystems", + "composition": [ + "examplePacket0", + "examplePacket1", + "examplePacket2" + ] + } + } + ], + "components": [ + { + "provides": "modelService", + "type": "provider", + "implementation": "ExampleTaxonomyModelProvider.js", + "depends": [ "$q" ] + } + ] + + } +} \ No newline at end of file diff --git a/example/taxonomy/src/ExampleTaxonomyModelProvider.js b/example/taxonomy/src/ExampleTaxonomyModelProvider.js new file mode 100644 index 0000000000..342418143e --- /dev/null +++ b/example/taxonomy/src/ExampleTaxonomyModelProvider.js @@ -0,0 +1,48 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + function ExampleTaxonomyModelProvider($q) { + var models = {}, + packetId, + telemetryId, + i, + j; + + // Add some "subsystems" + for (i = 0; i < 3; i += 1) { + packetId = "examplePacket" + i; + + models[packetId] = { + name: "Stub Subsystem " + (i + 1), + type: "telemetry.panel", + composition: [] + }; + + // Add some "telemetry points" + for (j = 0; j < 100 * (i + 1); j += 1) { + telemetryId = "exampleTelemetry" + j; + models[telemetryId] = { + name: "SWG" + i + "." + j, + type: "generator", + telemetry: { + period: 10 + i + j + } + }; + models[packetId].composition.push(telemetryId); + } + } + + return { + getModels: function () { + return $q.when(models); + } + }; + } + + return ExampleTaxonomyModelProvider; + } +); \ No newline at end of file diff --git a/platform/commonUI/general/bundle.json b/platform/commonUI/general/bundle.json index ca98296be4..b8213f8c77 100644 --- a/platform/commonUI/general/bundle.json +++ b/platform/commonUI/general/bundle.json @@ -24,49 +24,59 @@ "controllers": [ { "key": "TreeNodeController", - "implementation": "TreeNodeController.js", + "implementation": "controllers/TreeNodeController.js", "depends": [ "$scope", "$timeout" ] }, { "key": "ActionGroupController", - "implementation": "ActionGroupController.js", + "implementation": "controllers/ActionGroupController.js", "depends": [ "$scope" ] }, { "key": "ToggleController", - "implementation": "ToggleController.js" + "implementation": "controllers/ToggleController.js" }, { "key": "ContextMenuController", - "implementation": "ContextMenuController.js", + "implementation": "controllers/ContextMenuController.js", "depends": [ "$scope" ] }, { "key": "ClickAwayController", - "implementation": "ClickAwayController.js", + "implementation": "controllers/ClickAwayController.js", "depends": [ "$scope", "$document" ] }, { "key": "ViewSwitcherController", - "implementation": "ViewSwitcherController.js", + "implementation": "controllers/ViewSwitcherController.js", "depends": [ "$scope" ] }, { "key": "BottomBarController", - "implementation": "BottomBarController.js", + "implementation": "controllers/BottomBarController.js", "depends": [ "indicators[]" ] + }, + { + "key": "GetterSetterController", + "implementation": "controllers/GetterSetterController.js", + "depends": [ "$scope" ] } ], "directives": [ { "key": "mctContainer", - "implementation": "MCTContainer.js", + "implementation": "directives/MCTContainer.js", "depends": [ "containers[]" ] }, { "key": "mctDrag", - "implementation": "MCTDrag.js", + "implementation": "directives/MCTDrag.js", "depends": [ "$document" ] + }, + { + "key": "mctResize", + "implementation": "directives/MCTResize.js", + "depends": [ "$timeout" ] } ], "containers": [ diff --git a/platform/commonUI/general/res/fonts/symbols/wtdsymbols.woff2 b/platform/commonUI/general/res/fonts/symbols/wtdsymbols.woff2 deleted file mode 100755 index 2f2d374c79..0000000000 Binary files a/platform/commonUI/general/res/fonts/symbols/wtdsymbols.woff2 and /dev/null differ diff --git a/platform/commonUI/general/res/templates/controls/input-filter.html b/platform/commonUI/general/res/templates/controls/input-filter.html index 303a8db257..3c6be1684e 100644 --- a/platform/commonUI/general/res/templates/controls/input-filter.html +++ b/platform/commonUI/general/res/templates/controls/input-filter.html @@ -1,5 +1,13 @@ - - - x + + + + x + \ No newline at end of file diff --git a/platform/commonUI/general/src/ActionGroupController.js b/platform/commonUI/general/src/controllers/ActionGroupController.js similarity index 100% rename from platform/commonUI/general/src/ActionGroupController.js rename to platform/commonUI/general/src/controllers/ActionGroupController.js diff --git a/platform/commonUI/general/src/BottomBarController.js b/platform/commonUI/general/src/controllers/BottomBarController.js similarity index 100% rename from platform/commonUI/general/src/BottomBarController.js rename to platform/commonUI/general/src/controllers/BottomBarController.js diff --git a/platform/commonUI/general/src/ClickAwayController.js b/platform/commonUI/general/src/controllers/ClickAwayController.js similarity index 100% rename from platform/commonUI/general/src/ClickAwayController.js rename to platform/commonUI/general/src/controllers/ClickAwayController.js diff --git a/platform/commonUI/general/src/ContextMenuController.js b/platform/commonUI/general/src/controllers/ContextMenuController.js similarity index 100% rename from platform/commonUI/general/src/ContextMenuController.js rename to platform/commonUI/general/src/controllers/ContextMenuController.js diff --git a/platform/commonUI/general/src/controllers/GetterSetterController.js b/platform/commonUI/general/src/controllers/GetterSetterController.js new file mode 100644 index 0000000000..be11ffadc8 --- /dev/null +++ b/platform/commonUI/general/src/controllers/GetterSetterController.js @@ -0,0 +1,69 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * This controller acts as an adapter to permit getter-setter + * functions to be used as ng-model arguments to controls, + * such as the input-filter. This is supported natively in + * Angular 1.3+ via `ng-model-options`, so this controller + * should be made obsolete after any upgrade to Angular 1.3. + * + * It expects to find in scope a value `ngModel` which is a + * function which, when called with no arguments, acts as a + * getter, and when called with one argument, acts as a setter. + * + * It also publishes into the scope a value `getterSetter.value` + * which is meant to be used as an assignable expression. + * + * This controller watches both of these; when one changes, + * it will update the other's value to match. Because of this, + * the `ngModel` function should be both stable and computationally + * inexpensive, as it will be invoked often. + * + * Getter-setter style models can be preferable when there + * is significant indirection between templates; "dotless" + * expressions in `ng-model` can behave unexpectedly due to the + * rules of scope, but dots are lost when passed in via `ng-model` + * (so if a control is internally implemented using regular + * form elements, it can't transparently pass through the `ng-model` + * parameter it received.) Getter-setter functions are never the + * target of a scope assignment and so avoid this problem. + * + * @constructor + * @param {Scope} $scope the controller's scope + */ + function GetterSetterController($scope) { + + // Update internal assignable state based on changes + // to the getter-setter function. + function updateGetterSetter() { + if (typeof $scope.ngModel === 'function') { + $scope.getterSetter.value = $scope.ngModel(); + } + } + + // Update the external getter-setter based on changes + // to the assignable state. + function updateNgModel() { + if (typeof $scope.ngModel === 'function') { + $scope.ngModel($scope.getterSetter.value); + } + } + + // Watch for changes to both expressions + $scope.$watch("ngModel()", updateGetterSetter); + $scope.$watch("getterSetter.value", updateNgModel); + + // Publish an assignable field into scope. + $scope.getterSetter = {}; + + } + + return GetterSetterController; + + } +); \ No newline at end of file diff --git a/platform/commonUI/general/src/ToggleController.js b/platform/commonUI/general/src/controllers/ToggleController.js similarity index 100% rename from platform/commonUI/general/src/ToggleController.js rename to platform/commonUI/general/src/controllers/ToggleController.js diff --git a/platform/commonUI/general/src/TreeNodeController.js b/platform/commonUI/general/src/controllers/TreeNodeController.js similarity index 100% rename from platform/commonUI/general/src/TreeNodeController.js rename to platform/commonUI/general/src/controllers/TreeNodeController.js diff --git a/platform/commonUI/general/src/ViewSwitcherController.js b/platform/commonUI/general/src/controllers/ViewSwitcherController.js similarity index 100% rename from platform/commonUI/general/src/ViewSwitcherController.js rename to platform/commonUI/general/src/controllers/ViewSwitcherController.js diff --git a/platform/commonUI/general/src/MCTContainer.js b/platform/commonUI/general/src/directives/MCTContainer.js similarity index 100% rename from platform/commonUI/general/src/MCTContainer.js rename to platform/commonUI/general/src/directives/MCTContainer.js diff --git a/platform/commonUI/general/src/MCTDrag.js b/platform/commonUI/general/src/directives/MCTDrag.js similarity index 100% rename from platform/commonUI/general/src/MCTDrag.js rename to platform/commonUI/general/src/directives/MCTDrag.js diff --git a/platform/commonUI/general/src/directives/MCTResize.js b/platform/commonUI/general/src/directives/MCTResize.js new file mode 100644 index 0000000000..10e4256c50 --- /dev/null +++ b/platform/commonUI/general/src/directives/MCTResize.js @@ -0,0 +1,82 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + // Default resize interval + var DEFAULT_INTERVAL = 100; + + /** + * The mct-resize directive allows the size of a displayed + * HTML element to be tracked. This is done by polling, + * since the DOM API does not currently provide suitable + * events to watch this reliably. + * + * Attributes related to this directive are interpreted as + * follows: + * + * * `mct-resize`: An Angular expression to evaluate when + * the size changes; the variable `bounds` will be provided + * with two fields, `width` and `height`, both in pixels. + * * `mct-resize-interval`: Optional; the interval, in milliseconds, + * at which to watch for updates. In some cases checking for + * resize can carry a cost (it forces recalculation of + * positions within the document) so it may be preferable to watch + * infrequently. If omitted, a default of 100ms will be used. + * This is an Angular expression, and it will be re-evaluated after + * each interval. + * + * @constructor + * + */ + function MCTResize($timeout) { + + // Link; start listening for changes to an element's size + function link(scope, element, attrs) { + var lastBounds; + + // Determine how long to wait before the next update + function currentInterval() { + return attrs.mctResizeInterval ? + scope.$eval(attrs.mctResizeInterval) : + DEFAULT_INTERVAL; + } + + // Evaluate mct-resize with the current bounds + function fireEval(bounds) { + // Only update when bounds actually change + if (!lastBounds || + lastBounds.width !== bounds.width || + lastBounds.height !== bounds.height) { + scope.$eval(attrs.mctResize, { bounds: bounds }); + lastBounds = bounds; + } + } + + // Callback to fire after each timeout; + // update bounds and schedule another timeout + function onInterval() { + fireEval({ + width: element[0].offsetWidth, + height: element[0].offsetHeight + }); + $timeout(onInterval, currentInterval()); + } + + // Handle the initial callback + onInterval(); + } + + return { + // mct-resize only makes sense as an attribute + restrict: "A", + // Link function, to begin watching for changes + link: link + }; + } + + return MCTResize; + } +); \ No newline at end of file diff --git a/platform/commonUI/general/test/ActionGroupControllerSpec.js b/platform/commonUI/general/test/controllers/ActionGroupControllerSpec.js similarity index 98% rename from platform/commonUI/general/test/ActionGroupControllerSpec.js rename to platform/commonUI/general/test/controllers/ActionGroupControllerSpec.js index 5177d09f4c..314e070bc5 100644 --- a/platform/commonUI/general/test/ActionGroupControllerSpec.js +++ b/platform/commonUI/general/test/controllers/ActionGroupControllerSpec.js @@ -1,7 +1,7 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../src/ActionGroupController"], + ["../../src/controllers/ActionGroupController"], function (ActionGroupController) { "use strict"; diff --git a/platform/commonUI/general/test/BottomBarControllerSpec.js b/platform/commonUI/general/test/controllers/BottomBarControllerSpec.js similarity index 97% rename from platform/commonUI/general/test/BottomBarControllerSpec.js rename to platform/commonUI/general/test/controllers/BottomBarControllerSpec.js index c163105416..e26b39add6 100644 --- a/platform/commonUI/general/test/BottomBarControllerSpec.js +++ b/platform/commonUI/general/test/controllers/BottomBarControllerSpec.js @@ -1,7 +1,7 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../src/BottomBarController"], + ["../../src/controllers/BottomBarController"], function (BottomBarController) { "use strict"; diff --git a/platform/commonUI/general/test/ClickAwayControllerSpec.js b/platform/commonUI/general/test/controllers/ClickAwayControllerSpec.js similarity index 97% rename from platform/commonUI/general/test/ClickAwayControllerSpec.js rename to platform/commonUI/general/test/controllers/ClickAwayControllerSpec.js index 57d81ce59b..024bad4ecb 100644 --- a/platform/commonUI/general/test/ClickAwayControllerSpec.js +++ b/platform/commonUI/general/test/controllers/ClickAwayControllerSpec.js @@ -1,7 +1,7 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../src/ClickAwayController"], + ["../../src/controllers/ClickAwayController"], function (ClickAwayController) { "use strict"; diff --git a/platform/commonUI/general/test/ContextMenuControllerSpec.js b/platform/commonUI/general/test/controllers/ContextMenuControllerSpec.js similarity index 96% rename from platform/commonUI/general/test/ContextMenuControllerSpec.js rename to platform/commonUI/general/test/controllers/ContextMenuControllerSpec.js index bb6aa63f86..ea86ca42f2 100644 --- a/platform/commonUI/general/test/ContextMenuControllerSpec.js +++ b/platform/commonUI/general/test/controllers/ContextMenuControllerSpec.js @@ -1,7 +1,7 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../src/ContextMenuController"], + ["../../src/controllers/ContextMenuController"], function (ContextMenuController) { "use strict"; diff --git a/platform/commonUI/general/test/controllers/GetterSetterControllerSpec.js b/platform/commonUI/general/test/controllers/GetterSetterControllerSpec.js new file mode 100644 index 0000000000..caa4e0c724 --- /dev/null +++ b/platform/commonUI/general/test/controllers/GetterSetterControllerSpec.js @@ -0,0 +1,64 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/controllers/GetterSetterController"], + function (GetterSetterController) { + "use strict"; + + describe("The getter-setter controller", function () { + var mockScope, + mockModel, + controller; + + beforeEach(function () { + mockScope = jasmine.createSpyObj("$scope", ["$watch"]); + mockModel = jasmine.createSpy("ngModel"); + mockScope.ngModel = mockModel; + controller = new GetterSetterController(mockScope); + }); + + it("watches for changes to external and internal mode", function () { + expect(mockScope.$watch).toHaveBeenCalledWith( + "ngModel()", + jasmine.any(Function) + ); + expect(mockScope.$watch).toHaveBeenCalledWith( + "getterSetter.value", + jasmine.any(Function) + ); + }); + + it("updates an external function when changes are detected", function () { + mockScope.getterSetter.value = "some new value"; + // Verify precondition + expect(mockScope.ngModel) + .not.toHaveBeenCalledWith("some new value"); + // Fire the matching watcher + mockScope.$watch.calls.forEach(function (call) { + if (call.args[0] === "getterSetter.value") { + call.args[1](mockScope.getterSetter.value); + } + }); + // Verify getter-setter was notified + expect(mockScope.ngModel) + .toHaveBeenCalledWith("some new value"); + }); + + it("updates internal state when external changes are detected", function () { + mockScope.ngModel.andReturn("some other new value"); + // Verify precondition + expect(mockScope.getterSetter.value).toBeUndefined(); + // Fire the matching watcher + mockScope.$watch.calls.forEach(function (call) { + if (call.args[0] === "ngModel()") { + call.args[1]("some other new value"); + } + }); + // Verify state in scope was updated + expect(mockScope.getterSetter.value) + .toEqual("some other new value"); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/general/test/ToggleControllerSpec.js b/platform/commonUI/general/test/controllers/ToggleControllerSpec.js similarity index 96% rename from platform/commonUI/general/test/ToggleControllerSpec.js rename to platform/commonUI/general/test/controllers/ToggleControllerSpec.js index f2059d4c25..f070135ba2 100644 --- a/platform/commonUI/general/test/ToggleControllerSpec.js +++ b/platform/commonUI/general/test/controllers/ToggleControllerSpec.js @@ -1,7 +1,7 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../src/ToggleController"], + ["../../src/controllers/ToggleController"], function (ToggleController) { "use strict"; diff --git a/platform/commonUI/general/test/TreeNodeControllerSpec.js b/platform/commonUI/general/test/controllers/TreeNodeControllerSpec.js similarity index 99% rename from platform/commonUI/general/test/TreeNodeControllerSpec.js rename to platform/commonUI/general/test/controllers/TreeNodeControllerSpec.js index 62602c501f..ee8f15d7c5 100644 --- a/platform/commonUI/general/test/TreeNodeControllerSpec.js +++ b/platform/commonUI/general/test/controllers/TreeNodeControllerSpec.js @@ -1,7 +1,7 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../src/TreeNodeController"], + ["../../src/controllers/TreeNodeController"], function (TreeNodeController) { "use strict"; diff --git a/platform/commonUI/general/test/ViewSwitcherControllerSpec.js b/platform/commonUI/general/test/controllers/ViewSwitcherControllerSpec.js similarity index 98% rename from platform/commonUI/general/test/ViewSwitcherControllerSpec.js rename to platform/commonUI/general/test/controllers/ViewSwitcherControllerSpec.js index feb86a9cf6..af0b99aa88 100644 --- a/platform/commonUI/general/test/ViewSwitcherControllerSpec.js +++ b/platform/commonUI/general/test/controllers/ViewSwitcherControllerSpec.js @@ -4,7 +4,7 @@ * MCTRepresentationSpec. Created by vwoeltje on 11/6/14. */ define( - ["../src/ViewSwitcherController"], + ["../../src/controllers/ViewSwitcherController"], function (ViewSwitcherController) { "use strict"; diff --git a/platform/commonUI/general/test/MCTContainerSpec.js b/platform/commonUI/general/test/directives/MCTContainerSpec.js similarity index 98% rename from platform/commonUI/general/test/MCTContainerSpec.js rename to platform/commonUI/general/test/directives/MCTContainerSpec.js index 709adc3d82..d90f512180 100644 --- a/platform/commonUI/general/test/MCTContainerSpec.js +++ b/platform/commonUI/general/test/directives/MCTContainerSpec.js @@ -1,7 +1,7 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../src/MCTContainer"], + ["../../src/directives/MCTContainer"], function (MCTContainer) { "use strict"; diff --git a/platform/commonUI/general/test/MCTDragSpec.js b/platform/commonUI/general/test/directives/MCTDragSpec.js similarity index 99% rename from platform/commonUI/general/test/MCTDragSpec.js rename to platform/commonUI/general/test/directives/MCTDragSpec.js index a5805e1f6b..f73d647111 100644 --- a/platform/commonUI/general/test/MCTDragSpec.js +++ b/platform/commonUI/general/test/directives/MCTDragSpec.js @@ -1,7 +1,7 @@ /*global define,describe,it,expect,beforeEach,jasmine*/ define( - ["../src/MCTDrag"], + ["../../src/directives/MCTDrag"], function (MCTDrag) { "use strict"; diff --git a/platform/commonUI/general/test/directives/MCTResizeSpec.js b/platform/commonUI/general/test/directives/MCTResizeSpec.js new file mode 100644 index 0000000000..4f07701d50 --- /dev/null +++ b/platform/commonUI/general/test/directives/MCTResizeSpec.js @@ -0,0 +1,68 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/directives/MCTResize"], + function (MCTResize) { + "use strict"; + + describe("The mct-resize directive", function () { + var mockTimeout, + mockScope, + testElement, + testAttrs, + mctResize; + + beforeEach(function () { + mockTimeout = jasmine.createSpy("$timeout"); + mockScope = jasmine.createSpyObj("$scope", ["$eval"]); + + testElement = { offsetWidth: 100, offsetHeight: 200 }; + testAttrs = { mctResize: "some-expr" }; + + mctResize = new MCTResize(mockTimeout); + }); + + it("is applicable as an attribute only", function () { + expect(mctResize.restrict).toEqual("A"); + }); + + it("starts tracking size changes upon link", function () { + expect(mockTimeout).not.toHaveBeenCalled(); + mctResize.link(mockScope, [testElement], testAttrs); + expect(mockTimeout).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.any(Number) + ); + expect(mockScope.$eval).toHaveBeenCalledWith( + testAttrs.mctResize, + { bounds: { width: 100, height: 200 } } + ); + }); + + it("reports size changes on a timeout", function () { + mctResize.link(mockScope, [testElement], testAttrs); + + // Change the element's apparent size + testElement.offsetWidth = 300; + testElement.offsetHeight = 350; + + // Shouldn't know about this yet... + expect(mockScope.$eval).not.toHaveBeenCalledWith( + testAttrs.mctResize, + { bounds: { width: 300, height: 350 } } + ); + + // Fire the timeout + mockTimeout.mostRecentCall.args[0](); + + // Should have triggered an evaluation of mctResize + // with the new width & height + expect(mockScope.$eval).toHaveBeenCalledWith( + testAttrs.mctResize, + { bounds: { width: 300, height: 350 } } + ); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/general/test/suite.json b/platform/commonUI/general/test/suite.json index 590628ed91..05169c51b7 100644 --- a/platform/commonUI/general/test/suite.json +++ b/platform/commonUI/general/test/suite.json @@ -1,11 +1,13 @@ [ - "ActionGroupController", - "BottomBarController", - "ClickAwayController", - "ContextMenuController", - "MCTContainer", - "MCTDrag", - "ToggleController", - "TreeNodeController", - "ViewSwitcherController" + "controllers/ActionGroupController", + "controllers/BottomBarController", + "controllers/ClickAwayController", + "controllers/ContextMenuController", + "controllers/GetterSetterController", + "controllers/ToggleController", + "controllers/TreeNodeController", + "controllers/ViewSwitcherController", + "directives/MCTContainer", + "directives/MCTDrag", + "directives/MCTResize" ] \ No newline at end of file diff --git a/platform/telemetry/bundle.json b/platform/telemetry/bundle.json index 4b63f3f5f5..b99d497fbc 100644 --- a/platform/telemetry/bundle.json +++ b/platform/telemetry/bundle.json @@ -28,6 +28,11 @@ { "key": "telemetryFormatter", "implementation": "TelemetryFormatter.js" + }, + { + "key": "telemetrySubscriber", + "implementation": "TelemetrySubscriber.js", + "depends": [ "$q", "$timeout" ] } ] } diff --git a/platform/telemetry/src/TelemetryAggregator.js b/platform/telemetry/src/TelemetryAggregator.js index 797fc55c72..a1edfc8901 100644 --- a/platform/telemetry/src/TelemetryAggregator.js +++ b/platform/telemetry/src/TelemetryAggregator.js @@ -38,6 +38,23 @@ define( })).then(mergeResults); } + // Subscribe to updates from all providers + function subscribe(callback, requests) { + var unsubscribes = telemetryProviders.map(function (provider) { + return provider.subscribe(callback, requests); + }); + + // Return an unsubscribe function that invokes unsubscribe + // for all providers. + return function () { + unsubscribes.forEach(function (unsubscribe) { + if (unsubscribe) { + unsubscribe(); + } + }); + }; + } + return { /** * Request telemetry data. @@ -47,7 +64,23 @@ define( * which may (or may not, depending on * availability) satisfy the requests */ - requestTelemetry: requestTelemetry + requestTelemetry: requestTelemetry, + /** + * Subscribe to streaming updates to telemetry data. + * The provided callback will be invoked as new + * telemetry becomes available; as an argument, it + * will receive an object of key-value pairs, where + * keys are source identifiers and values are objects + * of key-value pairs, where keys are point identifiers + * and values are TelemetrySeries objects containing + * the latest streaming telemetry. + * @param {Function} callback the callback to invoke + * @param {TelemetryRequest[]} requests an array of + * requests to be subscribed upon + * @returns {Function} a function which can be called + * to unsubscribe + */ + subscribe: subscribe }; } diff --git a/platform/telemetry/src/TelemetryCapability.js b/platform/telemetry/src/TelemetryCapability.js index 2cdcd23f02..d84b2053f7 100644 --- a/platform/telemetry/src/TelemetryCapability.js +++ b/platform/telemetry/src/TelemetryCapability.js @@ -16,20 +16,22 @@ define( * @constructor */ function TelemetryCapability($injector, $q, $log, domainObject) { - var telemetryService; + var telemetryService, + subscriptions = [], + unsubscribeFunction; // We could depend on telemetryService directly, but // there isn't a platform implementation of this; function getTelemetryService() { - if (!telemetryService) { + if (telemetryService === undefined) { try { telemetryService = - $q.when($injector.get("telemetryService")); + $injector.get("telemetryService"); } catch (e) { - // $injector should throw is telemetryService + // $injector should throw if telemetryService // is unavailable or unsatisfiable. $log.warn("Telemetry service unavailable"); - telemetryService = $q.reject(e); + telemetryService = null; } } return telemetryService; @@ -83,16 +85,34 @@ define( } // Issue a request to the service - function requestTelemetryFromService(telemetryService) { + function requestTelemetryFromService() { return telemetryService.requestTelemetry([fullRequest]); } // If a telemetryService is not available, // getTelemetryService() should reject, and this should // bubble through subsequent then calls. - return getTelemetryService() - .then(requestTelemetryFromService) - .then(getRelevantResponse); + return getTelemetryService() && + requestTelemetryFromService() + .then(getRelevantResponse); + } + + // Listen for real-time and/or streaming updates + function subscribe(callback, request) { + var fullRequest = buildRequest(request || {}); + + // Unpack the relevant telemetry series + function update(telemetries) { + var source = fullRequest.source, + key = fullRequest.key, + result = ((telemetries || {})[source] || {})[key]; + if (result) { + callback(result); + } + } + + return getTelemetryService() && + telemetryService.subscribe(update, [fullRequest]); } return { @@ -115,7 +135,18 @@ define( // type-level and object-level telemetry // properties return buildRequest({}); - } + }, + + /** + * Subscribe to updates to telemetry data for this domain + * object. + * @param {Function} callback a function to call when new + * data becomes available; the telemetry series + * containing the data will be given as an argument. + * @param {TelemetryRequest} [request] parameters for the + * subscription request + */ + subscribe: subscribe }; } diff --git a/platform/telemetry/src/TelemetryController.js b/platform/telemetry/src/TelemetryController.js index ddc9ad6c7d..23ed15165e 100644 --- a/platform/telemetry/src/TelemetryController.js +++ b/platform/telemetry/src/TelemetryController.js @@ -49,7 +49,11 @@ define( // Used for getTelemetryObjects; a reference is // stored so that this can be called in a watch - telemetryObjects: [] + telemetryObjects: [], + + // Whether or not this controller is active; once + // scope is destroyed, polling should stop. + active: true }; // Broadcast that a telemetryUpdate has occurred. @@ -227,14 +231,25 @@ define( } self.refreshing = false; - startTimeout(); + + if (self.active) { + startTimeout(); + } }, self.interval); } } + // Stop polling for changes + function deactivate() { + self.active = false; + } + // Watch for a represented domain object $scope.$watch("domainObject", getTelemetryObjects); + // Stop polling when destroyed + $scope.$on("$destroy", deactivate); + // Begin polling for data changes startTimeout(); diff --git a/platform/telemetry/src/TelemetrySubscriber.js b/platform/telemetry/src/TelemetrySubscriber.js new file mode 100644 index 0000000000..d422a3af31 --- /dev/null +++ b/platform/telemetry/src/TelemetrySubscriber.js @@ -0,0 +1,54 @@ +/*global define*/ + +define( + ["./TelemetrySubscription"], + function (TelemetrySubscription) { + "use strict"; + + /** + * The TelemetrySubscriber is a service which allows + * subscriptions to be made for new data associated with + * domain objects. It is exposed as a service named + * `telemetrySubscriber`. + * + * Subscriptions may also be made directly using the + * `telemetry` capability of a domain objcet; the subscriber + * uses this as well, but additionally handles delegation + * (e.g. for telemetry panels) as well as latest-value + * extraction. + * + * @constructor + * @param $q Angular's $q + * @param $timeout Angular's $timeout + */ + function TelemetrySubscriber($q, $timeout) { + return { + /** + * Subscribe to streaming telemetry updates + * associated with this domain object (either + * directly or via capability delegation.) + * + * @param {DomainObject} domainObject the object whose + * associated telemetry data is of interest + * @param {Function} callback a function to invoke + * when new data has become available. + * @returns {TelemetrySubscription} the subscription, + * which will provide access to latest values. + * + * @method + * @memberof TelemetrySubscriber + */ + subscribe: function (domainObject, callback) { + return new TelemetrySubscription( + $q, + $timeout, + domainObject, + callback + ); + } + }; + } + + return TelemetrySubscriber; + } +); \ No newline at end of file diff --git a/platform/telemetry/src/TelemetrySubscription.js b/platform/telemetry/src/TelemetrySubscription.js new file mode 100644 index 0000000000..5e929d8ee4 --- /dev/null +++ b/platform/telemetry/src/TelemetrySubscription.js @@ -0,0 +1,199 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + + /** + * A TelemetrySubscription tracks latest values for streaming + * telemetry data and handles notifying interested observers. + * It implements the interesting behavior behind the + * `telemetrySubscriber` service. + * + * Subscriptions may also be made directly using the + * `telemetry` capability of a domain objcet; the subscriber + * uses this as well, but additionally handles delegation + * (e.g. for telemetry panels) as well as latest-value + * extraction. + * + * @constructor + * @param $q Angular's $q + * @param $timeout Angular's $timeout + * @param {DomainObject} domainObject the object whose + * associated telemetry data is of interest + * @param {Function} callback a function to invoke + * when new data has become available. + */ + function TelemetrySubscription($q, $timeout, domainObject, callback) { + var unsubscribePromise, + latestValues = {}, + telemetryObjects = [], + updatePending; + + // Look up domain objects which have telemetry capabilities. + // This will either be the object in view, or object that + // this object delegates its telemetry capability to. + function promiseRelevantObjects(domainObject) { + // If object has been cleared, there are no relevant + // telemetry-providing domain objects. + if (!domainObject) { + return $q.when([]); + } + + // Otherwise, try delegation first, and attach the + // object itself if it has a telemetry capability. + return $q.when(domainObject.useCapability( + "delegation", + "telemetry" + )).then(function (result) { + var head = domainObject.hasCapability("telemetry") ? + [ domainObject ] : [], + tail = result || []; + return head.concat(tail); + }); + } + + // Invoke the observer callback to notify that new streaming + // data has become available. + function fireCallback() { + callback(); + // Clear the pending flag so that future updates will + // schedule this callback. + updatePending = false; + } + + // Update the latest telemetry data for a specific + // domain object. This will notify listeners. + function update(domainObject, telemetry) { + var count = telemetry && telemetry.getPointCount(); + + // Only schedule notification if there isn't already + // a notification pending (and if we actually have + // data) + if (!updatePending && count) { + updatePending = true; + $timeout(fireCallback, 0); + } + + // Update the latest-value table + if (count > 0) { + latestValues[domainObject.getId()] = { + domain: telemetry.getDomainValue(count - 1), + range: telemetry.getRangeValue(count - 1) + }; + } + } + + // Prepare a subscription to a specific telemetry-providing + // domain object. + function subscribe(domainObject) { + var telemetryCapability = + domainObject.getCapability("telemetry"); + return telemetryCapability.subscribe(function (telemetry) { + update(domainObject, telemetry); + }); + } + + // Prepare subscriptions to all relevant telemetry-providing + // domain objects. + function subscribeAll(domainObjects) { + return domainObjects.map(subscribe); + } + + // Cache a reference to all relevant telemetry-providing + // domain objects. This will be called during the + // initial subscription chain; this allows `getTelemetryObjects()` + // to return a non-Promise to simplify usage elsewhere. + function cacheObjectReferences(objects) { + telemetryObjects = objects; + return objects; + } + + // Get a reference to relevant objects (those with telemetry + // capabilities) and subscribe to their telemetry updates. + // Keep a reference to their promised return values, as these + // will be unsubscribe functions. (This must be a promise + // because delegation is supported, and retrieving delegate + // telemetry-capable objects may be an asynchronous operation.) + unsubscribePromise = + promiseRelevantObjects(domainObject) + .then(cacheObjectReferences) + .then(subscribeAll); + + return { + /** + * Terminate all underlying subscriptions associated + * with this object. + * @method + * @memberof TelemetrySubscription + */ + unsubscribe: function () { + return unsubscribePromise.then(function (unsubscribes) { + return $q.all(unsubscribes.map(function (unsubscribe) { + return unsubscribe(); + })); + }); + }, + /** + * Get the most recent domain value that has been observed + * for the specified domain object. This will typically be + * a timestamp. + * + * The domain object passed here should be one that is + * subscribed-to here; that is, it should be one of the + * domain objects returned by `getTelemetryObjects()`. + * + * @param {DomainObject} domainObject the object of interest + * @returns the most recent domain value observed + * @method + * @memberof TelemetrySubscription + */ + getDomainValue: function (domainObject) { + var id = domainObject.getId(); + return (latestValues[id] || {}).domain; + }, + /** + * Get the most recent range value that has been observed + * for the specified domain object. This will typically + * be a numeric measurement. + * + * The domain object passed here should be one that is + * subscribed-to here; that is, it should be one of the + * domain objects returned by `getTelemetryObjects()`. + * + * @param {DomainObject} domainObject the object of interest + * @returns the most recent range value observed + * @method + * @memberof TelemetrySubscription + */ + getRangeValue: function (domainObject) { + var id = domainObject.getId(); + return (latestValues[id] || {}).range; + }, + /** + * Get all telemetry-providing domain objects which are + * being observed as part of this subscription. + * + * Capability delegation will be taken into account (so, if + * a Telemetry Panel was passed in the constructor, this will + * return its contents.) Capability delegation is resolved + * asynchronously so the return value here may change over + * time; while this resolution is pending, this method will + * return an empty array. + * + * @returns {DomainObject[]} all subscribed-to domain objects + * @method + * @memberof TelemetrySubscription + */ + getTelemetryObjects: function () { + return telemetryObjects; + } + }; + } + + return TelemetrySubscription; + + } +); \ No newline at end of file diff --git a/platform/telemetry/test/TelemetryAggregatorSpec.js b/platform/telemetry/test/TelemetryAggregatorSpec.js index bb6a48df11..200d2153a6 100644 --- a/platform/telemetry/test/TelemetryAggregatorSpec.js +++ b/platform/telemetry/test/TelemetryAggregatorSpec.js @@ -8,6 +8,7 @@ define( describe("The telemetry aggregator", function () { var mockQ, mockProviders, + mockUnsubscribes, aggregator; function mockPromise(value) { @@ -20,10 +21,15 @@ define( function mockProvider(key, index) { var provider = jasmine.createSpyObj( - "provider" + index, - [ "requestTelemetry" ] - ); + "provider" + index, + [ "requestTelemetry", "subscribe" ] + ), + unsubscribe = jasmine.createSpy("unsubscribe" + index); provider.requestTelemetry.andReturn({ someKey: key }); + provider.subscribe.andReturn(unsubscribe); + + // Store to verify interactions later + mockUnsubscribes[index] = unsubscribe; return provider; } @@ -31,6 +37,7 @@ define( mockQ = jasmine.createSpyObj("$q", [ "all" ]); mockQ.all.andReturn(mockPromise([])); + mockUnsubscribes = []; mockProviders = [ "a", "b", "c" ].map(mockProvider); aggregator = new TelemetryAggregator(mockQ, mockProviders); @@ -74,6 +81,24 @@ define( }); }); + it("broadcasts subscriptions from all providers", function () { + var mockCallback = jasmine.createSpy("callback"), + subscription = aggregator.subscribe(mockCallback); + + // Make sure all providers got subscribed to + mockProviders.forEach(function (mockProvider) { + expect(mockProvider.subscribe).toHaveBeenCalled(); + }); + + // Verify that unsubscription gets broadcast too + mockUnsubscribes.forEach(function (mockUnsubscribe) { + expect(mockUnsubscribe).not.toHaveBeenCalled(); + }); + subscription(); // unsubscribe + mockUnsubscribes.forEach(function (mockUnsubscribe) { + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); }); } diff --git a/platform/telemetry/test/TelemetryCapabilitySpec.js b/platform/telemetry/test/TelemetryCapabilitySpec.js index 1f8ef9ecbf..6ef083f766 100644 --- a/platform/telemetry/test/TelemetryCapabilitySpec.js +++ b/platform/telemetry/test/TelemetryCapabilitySpec.js @@ -12,6 +12,7 @@ define( mockDomainObject, mockTelemetryService, mockReject, + mockUnsubscribe, telemetry; @@ -33,9 +34,10 @@ define( ); mockTelemetryService = jasmine.createSpyObj( "telemetryService", - [ "requestTelemetry" ] + [ "requestTelemetry", "subscribe" ] ); mockReject = jasmine.createSpyObj("reject", ["then"]); + mockUnsubscribe = jasmine.createSpy("unsubscribe"); mockInjector.get.andReturn(mockTelemetryService); @@ -50,6 +52,11 @@ define( } }); + mockTelemetryService.requestTelemetry + .andReturn(mockPromise({})); + mockTelemetryService.subscribe + .andReturn(mockUnsubscribe); + // Bubble up... mockReject.then.andReturn(mockReject); @@ -124,6 +131,36 @@ define( expect(mockLog.warn).toHaveBeenCalled(); }); + it("allows subscriptions to updates", function () { + var mockCallback = jasmine.createSpy("callback"), + subscription = telemetry.subscribe(mockCallback); + + // Verify subscription to the appropriate object + expect(mockTelemetryService.subscribe).toHaveBeenCalledWith( + jasmine.any(Function), + [{ + id: "testId", // from domain object + source: "testSource", + key: "testKey" + }] + ); + + // Check that the callback gets invoked + expect(mockCallback).not.toHaveBeenCalled(); + mockTelemetryService.subscribe.mostRecentCall.args[0]({ + testSource: { testKey: { someKey: "some value" } } + }); + expect(mockCallback).toHaveBeenCalledWith( + { someKey: "some value" } + ); + + // Finally, unsubscribe + expect(mockUnsubscribe).not.toHaveBeenCalled(); + subscription(); // should be an unsubscribe function + expect(mockUnsubscribe).toHaveBeenCalled(); + + + }); }); } ); \ No newline at end of file diff --git a/platform/telemetry/test/TelemetryControllerSpec.js b/platform/telemetry/test/TelemetryControllerSpec.js index decc414e91..4714a70520 100644 --- a/platform/telemetry/test/TelemetryControllerSpec.js +++ b/platform/telemetry/test/TelemetryControllerSpec.js @@ -187,6 +187,13 @@ define( .toHaveBeenCalledWith("telemetryUpdate"); }); + it("listens for scope destruction to clean up", function () { + expect(mockScope.$on).toHaveBeenCalledWith( + "$destroy", + jasmine.any(Function) + ); + mockScope.$on.mostRecentCall.args[1](); + }); }); } diff --git a/platform/telemetry/test/TelemetryFormatterSpec.js b/platform/telemetry/test/TelemetryFormatterSpec.js index 7ff442f434..d4abf0ba23 100644 --- a/platform/telemetry/test/TelemetryFormatterSpec.js +++ b/platform/telemetry/test/TelemetryFormatterSpec.js @@ -1,8 +1,5 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ -/** - * MergeModelsSpec. Created by vwoeltje on 11/6/14. - */ define( ["../src/TelemetryFormatter"], function (TelemetryFormatter) { diff --git a/platform/telemetry/test/TelemetrySubscriberSpec.js b/platform/telemetry/test/TelemetrySubscriberSpec.js new file mode 100644 index 0000000000..a2989f917b --- /dev/null +++ b/platform/telemetry/test/TelemetrySubscriberSpec.js @@ -0,0 +1,54 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/TelemetrySubscriber"], + function (TelemetrySubscriber) { + "use strict"; + + describe("The telemetry subscriber", function () { + // TelemetrySubscriber just provides a factory + // for TelemetrySubscription, so most real testing + // should happen there. + var mockQ, + mockTimeout, + mockDomainObject, + mockCallback, + mockPromise, + subscriber; + + beforeEach(function () { + mockQ = jasmine.createSpyObj("$q", ["when"]); + mockTimeout = jasmine.createSpy("$timeout"); + mockDomainObject = jasmine.createSpyObj( + "domainObject", + [ "getCapability", "useCapability", "hasCapability" ] + ); + mockCallback = jasmine.createSpy("callback"); + mockPromise = jasmine.createSpyObj("promise", ["then"]); + + mockQ.when.andReturn(mockPromise); + mockPromise.then.andReturn(mockPromise); + + subscriber = new TelemetrySubscriber(mockQ, mockTimeout); + }); + + it("acts as a factory for subscription objects", function () { + var subscription = subscriber.subscribe( + mockDomainObject, + mockCallback + ); + // Just verify that this looks like a TelemetrySubscription + [ + "unsubscribe", + "getTelemetryObjects", + "getRangeValue", + "getDomainValue" + ].forEach(function (method) { + expect(subscription[method]) + .toEqual(jasmine.any(Function)); + }); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/telemetry/test/TelemetrySubscriptionSpec.js b/platform/telemetry/test/TelemetrySubscriptionSpec.js new file mode 100644 index 0000000000..44bd6c4d20 --- /dev/null +++ b/platform/telemetry/test/TelemetrySubscriptionSpec.js @@ -0,0 +1,125 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/TelemetrySubscription"], + function (TelemetrySubscription) { + "use strict"; + + describe("A telemetry subscription", function () { + var mockQ, + mockTimeout, + mockDomainObject, + mockCallback, + mockTelemetry, + mockUnsubscribe, + mockSeries, + subscription; + + function mockPromise(value) { + return (value && value.then) ? value : { + then: function (callback) { + return mockPromise(callback(value)); + } + }; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj("$q", ["when", "all"]); + mockTimeout = jasmine.createSpy("$timeout"); + mockDomainObject = jasmine.createSpyObj( + "domainObject", + [ "getCapability", "useCapability", "hasCapability", "getId" ] + ); + mockCallback = jasmine.createSpy("callback"); + mockTelemetry = jasmine.createSpyObj( + "telemetry", + ["subscribe"] + ); + mockUnsubscribe = jasmine.createSpy("unsubscribe"); + mockSeries = jasmine.createSpyObj( + "series", + [ "getPointCount", "getDomainValue", "getRangeValue" ] + ); + + mockQ.when.andCallFake(mockPromise); + + mockDomainObject.hasCapability.andReturn(true); + mockDomainObject.getCapability.andReturn(mockTelemetry); + mockDomainObject.getId.andReturn('test-id'); + + mockTelemetry.subscribe.andReturn(mockUnsubscribe); + + mockSeries.getPointCount.andReturn(42); + mockSeries.getDomainValue.andReturn(123456); + mockSeries.getRangeValue.andReturn(789); + + subscription = new TelemetrySubscription( + mockQ, + mockTimeout, + mockDomainObject, + mockCallback + ); + }); + + it("subscribes to the provided object", function () { + expect(mockTelemetry.subscribe).toHaveBeenCalled(); + }); + + it("unsubscribes on request", function () { + expect(mockUnsubscribe).not.toHaveBeenCalled(); + subscription.unsubscribe(); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it("fires callbacks when subscriptions update", function () { + expect(mockCallback).not.toHaveBeenCalled(); + mockTelemetry.subscribe.mostRecentCall.args[0](mockSeries); + // This gets fired via a timeout, so trigger that + expect(mockTimeout).toHaveBeenCalledWith( + jasmine.any(Function), + 0 + ); + mockTimeout.mostRecentCall.args[0](); + // Should have triggered the callback to alert that + // new data was available + expect(mockCallback).toHaveBeenCalled(); + }); + + it("fires subscription callbacks once per cycle", function () { + var i; + + for (i = 0; i < 100; i += 1) { + mockTelemetry.subscribe.mostRecentCall.args[0](mockSeries); + } + // This gets fired via a timeout, so trigger any of those + mockTimeout.calls.forEach(function (call) { + call.args[0](); + }); + // Should have only triggered the + expect(mockCallback.calls.length).toEqual(1); + }); + + it("reports its latest observed data values", function () { + mockTelemetry.subscribe.mostRecentCall.args[0](mockSeries); + // This gets fired via a timeout, so trigger that + mockTimeout.mostRecentCall.args[0](); + // Verify that the last sample was looked at + expect(mockSeries.getDomainValue).toHaveBeenCalledWith(41); + expect(mockSeries.getRangeValue).toHaveBeenCalledWith(41); + // Domain and range values should now be available + expect(subscription.getDomainValue(mockDomainObject)) + .toEqual(123456); + expect(subscription.getRangeValue(mockDomainObject)) + .toEqual(789); + }); + + it("provides no objects if no domain object is provided", function () { + // omit last arguments + subscription = new TelemetrySubscription(mockQ, mockTimeout); + + // Should have no objects + expect(subscription.getTelemetryObjects()).toEqual([]); + }); + }); + } +); \ No newline at end of file diff --git a/platform/telemetry/test/suite.json b/platform/telemetry/test/suite.json index 83226f2197..68110d0a6e 100644 --- a/platform/telemetry/test/suite.json +++ b/platform/telemetry/test/suite.json @@ -2,5 +2,7 @@ "TelemetryAggregator", "TelemetryCapability", "TelemetryController", - "TelemetryFormatter" + "TelemetryFormatter", + "TelemetrySubscriber", + "TelemetrySubscription" ] \ No newline at end of file