refactor: multiple

refactor: TimeOnly JSON converter added - regularizes record types
refactor: Strongly typed SignalR hub - removes client method name strings
chore: Redundant file cleanup
This commit is contained in:
Chris Davoren 2024-01-12 13:14:16 +10:00
parent c81f5e8641
commit 311355aca4
13 changed files with 111 additions and 124 deletions

View File

@ -0,0 +1,8 @@
namespace TrafficLightsSignalR.Hubs
{
public interface ILightHub
{
Task ReceiveStateUpdate(TrafficLightState state);
Task ReceiveServerState(TrafficLightState? state);
}
}

View File

@ -4,13 +4,22 @@ using TrafficLightsSignalR.Schema;
namespace TrafficLightsSignalR.Hubs namespace TrafficLightsSignalR.Hubs
{ {
public class LightHub(ITrafficLightStateService stateService) : Hub public class LightHub : Hub<ILightHub>
{ {
private readonly ITrafficLightStateService _stateService = stateService; private readonly ITrafficLightStateService _stateService;
private readonly ILogger<LightHub> _logger;
public LightHub(ITrafficLightStateService stateService, ILogger<LightHub> logger)
{
_stateService = stateService;
_logger = logger;
}
public async Task GetServerState(string connectionId) public async Task GetServerState(string connectionId)
{ {
await Clients.Client(connectionId).SendAsync("ReceiveServerState", _stateService.GetCurrentTrafficLightState()); var currentState = _stateService.GetCurrentTrafficLightState();
_logger.LogInformation($"Request for server state received, current state null?: ${currentState is null}");
await Clients.Client(connectionId).ReceiveServerState(currentState);
} }
} }
} }

View File

@ -3,6 +3,6 @@
public interface ITrafficLightStateService public interface ITrafficLightStateService
{ {
public void SetCurrentTrafficLightState(TrafficLightState currentTrafficLightState); public void SetCurrentTrafficLightState(TrafficLightState currentTrafficLightState);
public TrafficLightState GetCurrentTrafficLightState(); public TrafficLightState? GetCurrentTrafficLightState();
} }
} }

View File

@ -1,8 +0,0 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View File

@ -1,20 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace TrafficLightsSignalR.Pages
{
public class PrivacyModel : PageModel
{
private readonly ILogger<PrivacyModel> _logger;
public PrivacyModel(ILogger<PrivacyModel> logger)
{
_logger = logger;
}
public void OnGet()
{
}
}
}

View File

@ -38,7 +38,7 @@
<footer class="border-top footer text-muted"> <footer class="border-top footer text-muted">
<div class="container"> <div class="container">
&copy; 2024 - TrafficLightsSignalR - <a asp-area="" asp-page="/Privacy">Privacy</a> &copy; 2024 - TrafficLightsSignalR
</div> </div>
</footer> </footer>

View File

@ -3,12 +3,9 @@ using System.Text.Json.Serialization;
namespace TrafficLightsSignalR.Schema namespace TrafficLightsSignalR.Schema
{ {
public class Period public record Period(
{ string Name,
public string Name { get; set; } = ""; string VerboseName,
public string VerboseName { get; set; } = ""; TimeOnly TimeStart,
public string TimeStart { get; set; } = ""; List<State> States);
public TimeOnly TimeStartParsed;
public List<State> States { get; set; } = [];
}
} }

View File

@ -1,12 +1,10 @@
 namespace TrafficLightsSignalR.Schema  namespace TrafficLightsSignalR.Schema
{ {
public record State public record State(
{ int Duration,
public int Duration { get; set; } = -1; string North,
public string North { get; set; } = ""; string South,
public string South { get; set; } = ""; string East,
public string East { get; set; } = ""; string West,
public string West { get; set; } = ""; string NorthRight);
public string NorthRight { get; set; } = "";
}
} }

View File

@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
using System.Text.Json;
namespace TrafficLightsSignalR.Schema
{
public class TimeOnlyConverter : JsonConverter<TimeOnly>
{
private const string TimeFormat = "HH:mm";
public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string timeStr = reader.GetString() ?? throw new JsonException("Invalid time format - null string received.");
if (TimeOnly.TryParseExact(timeStr, TimeFormat, null, System.Globalization.DateTimeStyles.None, out TimeOnly time))
{
return time;
}
throw new JsonException("Invalid time format. Expected format: HH:mm");
}
public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString(TimeFormat));
}
}
}

