Replies: 16 comments 72 replies
-
You hit a good vibe here, I guess that would be a nice clean approach.
Here a former C/C++ programmer speaking :)
In all honesty, I think some responsibilities are placed wrongly. For example: RowingStatistics is aware of the update interval of both BLE and web interfaces and tailors the communication for it. Why not refresh the metrics on their side and let the endpoints decide they should act on it (and how). Like you indicate: all these user actions in Server.js are only a complicated way of telling RowingStatistics to stop or start (which RowingStatistics should tell the rest of the world). Some twists are odd (and created by me to fit the excisting pattern, I admit): RowingStatistics concludes the interval ends, tells server.js, server.js tells RowingStatistics the session is complete so it can stop, after which RowingStatistics will tell server.js it has stopped. IMHO, this should be handled internally by RowingStatistics. Server.js should tell RowingStatistics of user input, but not act upon it itself.
That is one way of looking at it. For RowingStatistics down I use a different pattern: I get a currentDt, send it downwards and I inspect the result. When all recipients know when they want to update (some update type has to be passed along, as the PM5 requires a stroke start event), stuff becomes more easy I think. So you have a GPIO input and the reciepients should be notified of a metric and possibly a resultingstrokestatus/sessionstatus update. Similar for an HR metrics update: HR metric gets sent, pheriperals should decide wether to act upon it. When BLE or UI get user input, only RowingStatistics can do something with it. So after that processing, the recipients should be notified. Currently, server.js basically processes the UI input, by telling RowingStatistics to do something.
I agree. But there are four: RowingStatistics is in itself just another event recipient. When recieving a 'stopped' event, it can do its things (instead of being told to write files by server.js).
I think Server.js should do that. Although main.js sounds comfortably familiar :), I don't know if node.js expects a server.js.
Its been a while since I used MVC pattern. But a small step towards it would be to move responsibilities to places where they should reside: update UI and peripherals when updates are available and make it more event-driven.
Yeah, I could look at RowingStatistics as well, as all chaos originates there...
I agree here, and GUI and BLE should/could fire identical events.
I agree, but you have several states changed (on stroke, interval and session level), but those are minor derails to be worked out. |
Beta Was this translation helpful? Give feedback.
-
@Abasz , come to think of it: why should RowingStatistics even be aware of the HR data? Wouldn't it make more sense to have features ask a HR datastore this (and simultanously ask |
Beta Was this translation helpful? Give feedback.
-
So I have put together a discussion starter class for the data service. What I did
So basically here whenever an update to any data is made the corresponding notification will be dispatched and subscribers will be notified as well as the data at that moment is sent along with the notification. An alternative approach would be that we dont send the data (just the notification), but rather provide an API (getter) on the data service that allows getting the data. I dont mind either approach (or can be both). The drawbacks of the first approach (only sending data along the notification) is that non-event based features (like a feature that gets data based on an internal timer) would need to keep a copy of the data. The drawback of the latter (i.e. signal the event and allow a getter for getting the data) is that it could happen (though it is very very unlikely) that by the time the feature reaches out to the data service to get the data it changes. Actually for this reason (but I know we discussed this several times :)) I would allow both approaches (especially since the code complexity for this and option 2 would be identical, the only difference that we send data along with the event not just the plain event). class RowingDataService {
#strokeState = "WaitingForDrive"
#workoutSessionData = {
sessionType: 'JustRow',
sessionStatus: "WaitingForStart",
intervalNumber: 0,
intervalMovingTime: 0,
intervalLinearDistance: 0,
}
#strokeCycleMetrics = {
driveLastStartTime: 0, //seconds
driveDuration: 0, // seconds
driveLength: 0, // meters
driveDistance: 0, // meters
driveAverageHandleForce: 0,
drivePeakHandleForce: 0,
driveHandleForceCurve: [0],
driveHandleVelocityCurve: [0],
driveHandlePowerCurve: [0],
recoveryDuration: 0, // seconds
strokeWork: 0, // Joules
strokeCalories: 0, // kCal
cycleDuration: 0, // seconds
cycleDistance: 0, // meters
cycleLinearVelocity: 0, // m/s
cyclePower: 0, // watts
dragFactor: 0,
instantPower: 0,
}
#heartRateMetrics = {
heartRate: undefined,
heartRateBatteryLevel: undefined
}
#overallMetrics = {
totalMovingTime: 0,
totalNumberOfStrokes: 0,
totalLinearDistance: 0, // meters
totalCalories: 0, // kcal
}
#lastRotationImpulseDataPoint = 0;
onOverallMetricsChange$ = undefined // we will provide access to an Observables via these public properties to which clients can subscribe; this will be triggered by overallMetricsSubject
onStrokeCycleMetricsChange$ = undefined // we will provide access to an Observables via these public properties to which clients can subscribe; this will be triggered by updateStrokeCycleMetricsSubject
onWorkoutSessionDataChange$ = undefined // we will provide access to an Observables via these public properties to which clients can subscribe; this will be triggered by updateWorkoutSessionDataSubject
onHeartRateMetricsChange$ = undefined // we will provide access to an Observables via these public properties to which clients can subscribe; this will be triggered by heartRateMetricsSubject
onStateChange$ = undefined // we will provide access to an Observables via these public properties to which clients can subscribe; this will be triggered by strokeStateSubject
onNewRotationImpulse$ = undefined // we will provide access to an Observables via these public properties to which clients can subscribe; this will be triggered by impulseDataPointSubject
onAction$ = undefined // we will provide access to an Observables via these public properties to which clients can subscribe; this will be triggered by actionSubject
// Taking advantage of dependency injection, and injecting config via constructor
constructor(_config) {
this.#strokeCycleMetrics.dragFactor = _config.rowerSettings.dragFactor ? _config.rowerSettings.dragFactor : 0 // Dragfactor
}
dispatchAction(action) {
// we trigger notification
// actionSubject.next(action)
// new data will broadcasted via onAction$ that is an observable. any client that subscribed will be notified with the new data
}
addNewRotationImpulse(dataPoint) {
this.#lastRotationImpulseDataPoint = dataPoint
// we trigger notification
// impulseDataPointSubject.next(dataPoint)
// new data will broadcasted via onNewRotationImpulse$ that is an observable. any client that subscribed will be notified with the new data
}
updateStrokeState(state) {
this.#strokeState = state
// we trigger notification for the observable. This will notify any subscribed client that there is new data
// strokeStateSubject.next(this.#strokeState)
}
updateOverallMetrics(metrics) {
// with the application of the spread operator partial update is allowed, needs to be decided if this is necessary
this.#overallMetrics = { ...this.#overallMetrics, ...metrics }
// we trigger notification for the observable. This will notify any subscribed client that there is new data
// overallMetricsSubject.next(this.#overallMetrics)
}
updateStrokeCycleMetrics(metrics) {
// with the application of the spread operator partial update is allowed, needs to be decided if this is necessary
this.#strokeCycleMetrics = { ...this.#strokeCycleMetrics, ...metrics }
// we trigger notification
// updateStrokeCycleMetricsSubject.next(this.#strokeCycleMetrics)
}
updateWorkoutSessionData(sessionData) {
// with the application of the spread operator partial update is allowed, needs to be decided if this is necessary
this.#workoutSessionData = { ...this.#workoutSessionData, ...sessionData }
// we trigger notification
// updateWorkoutSessionDataSubject.next(this.#workoutSessionData)
}
updateHeartRateMetrics(heartRateData) {
// with the application of the spread operator partial update is allowed, needs to be decided if this is necessary
this.#heartRateMetrics = { ...this.#heartRateMetrics, ...heartRateData }
// we trigger notification
// heartRateMetricsSubject.next(this.#heartRateMetrics)
}
} |
Beta Was this translation helpful? Give feedback.
-
Looking at the above discussion, I think we let the solution lure us into a Big Design Upfront approach, and abstract discussions about this type of change ahead of implementation are impossible without a whiteboard anyways. As we are both intelligent people with a great deal of experience in building systems, let's assume we can approach this more agile and trust ourselves to able to pull a change through without changing everything everywhere in one huge undertaking. Let's focus on small steps that result in a working solution each time. What I see is that
What I propose is making small steps and grow to a better structure:
Each step should result in a working solution, but allows us to test this extensively before taking the next step, and find potential issues. By making these steps small, we don't have to make huge decisions now, but we can see what happens to the code and take it from there, making many discussions much less abstract. |
Beta Was this translation helpful? Give feedback.
-
Yes, I agree. And this was the main reason I included a separate event for the state change in the data store mock and also mentioned some time ago that having a separate event for the state change is necessary and time stamps will not cut it (as this would liberate the recipient from keeping "old" state and compering on every metrics update).
Im probably missing something here as an empty handler really is not an old pipe going :) its an empty handler. Or do you have some specific client that would go in there?
So does the GUI need HR data, so this would mean that the GUI would need access to the workoutrecorder to retrieve HR.
That would mean that workoutrecroder (as well as the GUI) would need access to the peripheral manager. Honestly I think this is very easily centralized. It can have a data store with an update method to receive the HR data from the peripherals and a getter for any feature that needs the data. Then this data store can be injected as service dependency into the parts that use HR data (i.e. workout recorder and GUI and peripheral manager for the update). This approach was proposed by you actually in one of the post. HR data is independent from the rowing machine and will be updated from the peripheral after the rower stopped so getting recovery heart rate should not be an issue.
But these can be implemented in a separate events (on the same eventemitter) and subscirbed by the clients separately. I.e. one event that emits on every currentDt change (i.e. metric change) and one event that emits on state change/transition. Then a client can decide if they want to subscribed either of these or both. I dont like catch all subsrciption since it could cause issues if a new event for some reason with a different data structure is implemented on the same eventemitter (event that would not be relevant for the subscriber of the catch all). There would need to be guard clauses against this in the feature so actually when extending a feature already working functionality can be broken.
But that means that you need provide access to the peripheral manager to parts of the app that depend on HR data. You are essentially giving access to features to parts of the app that has no right to have access to (e.g. workout recorder to switch peripheral mode, it could do it since peripheral has this method public, it has to have it as that is main task, manage the modes). So the question is why should peripheral store HR data, when indeed they could easily send updates to a central data service that handles HR (and then the peripheral manager does not need to be accessed by different parts of the app) but rather this data store can be accessed the same way as would the peripheral manager (but with only functionality available that is relevant for that part). The difference here is that peripheral manager is liberated from storing HR data (its not an HRmanager, its aim is to manage the peripheral, starting stopping changing cleaning up).
Now that I think as well that it is a good idea (I mean to have only two public commands on RowingStat). That would make rowingstat better injectable (safer). But there is a BUT.
Yes I agree, hence rowing stat is a good start, but then again externalCommand Handler could be a new service/module that has input from these two sources (peripheral and webserver) and one output as an event to which Rowingstat subscribes. Essentially achieving the same thing you propose but abstracting one step further (laying down the initial plumbing of potential future centralization of event handling). And eventually at some point it would be possible that non of the features would need rowingstat injected anymore only the central handler.
Now this will be one of the challanges with having to integrate into rowingstat. That communication will go both ways (i.e. when peripheral is updated it should signal back that it was successfull so the GUI can update the view, or not if failed). So in rowingstat there would need to be a differentiation made on the direction of the command. Like an event that fires when changed peripheral mode successfully. While I understand the little steps approach this is once again a new responsibility that will make (in my view) rowerstat have to do more than it should. |
Beta Was this translation helpful? Give feedback.
-
That is a valid point, in this sense the "native" EventEmitter is indeed limited (i.e. would not be able to help on the event side). This would need to be handled, either the emitter side: we dont emit currentDt change on state change but that would create a hard requirement from the subscribers to subscribe to both, so this is a crappy solution. Or we handle on the recipient side depending on the feature's requirements which seems much better solution, here what needs to be avoided is that there a race condition arises (i.e. that it cannot be guaranteed that state change first then new currentDt event is received). I never played with this on the EventEmitter so I dont know if this can be sorted.
I have never used EventEmitter2 but it would be interesting how the issue of duoble events can be solved. The reason is that I am not sure if the order of the event on the recipient side can be guaranteed (i.e. that following a state change event always a currentDt event is followed but with identical data). I would need to give more thought to this, but one solution could be to signal the state change via the event payload. Meaning that only the new currentDt event is emmited (i.e. emmit on every new currentDt since state change is essentially triggered by specific new currentDt) and have a property with a boolean of
well what I meant by new message is not an "instead" situation, but rather a new situation. Lets say we want to add a fan that speed is triggered by the effort you put in and we need to monitor its rotation speed, so a new event with the
What I was trying to explain that I would create a command manager (or router if you will) instead delegating the commandhandling to rowingstat (similarly to the HR data service maanger I provide more info below). But dont worry about this for now (disregard my comment), as I think starting the refactoring with the HR part (please see further details below) is the best way, and the command handling would come at a later stage. So we can come back to the actions later
In general I would start with the restructuring of the HR data management. As you described, this is a well defined and reasonably simple flow. Here I would go all in, in terms of creating a separate HR data manager service (that receives HR data from peripheral manager via an Actually half of the approach is already used in the current implementation, in server.js, when updating the hr data in rowingstat: if (config.heartrateMonitorBLE) {
const bleCentralService = child_process.fork('./app/ble/CentralService.js')
bleCentralService.on('message', (heartrateMeasurement) => {
rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement)
})
}
if (config.heartrateMonitorANT) {
const antManager = createAntManager()
antManager.on('heartrateMeasurement', (heartrateMeasurement) => {
rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement)
})
} Instead of the above (i.e. call to NOTE: that in the current v1beta the ant+ and ble hr is separately reporting hr as shown above, this means that until these two abstracted away into peripheralManager (as has been done on my extend-peripheral manager branch), it would be hard to remove server js. Actual the rework of the peripheral management is done on my side, only some minor clean up is needed, and rebase to your v1beta_updates branch, I can do it over the weekend and then it can be the basis for the HR rework. Then taking workoutRecorder as an example I show you what (actually very limited) changes needed to be: Current HR is added as part of the metrics received from the function recordStroke (stroke) {
if (startTime === undefined) {
startTime = new Date()
}
strokes.push({...stroke, heartrate: heartRateDataStoreService.getHeartRateData()})
} If Hr is 0 in the data store it will add zero if other than that other value. The rest of the workoutrecorder can be left unchanged (though I have not tested this, but since the Of course the above to work workoutRecorder (as well as peripheralManager in my extened-peripherals branch or in AntManager.js and createCentralManager.js in the current v1beta) would need heartRateDataStoreService as dependency. This can be injected via a function argument (or constructor argument) when server.js sets it up: const heartRateDataStoreService = heartRateDataStoreService() // or can be = new heartRateDataStoreService()
const peripheralManager = createPeripheralManager(heartRateDataStoreService)
const workoutRecorder = createWorkoutRecorder(heartRateDataStoreService) Now we have the mediator datastore service in between the two features without server.js having to know anything about actual heartrates (apart from initilizing the service dependency tree). What needs to be handled is the disconnect or if no HR data is received by peripheral manager for a while (i.e. to avoid the GUI stuck on showing a value that is invalid and old). But this is already done by the RowingStatistics (with a 6sec interval, so this can be moved the peripheralManager so it sets the HR to 0 on the data store service if HR peripheral is enabled but no data has been received): // set the heart rate to zero if we did not receive a value for some time
if (heartrateResetTimer)clearInterval(heartrateResetTimer)
heartrateResetTimer = setTimeout(() => {
heartRateDataStoreService.updateHeartRateData({
heartrate: 0,
heartrateBatteryLevel: 0
})
}, 6000) Actually I would happy to do the refactor of the HR data management should you agree with the above approach. Especially since its real power would depend on the reworked and centralized peripheralManager. |
Beta Was this translation helpful? Give feedback.
-
@Abasz: I've been working on the modified architecture. Essentially, I combined the two "case....switch" commands of server.js into a single list, and moved this single list to the RowingStatistics and WorkoutRecorder as a single "HandleCommand" function, which will respond to the command for their area. I like the code a lot more, as it cleans up code quite nicely. In Server.js, the case statements could be removed, and be replaced by the various components being told to handle a command, regardless of the type of command (the commandhandler of the recipient will sort out if and how it will respond to a specific command). Especially in the RowingStatistics, you get a lot more symmetry as the multiple exit points are gone now. I also followed your lead in the revised recorder section. I introduced a recordingManager on top of the three individual recorders, which controls all actions of the three recorders. It makes the recorder code a lot easier to read. On hindsight, @laberning already said on top of the file "Todo: split this into multiple modules". I guess that is done :) I also moved the RecoveryHR to the WorkoutRecorder by directly pushing HR data to the recorder, removing that from the RowingStatistics. Only real issue is the Strava uploader: it does GUI stuff, and low level commands on the tcxRecorder. I haven't found an easy way to split this one up. I've added an additional branch (see https://github.com/JaapvanEkris/openrowingmonitor/tree/V1Beta_Architecture_Revision) to post the current status, and I'll be posting my current status tomorrow. There still is some work to be done, but perhaps you could apply a same pattern (especially the HandleCommand() thing, which could eliminate the case-statements from server.js and provide the underlying components with single command entry points). |
Beta Was this translation helpful? Give feedback.
-
I will need more time with this to review. But one thing I noticed is that with the non-realtime simulation the raw data file does not get written properly. I suppose the issue may relate to the immediate file write (but I ahve not looked at any code, I am just testing the total number of stroke issue and noticed this). In real time everything works fine. |
Beta Was this translation helpful? Give feedback.
-
I will have to think through how efficient code review can be done here :) there are a lot of changes to certain parts. I think I will start with the recorder at some point. |
Beta Was this translation helpful? Give feedback.
-
Jaap, I have been looking at the recorders and it occured to me that I dont fully understand the "business needs" for all of the recorders e.g. the raw and rowingdata recorder does saving/creating file. Then further down you are implementing a guard that do not create if it has already been created ( My understanding of the flow of the rowing statuses is that there is a set of states that happen (e.g. before stop we always have pause, when we hit reset we will have pause (and or stop). So I would try to find a common event when file is created as that would make life simpler in my view, but I am affraid that I dont fully understand the flow of state changes. Also I am slightly confused when is done what. In previous code I saw an instant writing of the raw and rowing data files but now it seems it is written to memory. So would you mind briefly summarize what are the specs of this feature? Also I noticed that you are doing some resetting in the reset event but on on stop and pause. But for instance when one stops, the rowingdata is written to file. But since the memory is not resetting when one restarts then stops again, the full memory is rewritten in its entirety to the file (effectively duplicating stuff in the csv). I appreciate your help. |
Beta Was this translation helpful? Give feedback.
-
on a different note: in the docs you mention this: https://github.com/JaapvanEkris/openrowingmonitor/blob/b7c72213d6c2d462a2e08d6d9f360678b684d3a9/docs/Architecture.md?plain=1#L209 I am not sure I can fully follow this explanation. You are injecting config as a dependency into the features. which I think is a very good approach. If you do not want all parts of the application to see changes instead of mutating the object you can create a deep copy (I generally use the If the intent is to have the config object behave as a service (or rather a store of state of the config) I dont see much of a regret or a downside that cannot be cured as there are only two major problems with this specific implementation in my view: when you inject a pure data object instead of a service class, it makes every consumer a "god" enabling them to do what ever they want. They can remove, add properties, they can act without control. The above while not ideal is less of a problem then the other issue that is since this is a pure object you cannot indicate/detect changes in the consumers of this dependency. This means that when a consumer changes a property, specific code (a function) is needed in every consumer that either queries the config object or the consumer changing the property needs a hacky way to tell others that hey, I changed something (which would be server.js as the top level function). The solution is extremly simple and both can be sorted with one change: instead of injecting the pure config data object a singleton config manager service (with getter and setter functions) should be injected as dependency with helper functions to allow tapping into changes. This would enable direct communication between components without the need for server.js routing. Of course the request for changing a config property will still need to flow through server.js as that is a command but then there will be no need for every component to implement an indication function when it makes changes to the config in my view effectively simplifying the flow So when peripheralManager changes the ble mode, it calls the configManager's Merging this with the HTTP request response pattern, when the user clicks change, we make a HTTP request and indicate the request for changing the BLE mode via the appropriate method (this is already done), then create a promise that resolves when configManager's Actually once I understand your intent slightly better I can draw up some code to show this, I think that would make it probably easier to digest. |
Beta Was this translation helpful? Give feedback.
-
I will draw up some code next week to show an example |
Beta Was this translation helpful? Give feedback.
-
I cleaned up the code, as we discussed: https://github.com/JaapvanEkris/openrowingmonitor/tree/V1Beta_Architecture_Revision I moved the control over when to broadcast what to the peripherals. Even the PM5 works now. Only ones not tested are BLE CSC and CPS. Some things to do:
|
Beta Was this translation helpful? Give feedback.
-
I think HR is not in rowingstatistics but rather in SessionManager. I started looking at the structure, and all in all I think this is a good setup. SessionManager is essentially became the central data store. Though I could find ways to split it up a bit (like separating the general data function and the actual session management, split monitoring etc.) as it still does too many things but things are already cleaner, I think. In the sense I see a pattern where I would not actually move HR out but rather have every other data consumers depend on it (sessionManager). I have not made my mind up whether this is better structure as in this case an additional broadcast would be necessary (i.e. breaking the one data per impulse pattern) on every new HR data and probably indicate via metric context that this is a new HR data (but then probably two new context would ne necessary like, In addition now that we broadcast on every impulse, actually this is redundant: function handleRotationImpulse (dataPoint) {
recordingManager.recordRotationImpulse(dataPoint)
sessionManager.handleRotationImpulse(dataPoint)
} Here In terms of todo I would like to do the below things (you did not leave that much :)):
Do you have anything other in mind? |
Beta Was this translation helpful? Give feedback.
-
@Abasz I have introduced the equipmentstate transitions into FEPeripheral.js based on this description of its behaviour. I see some pages seem to be missing, and we might need to make the lap bit an individual bit. Any thoughts on this one? |
Beta Was this translation helpful? Give feedback.
-
I pushed the updated code to my fork https://github.com/Abasz/openrowingmonitor/tree/V1Beta-Architecture_Revision If you are ok with the changes I will create a pull request to the V1Beta-Architecture_Revision branch. Please note that I went with the isRecoveryStart option as it was very annoying in practice that data on the watch was so much behind (it lags by default any way and having a full cycle delay made things even worse). |
Beta Was this translation helpful? Give feedback.
-
@JaapvanEkris I moved this to a new discussion to keep the other separated as this is a completely different topic
I have not noticed the top comment but now I read it, its a good assessment :):)
What you are saying sounds like an eventbus, but I haven't given much thought to this honestly just felt when I edited the file for the graceful shutdown. I like to use a main method as an entiry point and use that to wire up things (making it responsible only for wiring up dependencies for services and inject these services into the classes). But this is probably due to the fact that I originaly came from C# and dotnet core and used angular on the front end only.
For me an app at this scale is hard to oversee with its event based approach (not to mention the error prone nature of the string event names :)) but refactoring this app away from events to dependency injection and direct method calls in these singleton services for instance would be a pian (if even possible).
Altough I need to think about this more, but I think we have 3 mjour parts:
Based on the above, I beleive that the server.js should include (esentially from a public API perspective these are very similar in nature) only direct communiction between these 3 parts on a high level like: control event was fired and this is relayed to the other two so it can decide what to do with it, but the logic what sholud be done should be within the feature part.
I would add a layer on top, the main method (we can call it main.js :)) that is reponsible for wiring up things (starting these parts of the app, manage their and the app liftime etc.)
But this is just a quick thought, I would need to draw a chart to see what parts we have and what communication we have between each other.
There are simple things that can be done like you have peripheralManger:
So here we fire a control event then we bubble it up to server just to carry out an action on the periopheralManager (notifyStatus). This is unneccessary (I could have refactord this actually but I did not). or the event.res= true: not sure what that is but I left it in because did not want to break something :)
Summary (sorry the above became too long):
EDIT:
+1
It would be possible to build a central eventbus that holds/aggregates all the data and keeps state and notifies subscribers on changes of this state/data. So instead of having several different event types only a hand full would be remain (just out of my head I would can see about 3, maybe 4: dataChanged, stateChanged, controlAction, and maybe UI event can be separated out but I need to think this through) and clients could subscribe to one of these and receive notification on change and act appropriately. The main eventbus could be injected into every part a singleton and can call update method related to the events.
Beta Was this translation helpful? Give feedback.
All reactions