← How the PC side builds a high-performance environment for HarmonyOS tests and keeps the device awake

This article has no English version yet; the original Chinese text follows. Chart labels are in Chinese as well.

PC 端协议辅助代码

这是随文的 .NET 8 / C# 12 辅助库,展示 SmartPerf 命令构造、串行 TCP 写入,以及手机屏幕状态解析和恢复命令选择。构建为 library,不会连接或修改设备。

dotnet build PcProtocolExamples.csproj -c Release

调用者负责已授权 HDC target、daemon 启动和就绪、动态端口转发、持续接收与完整消息解析、会话持久化和资源恢复。本文中的 SmartPerfSession 是监控器的完整会话封装,不是这个小库定义的类。

string token = SmartPerfCommands.CreateToken();
string init = SmartPerfCommands.Init(token, highPerformance: true);
string start = SmartPerfCommands.Start(token);
string heartbeat = SmartPerfCommands.Keepalive(token);
string stop = SmartPerfCommands.Stop(token);

在接收循环里先注册相应 ACK 等待器,再发送 init/start/stop。SendSerializedAsync 只负责有超时的串行写入,不把 TCP 的一块读取当成完整响应。keepalive 没有 ACK。正常结束、取消以及启动响应丢失时,都需要显式停止或进入有记录的恢复流程;关闭 NetworkStream 不能替代 stop。

ScreenState before = ScreenCommands.Parse(powerServiceDump);
// 将 before 和本次会话的设备身份持久化,然后才请求覆盖值。
string enable = ScreenCommands.Override(-1);
// 只有 InfiniteExplicitlyRejected(response) 为 true 才使用有限覆盖。
string compatible = ScreenCommands.Override(ScreenCommands.FiniteTimeoutMs);

// 结束时先取消并等待续期任务,再选择恢复命令。
string restore = ScreenCommands.Restore(before);

开启后要确认临时覆盖值和 AWAKE;续期只检查超时,不强制唤醒。恢复后比较基础值和覆盖值;SameTimeouts 不要求恢复到原电源状态,因为用户可能主动熄屏。原先有临时覆盖值时必须写回该值,timeout -r 只适用于原先没有覆盖的情况。

示例不会自动吞掉命令失败。调用者应保留尚未完成的恢复记录,不能仅凭进程退出码 0 认定 power-shell 成功。

Files

PcProtocolExamples.cs 134 lines Download ↓
using System.Globalization;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;

namespace HarmonyPerf.Blog;

// Small protocol helpers. The application owns HDC, session recovery,
// incremental response parsing, sample recording and UI lifecycle.
public static class SmartPerfCommands
{
    public static string CreateToken() =>
        Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();

    public static string Init(string token, bool highPerformance,
        int intervalMs = 500, string packageName = "")
    {
        token = ValidateToken(token);
        if (intervalMs is < 500 or > 60_000)
            throw new ArgumentOutOfRangeException(nameof(intervalMs));
        var package = packageName.Trim();
        if (package.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '.' and not '_' and not '-'))
            throw new ArgumentException("Invalid package name.", nameof(packageName));
        var app = package.Length == 0 ? "" : $" -PKG {package} -f";
        var performance = highPerformance ? " -lockfreq" : "";
        return $"init::{token} -SESSIONID 1 -INTERVAL {intervalMs.ToString(CultureInfo.InvariantCulture)}" +
            $" -c -g -r -d -t -p -net{app}{performance}";
    }

    public static string Start(string token) => $"start::{ValidateToken(token)}";
    public static string Keepalive(string token) => $"keepalive::{ValidateToken(token)}";
    public static string Stop(string token) => $"stop::{ValidateToken(token)}";

    private static string ValidateToken(string token)
    {
        ArgumentNullException.ThrowIfNull(token);
        if (token.Length != 32 || token.Any(c => !Uri.IsHexDigit(c)))
            throw new ArgumentException("Expected 32 hexadecimal characters.", nameof(token));
        return token.ToLowerInvariant();
    }

    // Register an ACK waiter in the receiving loop BEFORE calling this method.
    // A TCP ReadAsync completion is not a message boundary.
    public static async Task SendSerializedAsync(NetworkStream stream,
        SemaphoreSlim writeGate, string command, CancellationToken cancellationToken)
    {
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken, timeout.Token);
        var entered = false;
        try
        {
            await writeGate.WaitAsync(linked.Token).ConfigureAwait(false);
            entered = true;
            // No newline is added by this daemon's command protocol.
            await stream.WriteAsync(Encoding.UTF8.GetBytes(command), linked.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (timeout.IsCancellationRequested &&
                                                !cancellationToken.IsCancellationRequested)
        {
            throw new TimeoutException("SmartPerf command write timed out.");
        }
        finally
        {
            if (entered) writeGate.Release();
        }
    }
}