View File

@ -1,32 +1,29 @@
namespace TrafficLightsSignalR using Microsoft.AspNetCore.SignalR;
using TrafficLightsSignalR.Hubs;
using TrafficLightsSignalR.Schema;
namespace TrafficLightsSignalR
{ {
public class TrafficLightStateService : ITrafficLightStateService public class TrafficLightStateService : ITrafficLightStateService
{ {
// string objects are immutable private readonly IHubContext<LightHub, ILightHub> _hubContext;
protected TrafficLightState _state; protected TrafficLightState? _state;
public TrafficLightStateService() public TrafficLightStateService(IHubContext<LightHub, ILightHub> hubContext)
{ {
_state = new TrafficLightState( _hubContext = hubContext;
new Schema.Period(),
new Schema.State(),
-1,
0.0,
-1,
"",
0.0,
-1,
-1);
} }
public void SetCurrentTrafficLightState(TrafficLightState currentTrafficLightState) public void SetCurrentTrafficLightState(TrafficLightState currentTrafficLightState)
{ {
_state = currentTrafficLightState; _state = currentTrafficLightState;
_hubContext.Clients.All.ReceiveStateUpdate(_state);
} }
public TrafficLightState GetCurrentTrafficLightState() public TrafficLightState? GetCurrentTrafficLightState()
{ {
return _state; return _state;
} }
} }
} }

View File

