From cb24a190253f9d43dec2c5b205d540b4b7ce4f1a Mon Sep 17 00:00:00 2001 From: Patrick Grawehr Date: Tue, 25 Jan 2022 00:17:04 +0100 Subject: [PATCH] Add class that supports OS-independent setting of the system clock (#1619) Co-authored-by: Jose Perez Rodriguez --- src/devices/Interop/Windows/Kernel32.cs | 30 +++ src/devices/Rtc/README.md | 14 +- src/devices/Rtc/Rtc.csproj | 2 + src/devices/Rtc/Rtc.sln | 34 ++- src/devices/Rtc/RtcBase.cs | 46 +++- src/devices/Rtc/SystemClock.cs | 261 ++++++++++++++++++++++ src/devices/Rtc/samples/Program.cs | 22 +- src/devices/Rtc/tests/Rtc.Tests.csproj | 15 ++ src/devices/Rtc/tests/SystemClockTests.cs | 65 ++++++ 9 files changed, 477 insertions(+), 12 deletions(-) create mode 100644 src/devices/Interop/Windows/Kernel32.cs create mode 100644 src/devices/Rtc/SystemClock.cs create mode 100644 src/devices/Rtc/tests/Rtc.Tests.csproj create mode 100644 src/devices/Rtc/tests/SystemClockTests.cs diff --git a/src/devices/Interop/Windows/Kernel32.cs b/src/devices/Interop/Windows/Kernel32.cs new file mode 100644 index 00000000..9d63e558 --- /dev/null +++ b/src/devices/Interop/Windows/Kernel32.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Runtime.InteropServices; + +internal partial class Interop +{ + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetSystemTime([In] ref SystemTime lpSystemTime); + + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetSystemTime([Out] out SystemTime lpSystemTime); + + [Serializable] + [StructLayout(LayoutKind.Sequential)] + public struct SystemTime + { + public ushort Year; + public ushort Month; + public ushort DayOfWeek; + public ushort Day; + public ushort Hour; + public ushort Minute; + public ushort Second; + public ushort Milliseconds; + } +} diff --git a/src/devices/Rtc/README.md b/src/devices/Rtc/README.md index a1d69b0d..a2c4dcc4 100644 --- a/src/devices/Rtc/README.md +++ b/src/devices/Rtc/README.md @@ -2,6 +2,9 @@ The RTC devices supported by the project include DS1307, DS3231, PCF8563. +A class for setting the system clock is also provided, so that for instance the Raspberry Pi's operating system time can be synchronized +to a hardware RTC clock. + ## Documentation - DS1307 [datasheet](https://cdn.datasheetspdf.com/pdf-down/D/S/1/DS1307-Maxim.pdf) @@ -12,7 +15,7 @@ The RTC devices supported by the project include DS1307, DS3231, PCF8563. ![Circuit DS1307](Circuit_DS1307_bb.png) -## Usage +## Usage with Hardware clocks ### Hardware Required @@ -46,3 +49,12 @@ using (Ds1307 rtc = new Ds1307(device)) ### Result ![Sample result](RunningResult.jpg) + +## Setting the operating system clock + +The class `SystemClock` contains static methods to set the operating system clock. Since this operation requires elevated permissions, +some special requirements apply unless the application is run as root (on Linux or MacOs)/administrator (on Windows). + +On linux or MacOs, the user calling the `SetSystemTimeUtc` must either be root or the `date` command must have the setUid bit set. To do this, one must execute this command once: `sudo chmod +s /bin/date`. This allows everyone to set the clock. + +On Windows, a system policy exists to allow anybody of a named user group to set the system clock. Normally, this right is limited to users belonging to the "Administrators" group. To configure it, open 'gpedit.msc' and go to Computer Configuration > Windows Settings > Security Settings > Local Policies > User Rights Assignments and add the user or his group to the setting 'Change System Time'. diff --git a/src/devices/Rtc/Rtc.csproj b/src/devices/Rtc/Rtc.csproj index f70d6979..91daa8d8 100644 --- a/src/devices/Rtc/Rtc.csproj +++ b/src/devices/Rtc/Rtc.csproj @@ -17,5 +17,7 @@ + + \ No newline at end of file diff --git a/src/devices/Rtc/Rtc.sln b/src/devices/Rtc/Rtc.sln index 0505ced1..67ee99a6 100644 --- a/src/devices/Rtc/Rtc.sln +++ b/src/devices/Rtc/Rtc.sln @@ -1,13 +1,17 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31424.327 MinimumVisualStudioVersion = 15.0.26124.0 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{B141143A-3C49-47EC-8ADA-F17F6C6A827F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rtc.Samples", "samples\Rtc.Samples.csproj", "{82FA815D-0081-43BA-8864-C7E971A6A93C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rtc.Samples", "samples\Rtc.Samples.csproj", "{82FA815D-0081-43BA-8864-C7E971A6A93C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rtc", "Rtc.csproj", "{31E8FF38-E4AF-4F1E-9806-487548021F0D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rtc", "Rtc.csproj", "{31E8FF38-E4AF-4F1E-9806-487548021F0D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D05C3402-0D61-458E-8925-0D6EAB494E29}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rtc.Tests", "tests\Rtc.Tests.csproj", "{9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -18,9 +22,6 @@ Global Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {82FA815D-0081-43BA-8864-C7E971A6A93C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {82FA815D-0081-43BA-8864-C7E971A6A93C}.Debug|Any CPU.Build.0 = Debug|Any CPU @@ -46,8 +47,27 @@ Global {31E8FF38-E4AF-4F1E-9806-487548021F0D}.Release|x64.Build.0 = Release|Any CPU {31E8FF38-E4AF-4F1E-9806-487548021F0D}.Release|x86.ActiveCfg = Release|Any CPU {31E8FF38-E4AF-4F1E-9806-487548021F0D}.Release|x86.Build.0 = Release|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Debug|x64.ActiveCfg = Debug|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Debug|x64.Build.0 = Debug|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Debug|x86.ActiveCfg = Debug|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Debug|x86.Build.0 = Debug|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Release|Any CPU.Build.0 = Release|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Release|x64.ActiveCfg = Release|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Release|x64.Build.0 = Release|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Release|x86.ActiveCfg = Release|Any CPU + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {82FA815D-0081-43BA-8864-C7E971A6A93C} = {B141143A-3C49-47EC-8ADA-F17F6C6A827F} + {9BFDB9B9-D35B-4960-86B9-96C02FBB7A24} = {D05C3402-0D61-458E-8925-0D6EAB494E29} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {3926A7E9-5885-4836-8360-231F95CA76F2} EndGlobalSection EndGlobal diff --git a/src/devices/Rtc/RtcBase.cs b/src/devices/Rtc/RtcBase.cs index 8d3722b6..ad2af903 100644 --- a/src/devices/Rtc/RtcBase.cs +++ b/src/devices/Rtc/RtcBase.cs @@ -10,15 +10,57 @@ namespace Iot.Device.Rtc /// public abstract class RtcBase : IDisposable { + private TimeZoneInfo _timeZone; + /// - /// The Device's + /// The Device's raw . + /// The caller must be aware of the device's time zone. + /// The behavior of the property is implementation-dependent (typically it is ignored) /// - public virtual DateTime DateTime + public virtual DateTime RtcDateTime { get => ReadTime(); set => SetTime(value); } + /// + /// Set or retrieves the current date/time. This property returns a and + /// is therefore correct regardless of the current time zone (when is set correctly). + /// + public DateTimeOffset DateTime + { + get + { + var now = RtcDateTime; + return new DateTimeOffset(now.Ticks, LocalTimeZone.GetUtcOffset(now)); + } + set + { + var clockNow = new DateTime((value.UtcDateTime + LocalTimeZone.GetUtcOffset(value)).Ticks, DateTimeKind.Local); + RtcDateTime = clockNow; + } + } + + /// + /// Gets or sets the time zone this instance will operate in. + /// Defaults to the local time zone from the system. + /// Changing this property will not change the time on the real time clock, + /// but instead affect the return value of + /// + public virtual TimeZoneInfo LocalTimeZone + { + get => _timeZone; + set => _timeZone = value; + } + + /// + /// Creates an instance of this base class + /// + protected RtcBase() + { + _timeZone = TimeZoneInfo.Local; + } + /// /// Set the device time /// diff --git a/src/devices/Rtc/SystemClock.cs b/src/devices/Rtc/SystemClock.cs new file mode 100644 index 00000000..408a1c11 --- /dev/null +++ b/src/devices/Rtc/SystemClock.cs @@ -0,0 +1,261 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Runtime.InteropServices; + +namespace Iot.Device.Rtc +{ + /// + /// Contains methods to access and update the system real time clock ("Bios clock") + /// + public class SystemClock : RtcBase + { + private static readonly DateTime UnixEpochStart = new DateTime(1970, 1, 1); + + /// + /// We always use UTC when reading/writing the system clock, makes things easier. + /// Technically, the BIOS RTC is configured in local time by default on Windows, but in UTC on Linux (causing + /// weird effects when dual-booting). Both systems allow changing this setting, though. + /// + public override TimeZoneInfo LocalTimeZone + { + get + { + return TimeZoneInfo.Utc; + } + set + { + if (!value.Equals(TimeZoneInfo.Utc)) + { + throw new NotSupportedException( + "The time zone configuration for the system clock cannot be changed"); + } + } + } + + /// + /// Set the system time to the given date/time. + /// The time must be given in utc. + /// The method requires elevated permissions. On Windows, the calling user must either be administrator or the right + /// "Change the system clock" must have been granted to the "Users" group (in Security policy management). + /// On Unix and MacOs, the current user must be root or the "date" command must have the setUid bit set. + /// + /// + /// This method is primarily intended for setting the system clock from an external clock source, such as a DS1307 or a GNSS source when no + /// internet connection is available. If an internet connection is available, most operating systems will by default automatically sync the + /// time to a network server, which might interfere with this operation. So when using this method, the clock synchronization should be disabled, + /// or it should only be done if the time difference is large. + /// + /// Date/time to set the system clock to. This must be in UTC + /// This method is not supported on this platform + /// There was an error executing a system command + /// The user does not have permissions to set the system clock + public static void SetSystemTimeUtc(DateTime dt) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + SetSystemTimeUtcWindows(dt); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + SetDateTimeUtcUnix(dt); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + SetDateTimeUtcMacOs(dt); + } + else + { + throw new PlatformNotSupportedException($"No implementation available for {Environment.OSVersion}"); + } + } + + /// + /// Gets the current system time directly using OS calls. + /// Normally, this should return the same as + /// + /// The current system time + public static DateTime GetSystemTimeUtc() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return GetSystemTimeUtcWindows(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return GetDateTimeUtcUnix(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return GetDateTimeUtcMacOs(); + } + else + { + throw new PlatformNotSupportedException($"No implementation available for {Environment.OSVersion}"); + } + } + + private static void SetSystemTimeUtcWindows(DateTime dt) + { + Interop.SystemTime st = DateTimeToSystemTime(dt); + try + { + if (!Interop.SetSystemTime(ref st)) + { + throw new IOException("SetSystemTime returned an unspecified error"); + } + } + catch (System.Security.SecurityException x) + { + // Let's be exhaustive here, because without google, it's next to impossible to find this setting. + throw new UnauthorizedAccessException("Permission denied for setting the clock. Either run this program with elevated permissions or make sure " + + "the current user has permission to change the clock. Open 'gpedit.msc' and go to Computer Configuration > Windows Settings > " + + "Security Settings > Local Policies > User Rights Assignments and add the user or his group to the setting " + + "'Change System Time'.", x); + } + } + + private static DateTime GetSystemTimeUtcWindows() + { + Interop.SystemTime st; + + DateTime dt; + try + { + if (!Interop.GetSystemTime(out st)) + { + throw new IOException("GetSystemTime returned an unspecified error"); + } + + dt = SystemTimeToDateTime(ref st); + } + catch (System.Security.SecurityException x) + { + throw new UnauthorizedAccessException("Permission denied for getting the clock", x); + } + + return dt; + } + + private static Interop.SystemTime DateTimeToSystemTime(DateTime dt) + { + Interop.SystemTime st; + + st.Year = (ushort)dt.Year; + st.Day = (ushort)dt.Day; + st.Month = (ushort)dt.Month; + st.Hour = (ushort)dt.Hour; + st.Minute = (ushort)dt.Minute; + st.Second = (ushort)dt.Second; + st.Milliseconds = (ushort)dt.Millisecond; + st.DayOfWeek = (ushort)dt.DayOfWeek; + + return st; + } + + private static DateTime SystemTimeToDateTime(ref Interop.SystemTime st) + { + return new DateTime(st.Year, st.Month, st.Day, st.Hour, st.Minute, st.Second, st.Milliseconds); + } + + private static DateTime GetDateTimeUtcUnix() + { + string date = RunDateCommandUnix("-u +%s.%N", out _); // Floating point seconds since epoch + if (Double.TryParse(date, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + DateTime dt = UnixEpochStart + TimeSpan.FromSeconds(result); + return dt; + } + + throw new IOException($"The return value of the date command could not be parsed: {date} (seconds since 01/01/1970)"); + } + + private static DateTime GetDateTimeUtcMacOs() + { + string date = RunDateCommandUnix("-u +%s", out _); // Floating point seconds since epoch + if (Double.TryParse(date, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + DateTime dt = UnixEpochStart + TimeSpan.FromSeconds(result); + return dt; + } + + throw new IOException($"The return value of the date command could not be parsed: {date} (seconds since 01/01/1970)"); + } + + private static void SetDateTimeUtcUnix(DateTime dt) + { + string formattedTime = dt.ToString("yyyy-MM-dd HH:mm:ss.fffff", CultureInfo.InvariantCulture); + int exitCode; + // Try to run the date command as user first (maybe it has the set-user-id bit set) otherwise, try root. + string output = RunDateCommandUnix($"-u -s \"{formattedTime}\"", out exitCode); + if (exitCode != 0) + { + throw new UnauthorizedAccessException($"Error running date command. Error {exitCode}: {output}"); + } + } + + private static void SetDateTimeUtcMacOs(DateTime dt) + { + // The format is "[[[mm]dd]HH]MM[[cc]yy][.ss]" from https://www.unix.com/man-page/osx/1/date/ - pretty weird to do this without delimiters + string formattedTime = dt.ToString("MMddHHmmyyyy.ss", CultureInfo.InvariantCulture); + int exitCode; + // Try to run the date command as user. If user doesn't have permissions, then command will fail and we throw UnauthorizedAccessException + string output = RunDateCommandUnix($"{formattedTime}", out exitCode); + if (exitCode != 0) + { + throw new UnauthorizedAccessException($"Error running date command. Error {exitCode}: {output}. Either run this program as root or ensure " + + $"/bin/date has the setuid bit set (execute 'chmod +s /bin/date' once as root)"); + } + } + + private static string RunDateCommandUnix(string commandLine, out int exitCode) + { + var si = new ProcessStartInfo() + { + FileName = "date", + Arguments = commandLine, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var process = new Process(); + string outputData; + process.StartInfo = si; + + process.Start(); + outputData = process.StandardOutput.ReadToEnd(); + var errorData = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + exitCode = process.ExitCode; + if (exitCode != 0) + { + outputData += errorData; + } + + return outputData; + } + + /// + protected override void SetTime(DateTime time) + { + SetSystemTimeUtc(time); + } + + /// + protected override DateTime ReadTime() + { + var dt = GetSystemTimeUtc(); + return dt; + } + } +} diff --git a/src/devices/Rtc/samples/Program.cs b/src/devices/Rtc/samples/Program.cs index b43553b0..30fe637b 100644 --- a/src/devices/Rtc/samples/Program.cs +++ b/src/devices/Rtc/samples/Program.cs @@ -1,8 +1,26 @@ -using System; +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; using System.Device.I2c; using System.Threading; using Iot.Device.Rtc; +static void TestSystemTime() +{ + DateTime dt = SystemClock.GetSystemTimeUtc(); + Console.WriteLine($"The system time is now {dt}"); + + DateTime newTime = new DateTime(2019, 4, 3, 20, 10, 10); + Console.WriteLine($"Do you want to set the time to {newTime}?"); + if (Console.ReadLine()!.StartsWith("y")) + { + SystemClock.SetSystemTimeUtc(newTime); + } +} + +TestSystemTime(); + // This project contains DS1307, DS3231, PCF8563 I2cConnectionSettings settings = new(1, Ds3231.DefaultI2cAddress); I2cDevice device = I2cDevice.Create(settings); @@ -15,7 +33,7 @@ rtc.DateTime = DateTime.Now; while (true) { // read time - DateTime dt = rtc.DateTime; + DateTimeOffset dt = rtc.DateTime; Console.WriteLine($"Time: {dt.ToString("yyyy/MM/dd HH:mm:ss")}"); Console.WriteLine($"Temperature: {rtc.Temperature.DegreesCelsius} ℃"); diff --git a/src/devices/Rtc/tests/Rtc.Tests.csproj b/src/devices/Rtc/tests/Rtc.Tests.csproj new file mode 100644 index 00000000..c9a9b1a6 --- /dev/null +++ b/src/devices/Rtc/tests/Rtc.Tests.csproj @@ -0,0 +1,15 @@ + + + + netcoreapp3.1 + $(TargetFrameworks);net48 + 9 + false + false + + + + + + + diff --git a/src/devices/Rtc/tests/SystemClockTests.cs b/src/devices/Rtc/tests/SystemClockTests.cs new file mode 100644 index 00000000..af320061 --- /dev/null +++ b/src/devices/Rtc/tests/SystemClockTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Iot.Device.Rtc; +using Xunit; + +namespace Iot.Device.Rtc.Tests +{ + public class SystemClockTests + { + [Fact] + public void GetSystemTimeReturnsCorrectTime() + { + DateTime shouldBe = DateTime.UtcNow; + DateTime actual = SystemClock.GetSystemTimeUtc(); + Assert.True((shouldBe - actual).Duration() < TimeSpan.FromSeconds(2)); + } + + [Fact] + public void TimeZoneConversionWorks() + { + DummyRtc rtc = new DummyRtc(); + Assert.True(rtc.LocalTimeZone.Equals(TimeZoneInfo.Local)); + + DateTime initialTimeOfClock = new DateTime(2018, 1, 1, 12, 9, 1); + rtc.RtcDateTime = initialTimeOfClock; + Assert.Equal(initialTimeOfClock, rtc.TimeOfClock); + + var now = DateTime.Now; + var utcNow = DateTime.UtcNow; + + // Round the offset to minutes (otherwise the delta is not exact, causing an exception in the DateTimeOffset ctor) + TimeSpan offset = TimeSpan.FromMinutes(Math.Round((now - utcNow).TotalMinutes)); + utcNow = new DateTime((now - offset).Ticks, DateTimeKind.Utc); // To make sure the delta matches afterwards + + DateTimeOffset newLocalTime = new DateTimeOffset(now, offset); + rtc.DateTime = newLocalTime; + Assert.Equal(now, rtc.TimeOfClock); + Assert.Equal(utcNow, rtc.DateTime); + } + + private sealed class DummyRtc : RtcBase + { + public DateTime TimeOfClock + { + get; + set; + } + + protected override DateTime ReadTime() + { + return TimeOfClock; + } + + protected override void SetTime(DateTime time) + { + // Convert to local time + TimeOfClock = new DateTime(time.Ticks, DateTimeKind.Local); + } + } + } +} -- GitLab