public sealed record ScreenState(int BaseTimeoutMs, int? OverrideTimeoutMs, string? PowerState)
{
    public bool IsAwake => PowerState == "AWAKE";
}

public static class ScreenCommands
{
    public const int FiniteTimeoutMs = 7_200_000;
    public const string Read = "hidumper -s PowerManagerService -a '-s'";
    public const string Wakeup = "power-shell wakeup";
    private static readonly Regex TimeoutPattern = new(
        @"^[ \t]*ScreenOffTime:[ \t]*Timeout=(?<base>-?\d+)ms(?:[ \t]+OverrideTimeout=(?<override>-?\d+)ms)?[ \t]*\r?$",
        RegexOptions.Multiline | RegexOptions.CultureInvariant);
    private static readonly Regex StatePattern = new(
        @"^[ \t]*Current State:[ \t]*(?<state>[A-Z_]+)(?:[ \t]+Reason:.*)?[ \t]*\r?$",
        RegexOptions.Multiline | RegexOptions.CultureInvariant);

    public static ScreenState Parse(string dump)
    {
        var matches = TimeoutPattern.Matches(dump);
        if (matches.Count != 1) throw new FormatException("Expected one screen timeout record.");
        var baseline = ParseTimeout(matches[0].Groups["base"].Value);
        var temporary = matches[0].Groups["override"];
        int? overridden = temporary.Success ? ParseTimeout(temporary.Value) : null;
        var states = StatePattern.Matches(dump);
        if (states.Count > 1) throw new FormatException("Ambiguous power state.");
        return new(baseline, overridden, states.Count == 1 ? states[0].Groups["state"].Value : null);
    }

    public static string Override(int milliseconds)
    {
        if (milliseconds < -1) throw new ArgumentOutOfRangeException(nameof(milliseconds));
        return $"power-shell timeout -o {milliseconds.ToString(CultureInfo.InvariantCulture)}";
    }

    // Persist original BEFORE any override. Before restore, cancel and join renewals.
    public static string Restore(ScreenState original)
    {
        if (original.BaseTimeoutMs < -1 || original.OverrideTimeoutMs is < -1)
            throw new ArgumentOutOfRangeException(nameof(original));
        return original.OverrideTimeoutMs is int prior ? Override(prior) : "power-shell timeout -r";
    }

    public static bool InfiniteExplicitlyRejected(string output) =>
        output.Trim() == "Override screen off time to -1 failed";

    public static void RequireResponse(string output, string expected)
    {
        if (!string.Equals(output.Trim(), expected, StringComparison.Ordinal))
            throw new InvalidOperationException("Device did not confirm the requested command.");
    }

    // A sleeping screen after a user's power-button action may still have a valid override.
    public static bool SameTimeouts(ScreenState actual, ScreenState expected) =>
        actual.BaseTimeoutMs == expected.BaseTimeoutMs && actual.OverrideTimeoutMs == expected.OverrideTimeoutMs;

    private static int ParseTimeout(string text)
    {
        if (!int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value) || value < -1)
            throw new FormatException("Invalid screen timeout value.");
        return value;
    }
}
PcProtocolExamples.csproj 10 lines Download ↓
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>12</LangVersion>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>