@ -6,7 +6,7 @@ using TrafficLightsSignalR.Schema;
namespace TrafficLightsSignalR namespace TrafficLightsSignalR
{ {
public class TrafficLightsHostedService(IHubContext<LightHub> hubContext, ITrafficLightStateService stateService, IConfiguration configuration, ILogger<TrafficLightsHostedService> logger) : BackgroundService public class TrafficLightsHostedService(ITrafficLightStateService stateService, IConfiguration configuration, ILogger<TrafficLightsHostedService> logger) : BackgroundService
{ {
// Simulation constants // Simulation constants
const long TICKS_PER_SECOND = 10000000; const long TICKS_PER_SECOND = 10000000;
@ -15,7 +15,6 @@ namespace TrafficLightsSignalR
// Interconnects // Interconnects
private readonly ILogger<TrafficLightsHostedService> _logger = logger; private readonly ILogger<TrafficLightsHostedService> _logger = logger;
private readonly IHubContext<LightHub> _hubContext = hubContext;
private readonly ITrafficLightStateService _stateService = stateService; private readonly ITrafficLightStateService _stateService = stateService;
private readonly IConfiguration _configuration = configuration; private readonly IConfiguration _configuration = configuration;
@ -29,7 +28,26 @@ namespace TrafficLightsSignalR
private void UpdateState(TrafficLightState state) private void UpdateState(TrafficLightState state)
{ {
_stateService.SetCurrentTrafficLightState(state); _stateService.SetCurrentTrafficLightState(state);
_ = _hubContext.Clients.All.SendAsync("ReceiveStateUpdate", state); }
private TrafficLightState CreateStateRecord(Period currentPeriod, State currentState, int currentStateIndex, long nextChangeoverTicks, DateTime startSimulationDateTime, DateTime currentSimulationDateTime)
{
return new TrafficLightState(
currentPeriod,
currentState,
currentStateIndex,
// Seconds until next changeover
(nextChangeoverTicks - DateTime.UtcNow.Ticks) / (double)TICKS_PER_SECOND,
// Next changeover timestamp in UNIX time, millisecond granularity
(nextChangeoverTicks - EPOCH_TICKS) / (TICKS_PER_SECOND / 1000), // Division by 1000 -> needs to be in ms
// Current simulation time string, formatted for display
TimeOnly.FromDateTime(currentSimulationDateTime).ToString(),
// Current time factor
_timeFactor,
// Current simulated timestamp in UNIX time, millisecond granularity
(currentSimulationDateTime.Ticks - EPOCH_TICKS) / (TICKS_PER_SECOND / 1000), // Division by 1000 -> needs to be in ms
// Start timestamp (simulation time) in UNIX time, millisecond granularity
(startSimulationDateTime.Ticks - EPOCH_TICKS) / (TICKS_PER_SECOND / 1000)); // Division by 1000 -> needs to be in ms
} }
[MemberNotNull(nameof(_periodData))] [MemberNotNull(nameof(_periodData))]
@ -46,22 +64,19 @@ namespace TrafficLightsSignalR
// Load and deserialised JSON data from file // Load and deserialised JSON data from file
var periodDataFilename = _configuration.GetValue<string>("PeriodConfigurationFile") ?? throw new InvalidOperationException("Unable to find period data filename (PeriodConfigurationFile) in configuration -> please check application settings"); var periodDataFilename = _configuration.GetValue<string>("PeriodConfigurationFile") ?? throw new InvalidOperationException("Unable to find period data filename (PeriodConfigurationFile) in configuration -> please check application settings");
var periodDataStream = File.OpenRead(periodDataFilename); var periodDataStream = File.OpenRead(periodDataFilename);
_periodData = JsonSerializer.Deserialize<List<Period>>(periodDataStream); // may throw JsonException
var serializerOptions = new JsonSerializerOptions { Converters = { new TimeOnlyConverter() } };
_periodData = JsonSerializer.Deserialize<List<Period>>(periodDataStream, serializerOptions); // may throw JsonException
if (_periodData is null) if (_periodData is null)
{ {
throw new InvalidOperationException($"JSON deserialisation returned no data available in given configuration file: {periodDataFilename}"); throw new InvalidOperationException($"JSON deserialisation returned no data available in given configuration file: {periodDataFilename}");
} }
periodDataStream.Close();
// Parse file for convenience // Parse file for convenience
foreach (Period period in _periodData) foreach (Period period in _periodData)
{ {
timeParseSuccess = TimeOnly.TryParse(period.TimeStart, out period.TimeStartParsed); _logger.LogInformation($"Period loaded - name: '{period.Name}', timestart: '{period.TimeStart}', parsed timestart: {period.TimeStart}, parse success: {timeParseSuccess}");
if (!timeParseSuccess)
{
throw new InvalidOperationException($"Unable to parse time start string in period data: ${period.TimeStart}");
}
_logger.LogInformation($"Period found with name: '{period.Name}', timestart: '{period.TimeStart}', parsed timestart: {period.TimeStartParsed}, parse success: {timeParseSuccess}");
foreach (State state in period.States) foreach (State state in period.States)
{ {
@ -82,7 +97,7 @@ namespace TrafficLightsSignalR
{ {
return index; return index;
} }
if (testTime >= _periodData[index].TimeStartParsed && testTime < _periodData[index + 1].TimeStartParsed) if (testTime >= _periodData[index].TimeStart && testTime < _periodData[index + 1].TimeStart)
{ {
return index; return index;
} }
@ -110,10 +125,10 @@ namespace TrafficLightsSignalR
_logger.LogInformation($"Next changeover second(s): {TimeSpan.FromTicks(nextChangeoverTicks - startTicks)}"); _logger.LogInformation($"Next changeover second(s): {TimeSpan.FromTicks(nextChangeoverTicks - startTicks)}");
_logger.LogInformation($"Start simulation DT: {startSimulationDateTime}"); _logger.LogInformation($"Start simulation DT: {startSimulationDateTime}");
UpdateState(CreateStateRecord(_periodData[currentPeriodIndex], _periodData[currentPeriodIndex].States[currentStateIndex], currentStateIndex, nextChangeoverTicks, startSimulationDateTime, startSimulationDateTime));
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
// _logger.LogInformation("Working behind the scenes...");
// _stateService.SetCurrentTrafficLightState(string.Format("Thoughts about life, the universe, and everything! [ {0} ]", DateTime.Now));
now = DateTime.UtcNow; now = DateTime.UtcNow;
long nowTicks = now.Ticks; long nowTicks = now.Ticks;
@ -139,17 +154,7 @@ namespace TrafficLightsSignalR
nextChangeoverTicks = nowTicks + (long)((_periodData[currentPeriodIndex].States[currentStateIndex].Duration * TICKS_PER_SECOND) / _timeFactor); nextChangeoverTicks = nowTicks + (long)((_periodData[currentPeriodIndex].States[currentStateIndex].Duration * TICKS_PER_SECOND) / _timeFactor);
_logger.LogInformation($" Next changeover in {_periodData[currentPeriodIndex].States[currentStateIndex].Duration} simulation second(s)"); _logger.LogInformation($" Next changeover in {_periodData[currentPeriodIndex].States[currentStateIndex].Duration} simulation second(s)");
var currentState = new TrafficLightState( UpdateState(CreateStateRecord(_periodData[currentPeriodIndex], _periodData[currentPeriodIndex].States[currentStateIndex], currentStateIndex, nextChangeoverTicks, startSimulationDateTime, currentSimulationDateTime));
_periodData[currentPeriodIndex],
_periodData[currentPeriodIndex].States[currentStateIndex],
currentStateIndex,
(nextChangeoverTicks - DateTime.UtcNow.Ticks) / (double)TICKS_PER_SECOND,
(nextChangeoverTicks - EPOCH_TICKS) / (TICKS_PER_SECOND / 1000), // Division by 1000 -> needs to be in ms
TimeOnly.FromDateTime(currentSimulationDateTime).ToString(),
_timeFactor,
(currentSimulationDateTime.Ticks - EPOCH_TICKS) / (TICKS_PER_SECOND / 1000), // Division by 1000 -> needs to be in ms
(startSimulationDateTime.Ticks - EPOCH_TICKS) / (TICKS_PER_SECOND / 1000)); // Division by 1000 -> needs to be in ms
UpdateState(currentState);
} }
await Task.Delay((int)((1000 / UPDATES_PER_SIMULATED_SECOND) / _timeFactor), cancellationToken); await Task.Delay((int)((1000 / UPDATES_PER_SIMULATED_SECOND) / _timeFactor), cancellationToken);

View File

@ -3,21 +3,6 @@ using TrafficLightsSignalR.Schema;
namespace TrafficLightsSignalR namespace TrafficLightsSignalR
{ {
/*
public record TrafficLightsState
{
public Period CurrentPeriod { get; init; }
public State CurrentState { get; init; }
public int CurrentStateIndex { get; init; }
public double SecondsUntilChangeover { get; init; }
public long NextChangeoverTimestampMs { get; init; }
public string CurrentSimulationTime { get; init; } // UTC
public double TimeFactor { get; init; }
public long CurrentSimulationTimestampMs { get; init; } // UNIX epoch timestamp (UTC in milliseconds)
public long StartSimulationTimestampMs { get; init; } // UNIX epoch timestamp (UTC in milliseconds)
}
*/
public record TrafficLightState( public record TrafficLightState(
Period CurrentPeriod, Period CurrentPeriod,
State CurrentState, State CurrentState,

View File

@ -8,16 +8,21 @@ var nextUpdateTime = null;
var connection = new signalR.HubConnectionBuilder().withUrl("/lightHub").build(); var connection = new signalR.HubConnectionBuilder().withUrl("/lightHub").build();
connection.on("ReceiveStateUpdate", function (stateData) { connection.on("ReceiveStateUpdate", function (stateData) {
console.log("Update pushed."); console.log("Received pushed update.");
updateView(stateData); updateView(stateData);
}); });
connection.on("ReceiveServerState", function (stateData) { connection.on("ReceiveServerState", function (stateData) {
console.log("Recevied requested server state...") console.log("Recevied requested server state...");
if (stateData == null) {
console.log("State not yet initialized, not updating.");
return;
}
updateView(stateData); updateView(stateData);
}); });
connection.start().then(function () { connection.start().then(function () {
console.log("Connection started, requesting server state...");
connection.invoke("GetServerState", connection.connectionId).catch(function (err) { connection.invoke("GetServerState", connection.connectionId).catch(function (err) {
return console.error(err.toString()); return console.error(err.toString());
}); });
@ -25,23 +30,6 @@ connection.start().then(function () {
return console.error(err.toString()); return console.error(err.toString());
}); });
document.getElementById("sendButton").addEventListener("click", function (event) {
var user = document.getElementById("userInput").value;
var message = document.getElementById("messageInput").value;
connection.invoke("SendMessage", user, message).catch(function (err) {
return console.error(err.toString());
});
event.preventDefault();
});
document.getElementById("updateButton").addEventListener("click", function (event) {
var connectionId = connection.connectionId;
connection.invoke("GetServerState", connectionId).catch(function (err) {
return console.error(err.toString());
});
event.preventDefault();
});
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)) return new Promise(resolve => setTimeout(resolve, ms))
} }