Subscribables
Overview
The avionics framework provides a variety of classes that provide observable values. These classes are collectively referred to as subscribables. The Subscribable
interface describes the most generic of these classes while other related interfaces exist for subscribables with more specialized values, such as arrays, sets, and maps.
The Subscribable
interface (and its more specialized versions) define a common API with which consumers can access observed values from implementing classes. However, the interface is purposely non-prescriptive about where its observed value is sourced from or when/how/why the value can be changed (or even if it can be changed). This keeps the interface flexible and allows a for a wide variety of implementing classes.
Subjects
Most of the classes included in the framework that are subscribables fall under the umbrella of subjects. A subject is a generic subscribable that provides some degree of control over the value it provides.
Below are some of the basic types of subjects included in the framework.
Subject
This is the most generic and flexible subject. A Subject
can provide any type of value and allows its value to be modified without restriction using the set()
method:
import { Subject } from '@microsoft/msfs-sdk';
// The first argument passed to create() defines the new subject's initial value.
const numberSubject = Subject.create<number>(0);
numberSubject.set(10);
const stringSubject = Subject.create<string>('');
stringSubject.set('foo');
const objectSubject = Subject.create<Record<string, any>>({});
objectSubject.set({ a: 10, b: false });
By default, Subject
uses Javascript's built-in "strict equality" operator (===
) to determine whether two values are equal. This is important to keep in mind because Subject
will notify subscribers after its value is modified if and only if the old and new values are considered not equal. When working with primitive types, the strict equality operator usually gives the desired behavior. However, sometimes this isn't the case, in which case you can define your own equality logic:
// Here we define a custom equality function to avoid the surprising convention where NaN === NaN is false.
const numberSubject = Subject.create<number>(NaN, (a, b) => {
if (isNaN(a) && isNaN(b)) {
return true;
} else {
return a === b;
}
});
numberSubject.set(NaN); // Will not trigger a notification.
Custom equality logic is especially useful when working with object-valued subjects:
type LatLon = {
lat: number;
lon: number;
};
const latLonSubject1 = Subject.create<LatLon>({ lat: 0, lon: 0 });
latLonSubject1.set({ lat: 0, lon: 0 }); // Will trigger a notification!
const latLonSubject2 = Subject.create<LatLon>({ lat: 0, lon: 0 }, (a, b) => {
return a.lat === b.lat && a.lon === b.lon;
});
latLonSubject2.set({ lat: 0, lon: 0 }); // Will not trigger a notification.
latLonSubject2.set({ lat: 5, lon: 0 }); // Will trigger a notification.
When dealing with object values, Subject
works best when using immutable objects. When mutable objects must be used, keep in mind that any mutable object that is the value of a Subject
must be changed through the Subject
for the changes to trigger a notification.
Defining a custom mutator function will allow you to mutate the value of a Subject
in-place using set()
rather than having the new value replace the existing value by reference:
type LatLon = {
lat: number;
lon: number;
};
const latLonSubject = Subject.create<LatLon>(
// Initial value
{ lat: 0, lon: 0 },
// Equality function
(a, b) => a.lat === b.lat && a.lon === b.lon,
// Mutator function
(existingVal, newVal) => {
existingVal.lat = newVal.lat;
existingVal.lon = newVal.lon;
}
);
const newLatLon = { lat: 5, lon: 0 };
latLonSubject.set(newLatLon); // latLonSubject's value is now { lat: 5, lon: 0 }.
newLatLon.lon = 10; // latLonSubject's value is still { lat: 5, lon: 0 }.
latLonSubject.set(newLatLon); // latLonSubject's value is now { lat: 5, lon: 10 }.
When defining a custom mutator function, it is highly recommended that you also define a custom equality function. The default equality-by-reference behavior is almost never desired when working with mutable objects.
ComputedSubject
This is a subject which, like Subject
, allows you to freely change its value via a set()
method but applies a transformation function to all inputs passed to set()
:
import { ComputedSubject } from '@microsoft/msfs-sdk';
// Creates a ComputedSubject that converts numeric inputs to their string values.
// The initial value is set to '0' (converted from the number 0).
const subject = ComputedSubject.create<number, string>(0, input => input.toString());
subject.set(1); // subject's value is now '1'.
ComputedSubject
does not support custom equality or mutator functions. Therefore, it is recommended to only use it with primitive-typed values.
ConsumerSubject
This is a subject which takes its value from the data published to an event bus topic. You cannot freely change the value of a ConsumerSubject
. However, you can change the topic and/or Consumer
from which the subject's value is sourced:
import { ConsumerSubject } from '@microsoft/msfs-sdk';
// Creates a ConsumerSubject that sources its value from the 'n1_1' topic.
// The initial value is set to 0 in case a cached value has not yet been published to the topic.
const subject = ConsumerSubject.create<number>(bus.getSubscriber<EngineEvents>().on('n1_1'), 0);
bus.getPublisher<EngineEvents>().pub('n1_1', 50, false, true); // subject's value is now 50.
bus.getPublisher<EngineEvents>().pub('n1_1', 100, false, true); // subject's value is still 50.
// Removes subject's consumer source. It will now stop updating its value.
subject.setConsumer(null); // subject's value is still 100.
bus.getPublisher<EngineEvents>().pub('n1_1', 75, false, true); // subject's value is still 100.
bus.getPublisher<EngineEvents>().pub('n1_2', 25, false, true);
// Sets a new consumer source for subject such that it now sources its value from the 'n1_2' topic.
subject.setConsumer(bus.getSubscriber<EngineEvents>().on('n1_2')) // subject's value is now 25.
Like with Subject
, you can specify custom equality and mutator functions when creating a ConsumerSubject
. The default behavior for both is the same as the default behavior for Subject
.
ConsumerSubject
also implements the Subscription
interface. This is meant to represent the subscription to the event bus that updates the value of the subject. By manipulating ConsumerSubject
as a subscription, you can control control when its value is updated from the event bus:
const subject = ConsumerSubject.create<number>(bus.getSubscriber<EngineEvents>().on('n1_1'), 0);
bus.getPublisher<EngineEvents>().pub('n1_1', 50, false, true); // subject's value is now 50.
subject.pause();
bus.getPublisher<EngineEvents>().pub('n1_1', 100, false, true); // subject's value is still 50.
subject.resume(); // subject's value is now 100.
bus.getPublisher<EngineEvents>().pub('n1_1', 25, false, true); // subject's value is now 25.
You may have noticed in the above example that calling resume()
immediately updated the subject's value to the latest cached value published to the event bus even though we didn't specify that an initial notification should be triggered. This is because resuming ConsumerSubject
always triggers an initial notification from its source. This forced initial notification guarantees that the subject's value will immediately be updated (if possible) when the subject is resumed. However, the subject's subscribers will only be notified if the subject's value changes as a result of the update.
Like with all subscriptions, ConsumerSubject
should be cleaned up by calling its destroy()
method when it is no longer needed in order to prevent memory leaks.
Specialized Subscribables
The framework provides a number of more specialized subscribable classes. Read more about them here.
Using Subscribables
Getting and Subscribing to Values
The two most basic operations you can perform with a subscribable is to retrieve its value and to subscribe to it. Retrieving a subscribable's value is straightforward and accomplished using the get()
method. Subscribing to a subscribable allows a handler function to be executed when the subscribable's value changes and is accomplished using the sub()
method:
const subject = Subject.create(0);
subject.sub(value => { console.log(`Value changed to ${value}`); });
subject.set(1); // 'Value changed to 1' is logged to the console.
subject.set(1); // Nothing is logged to the console.
subject.set(5); // 'Value changed to 5' is logged to the console.
Using initialNotify
when Subscribing
In the above example, you may have noticed that nothing was logged to the console when sub()
was called. This is because by default, sub()
will not notify the handler of the subscribable's current value when the method is called; the first opportunity for the handler to be notified is the next time the subscribable's value changes after calling sub()
.
However, oftentimes you will want a handler to be notified of a subscribable's current value immediately upon subscribing. You can accomplish this by passing true
to the initialNotify
parameter of sub()
:
const subject = Subject.create(0);
subject.sub(value => {
console.log(`Value changed to ${value}`);
}, true); // 'Value changed to 0' is logged to the console.
Managing Subscriptions
Calling the sub()
method returns a Subscription
object which allows you to manage the new subscription set up by the method. For more information on subscriptions, refer to this page.
You can use the subscriptions returned by sub()
to pause and resume notifications. Initial notify on resume is supported and guaranteed to work for all of these subscriptions. You can also use sub()
to create subscriptions that are initially paused and resume them later when appropriate:
const subject = Subject.create(0);
// Creates a paused subscription. Initially paused subscriptions ignore the `initialNotify` argument,
// so nothing is logged to the console when the subscription is created.
const sub = subject.sub(value => {
console.log(`Value changed to ${value}`);
}, true, true);
subject.set(5); // Nothing is logged to the console.
sub.resume(true); // 'Value changed to 5' is logged to the console.
Finally, subscriptions can be destroyed. Once a subscription is destroyed, notifications will no longer be sent to the handler. It is recommended to always destroy subscriptions when they are no longer needed in order to allow the appropriate resources to be garbage collected and prevent memory leaks.
Piping Values to Other Subscribables
If you want to sync the value of one subscribable to another, you can do so using sub()
and an appropriate handler:
const source = Subject.create(1);
const target = Subject.create(0);
source.sub(value => { target.set(value); }, true); // target's value is now 1.
source.set(5); // target's value is now 5.
However, we can avoid the boilerplate associated with the above approach by taking advantage of the pipe()
method:
const source = Subject.create(1);
const target = Subject.create(0);
source.pipe(this.target); // target's value is now 1.
source.set(5); // target's value is now 5.
pipe()
sets up a value pipe from one subscribable to another subscribable that implements the MutableSubscribable
interface. The key feature of a MutableSubscribable
is the presence of a set()
method. Note that there is no requirement for set()
to directly set the value of the subscribable, or even that it accept inputs of the same type as the subscribable's value. For example, ComputedSubject
implements MutableSubscribable
and is a perfectly valid target for pipe()
as long as the value type of the source subscribable matches the input type of the ComputedSubject
:
const source = Subject.create<number>(1);
const target = ComputedSubject.create<number, string>(0, value => value.toString());
source.pipe(this.target); // target's value is now '1'.
source.set(5); // target's value is now '5'.
You can also specify an optional mapping function to pipe()
in order to pipe a transformed version of the source subscribable's value to the input of the target subscribable:
const source = Subject.create(1);
const target = Subject.create(0);
source.pipe(this.target, value => value * 2); // target's value is now 2.
source.set(5); // target's value is now 10.
Like sub()
, pipe()
returns a Subscription
object which can be used to pause, resume, and destroy the newly created pipe. You can also create an initially paused pipe. Importantly, there is no equivalent to sub()
's initialNotify
parameter. A new pipe always immediately pipes the current value of the source subscribable at the time the pipe was created to the target unless it is initially paused.
If for some reason you want to create a pipe without initial notify, you can create a paused pipe and then resume the pipe without initial notify:
source.pipe(target, mapFunc, true).resume();
An important caveat to keep in mind is that establishing a pipe does not prevent the value of the target subscribable from being changed through other means, including by other pipes. Therefore, a pipe is only guaranteed to keep the values of the source and target in "sync" if nothing else changes the value of the target:
const source = Subject.create(1);
const target = Subject.create(0);
source.pipe(target); // target's value is now 1.
source.set(2); // target's value is now 2.
target.set(0); // target's value is now 0.
source.set(3); // target's value is now 3.
Mapping a Subscribable to New Subscribables
In the previous section, we discussed how pipes can be used to sync the transformed value of one subscribable to another. Pipes are useful for mapping transformed values into pre-existing subscribables or when the source and/or target subscribable need to change over time:
const pipe1 = source1.pipe(target, mapFunc);
// ... time passes
pipe1.destroy();
const pipe2 = source2.pipe(target, mapFunc);
// ... time passes
pipe2.destroy();
const pipe3 = source3.pipe(target, mapFunc);
// ... and so on...
However, when you are allowed to create a single new subscribable to hold the mapped value and the source never changes, you can use the map()
method instead:
const source = Subject.create(1);
// Map source to twice its value.
const mapped = source.map(value => value * 2); // mapped's value is initialized to 2.
source.set(5); // mapped's value is now 10.
map()
creates and returns a new subscribable which implements the MappedSubscribable
interface. The mapped subscribable's value is derived from the source subscribable's value after applying a mapping function. Whenever the source subscribable's value changes and triggers a notification to subscribers, the mapped subscribable's value will automatically be updated. The mapped subscribable can be used just like any other subscribable. You can even chain mapped subscribables ad infinitum:
Subject.create(1)
.map(value => value * 2)
.map(value => 1 / value)
.map(value => value.toString()); // The last subscribable is initialized with a value of '0.5'.
Chaining mapped subscribables is only useful when the code creating the downstream mappings don't have control over the upstream mappings or when access to all of the intermediate values is needed. If neither of these conditions is true, then it is more efficient to combine the mapping functions into a single function and collapse the chain to just one mapping step.
The mapping function passed to map()
can take an additional optional parameter. This additional parameter is undefined
the first time the mapping function is called when the mapped subscribable is created. Afterwards, the parameter will provide the value of the mapped subscribable at the time the mapping function was invoked. This additional parameter can be used to map a value with hysteresis:
const source = Subject.create(9);
const mapped = source.map((value: number, previousVal?: boolean): boolean => {
return value >= (previousVal === true ? 8 : 10);
}); // mapped's value is initialized to false.
source.set(20); // mapped's value is now true.
source.set(9); // mapped's value is still true.
source.set(0); // mapped's value is now false.
The SubscribableMapFunctions
class can help you to create some common mapping functions.
A mapped subscribable implements the Subscription
interface because it effectively acts as a subscription to its parent subscribable that updates the mapped value. As such, it can be paused, resumed, and destroyed.
Pausing a mapped subscribable will temporarily stop its value from updating when its source subscribable's value changes until the mapped subscribable is resumed again. Resuming a mapped subscribable always triggers an initial notification from its source (even when false
is passed as the initialNotify
argument to resume()
) - this causes its value to immediately be updated from the current value of its source subscribable. Note that the mapped subscribable's subscribers will still only be notified if the resume operation changes the mapped subscribable's value. Once the mapped subscribable is resumed, it will continue to update its value in response to changes in the value of its source subscribable.
Destroying a mapped subscribable permanently stops its value from updating. Once destroyed, a mapped subscribable can no longer be paused or resumed. The value of a destroyed mapped subscribable can still be accessed via get()
, subscriptions to it will persist, and new subscriptions can even be created through sub()
, pipe()
, and map()
, but its value will be fixed to whatever it was when the subscribable was destroyed.
While map()
allows you to create mappings from one source/input to one output, sometimes you will need to map from multiple inputs to an output. For the latter case, you should use MappedSubject
instead.
Mapped subscribables created by map()
by default use the strict equality operator (===
) to compare values for equality and replace old values with new values by reference. You can choose to override this default behavior by specifying custom equality and mutator functions in the same way you would when creating a new Subject
:
const source = Subject.create(1);
// Map source to its natural logarithm.
const mapped = source.map(Math.log, (a, b) => {
if (isNaN(a) && isNaN(b)) {
return true;
} else {
return a === b;
}
});
mapped.sub(value => {
console.log(`Value changed to ${value}`);
}, true); // 'Value changed to 0' is logged to the console.
source.set(-1); // 'Value changed to NaN' is logged to the console.
source.set(-2); // Nothing is logged to the console.
The equality function used to create a mapped subscribable compares mapped values, not source values.