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
{
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)
{
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 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">
<div class="container">
&copy; 2024 - TrafficLightsSignalR - <a asp-area="" asp-page="/Privacy">Privacy</a>
&copy; 2024 - TrafficLightsSignalR
</div>
</footer>

View File

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

View File

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

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
{
// string objects are immutable
protected TrafficLightState _state;
private readonly IHubContext<LightHub, ILightHub> _hubContext;
protected TrafficLightState? _state;
public TrafficLightStateService()
public TrafficLightStateService(IHubContext<LightHub, ILightHub> hubContext)
{
_state = new TrafficLightState(
new Schema.Period(),
new Schema.State(),
-1,
0.0,
-1,
"",
0.0,
-1,
-1);
_hubContext = hubContext;
}
public void SetCurrentTrafficLightState(TrafficLightState currentTrafficLightState)
{
_state = currentTrafficLightState;
_hubContext.Clients.All.ReceiveStateUpdate(_state);
}
public TrafficLightState GetCurrentTrafficLightState()
public TrafficLightState? GetCurrentTrafficLightState()
{
return _state;
}
}
}

View File

@ -6,7 +6,7 @@ using TrafficLightsSignalR.Schema;
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
const long TICKS_PER_SECOND = 10000000;
@ -15,7 +15,6 @@ namespace TrafficLightsSignalR
// Interconnects
private readonly ILogger<TrafficLightsHostedService> _logger = logger;
private readonly IHubContext<LightHub> _hubContext = hubContext;
private readonly ITrafficLightStateService _stateService = stateService;
private readonly IConfiguration _configuration = configuration;
@ -29,7 +28,26 @@ namespace TrafficLightsSignalR
private void UpdateState(TrafficLightState 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))]
@ -46,22 +64,19 @@ namespace TrafficLightsSignalR
// 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 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)
{
throw new InvalidOperationException($"JSON deserialisation returned no data available in given configuration file: {periodDataFilename}");
}
periodDataStream.Close();
// Parse file for convenience
foreach (Period period in _periodData)
{
timeParseSuccess = TimeOnly.TryParse(period.TimeStart, out period.TimeStartParsed);
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}");
_logger.LogInformation($"Period loaded - name: '{period.Name}', timestart: '{period.TimeStart}', parsed timestart: {period.TimeStart}, parse success: {timeParseSuccess}");
foreach (State state in period.States)
{
@ -82,7 +97,7 @@ namespace TrafficLightsSignalR
{
return index;
}
if (testTime >= _periodData[index].TimeStartParsed && testTime < _periodData[index + 1].TimeStartParsed)
if (testTime >= _periodData[index].TimeStart && testTime < _periodData[index + 1].TimeStart)
{
return index;
}
@ -110,10 +125,10 @@ namespace TrafficLightsSignalR
_logger.LogInformation($"Next changeover second(s): {TimeSpan.FromTicks(nextChangeoverTicks - startTicks)}");
_logger.LogInformation($"Start simulation DT: {startSimulationDateTime}");
UpdateState(CreateStateRecord(_periodData[currentPeriodIndex], _periodData[currentPeriodIndex].States[currentStateIndex], currentStateIndex, nextChangeoverTicks, startSimulationDateTime, startSimulationDateTime));
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;
long nowTicks = now.Ticks;
@ -139,17 +154,7 @@ namespace TrafficLightsSignalR
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)");
var currentState = new TrafficLightState(
_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);
UpdateState(CreateStateRecord(_periodData[currentPeriodIndex], _periodData[currentPeriodIndex].States[currentStateIndex], currentStateIndex, nextChangeoverTicks, startSimulationDateTime, currentSimulationDateTime));
}
await Task.Delay((int)((1000 / UPDATES_PER_SIMULATED_SECOND) / _timeFactor), cancellationToken);

View File

@ -3,21 +3,6 @@ using TrafficLightsSignalR.Schema;
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(
Period CurrentPeriod,
State CurrentState,

View File

@ -8,16 +8,21 @@ var nextUpdateTime = null;
var connection = new signalR.HubConnectionBuilder().withUrl("/lightHub").build();
connection.on("ReceiveStateUpdate", function (stateData) {
console.log("Update pushed.");
console.log("Received pushed update.");
updateView(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);
});
connection.start().then(function () {
console.log("Connection started, requesting server state...");
connection.invoke("GetServerState", connection.connectionId).catch(function (err) {
return console.error(err.toString());
});
@ -25,23 +30,6 @@ connection.start().then(function () {
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) {
return new Promise(resolve => setTimeout(resolve, ms))
}