1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
package com.battery.device.bts;
 
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.locks.ReentrantLock;
 
/**
 * 新威 BTS 对外接口的**远程命名管道**传输(ADR-0068)。
 *
 * <p>现场实测的三条关键事实决定了这个类的形状:
 *
 * <ol>
 *   <li><b>接口是命名管道,不是 TCP。</b>协议文档的 TCP 小节在现场没有对应物;
 *       可达的是服务端命名管道 {@code \\.\pipe\NewareBtsAPI}。跨机访问它要走 SMB,
 *       所以这里用 {@code \\<主机>\pipe\NewareBtsAPI} 这个 Win32 路径,
 *       并在打开管道前先建一条 SMB 会话。</li>
 *   <li><b>一条连接只服务一条请求。</b>实测在同一条连接上发第二条命令会永久挂住
 *       (不报错、不返回)。所以每次请求都新建连接、收到应答即关闭,不做长连接复用。</li>
 *   <li><b>报文结束符是纯 {@code \n\n}。</b>文档里的 {@code #\r\n} 与 {@code #\r\n} 变体实测均无效
 *       (后者零响应)。这里按实测取 {@code \n\n}。</li>
 * </ol>
 *
 * <p><b>权限</b>:调用方需管理员身份,且 SMB 会话用的是那台机器的本地管理员账号——
 * 口令从本地凭据文件取({@link com.battery.device.api.credentials.LocalCredentials}),不进数据库、不进仓库。
 * 会话由本类用 {@code net.exe use} 建立:Java 无法给命名管道传凭据,
 * 只有先有一条已认证的会话,路径才打得开。会话按"主机 + 账号 + 口令"缓存,进程内每条只建一次。
 *
 * <p><b>只读边界</b>:出站请求先过 {@link NewareBtsReadOnlyCommands} 的白名单,
 * 非只读命令在写进管道之前就被拒绝。
 *
 * <p><b>读超时</b>:{@code RandomAccessFile} 没有读超时(管道挂住就是永久阻塞),
 * 因此读取放在单线程执行器里、由调用方带超时等待;超时从另一个线程关掉在途句柄把读打断,
 * 使"挂住"变成一次可分类的失败,而不是把调用线程钉死。
 */
public final class NewareBtsPipeTransport implements BtsTransport
{
    /** 服务端命名管道的名字;跨机时路径为 {@code \\<主机>\pipe\<名字>}。 */
    private static final String PIPE_NAME = "NewareBtsAPI";
    /** 实测的报文结束符(协议文档的 {@code #\r\n} 在本接口上无效)。 */
    private static final byte[] FRAME_END = "\n\n".getBytes(StandardCharsets.UTF_8);
    private static final int DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
    private static final int MAX_SESSION_TIMEOUT_MS = 30_000;
    private static final int MAX_RESPONSE_TIMEOUT_MS = 120_000;
    private static final int READ_BUFFER_BYTES = 64 * 1024;
    /** 已建立的 SMB 会话;键为「主机 + 账号 + 口令」的摘要,进程内复用。 */
    private static final Set<String> ESTABLISHED_SESSIONS = ConcurrentHashMap.newKeySet();
 
    private final String host;
    private final String user;
    private final String password;
    private final String pipePath;
    private final String sessionKey;
    private final int sessionTimeoutMs;
    private final int responseTimeoutMs;
    private final int maxResponseBytes;
    private final ReentrantLock requestLock = new ReentrantLock(true);
    /** 读取放在这里跑,使调用方能带超时等待;线程是守护线程,不拖住进程退出。 */
    private final ExecutorService reader = Executors.newSingleThreadExecutor(runnable -> {
        Thread thread = new Thread(runnable, "neware-bts-pipe-reader");
        thread.setDaemon(true);
        return thread;
    });
    /** 当前在途的管道句柄:读超时时由调用线程关掉它,把阻塞中的读打断。 */
    private volatile RandomAccessFile inFlight;
 
    public NewareBtsPipeTransport(String host, String user, String password,
            Duration sessionTimeout, Duration responseTimeout)
    {
        this(host, user, password, sessionTimeout, responseTimeout, DEFAULT_MAX_RESPONSE_BYTES);
    }
 
    NewareBtsPipeTransport(String host, String user, String password,
            Duration sessionTimeout, Duration responseTimeout, int maxResponseBytes)
    {
        this.host = requireText(host, "host");
        this.user = requireText(user, "user");
        this.password = Objects.requireNonNull(password, "password");
        this.sessionTimeoutMs = timeout(sessionTimeout, MAX_SESSION_TIMEOUT_MS, "session");
        this.responseTimeoutMs = timeout(responseTimeout, MAX_RESPONSE_TIMEOUT_MS, "response");
        this.maxResponseBytes = maxResponseBytes;
        if (maxResponseBytes < 1) throw new IllegalArgumentException("maxResponseBytes must be positive");
        this.pipePath = "\\\\" + this.host + "\\pipe\\" + PIPE_NAME;
        this.sessionKey = this.host + "|" + this.user + "|" + Integer.toHexString(this.password.hashCode());
    }
 
    /**
     * 发出一条只读请求并返回原始应答。
     *
     * <p>前置条件:请求只含只读白名单内的命令。并发调用由锁串行化(本接口一条连接只服务一条请求)。
     * SMB 会话首次使用时建立;管道打开或读取失败时重建会话并重试一次,仍失败则归类抛出。
     *
     * @param request 完整请求报文(不含结束符)
     * @return 应答原文(已去掉 {@code \n\n})
     * @throws BtsTransportException 请求越权,或会话、管道、读取失败
     */
    @Override
    public String exchange(String request)
    {
        NewareBtsReadOnlyCommands.requireAllowed(request);
        requestLock.lock();
        try
        {
            ensureSession(false);
            try
            {
                return perform(request);
            }
            catch (BtsTransportException failure)
            {
                // 会话可能已被对端或系统回收(长时间空闲、重启上位机):重建一次再试,只试一次。
                if (!isSessionRecoverable(failure.reason())) throw failure;
                ensureSession(true);
                return perform(request);
            }
        }
        finally
        {
            requestLock.unlock();
        }
    }
 
    private String perform(String request)
    {
        Future<String> pending = reader.submit(() -> openAndExchange(request));
        try
        {
            return pending.get(responseTimeoutMs, TimeUnit.MILLISECONDS);
        }
        catch (TimeoutException ex)
        {
            interruptRead();
            pending.cancel(true);
            throw new BtsTransportException(BtsTransportException.Reason.TIMEOUT,
                    "BTS 管道应答超时(" + responseTimeoutMs + " ms):" + pipePath, ex);
        }
        catch (InterruptedException ex)
        {
            interruptRead();
            pending.cancel(true);
            Thread.currentThread().interrupt();
            throw new BtsTransportException(BtsTransportException.Reason.IO_FAILURE,
                    "BTS 管道读取被中断:" + pipePath, ex);
        }
        catch (ExecutionException ex)
        {
            Throwable cause = ex.getCause();
            if (cause instanceof BtsTransportException failure) throw failure;
            throw new BtsTransportException(BtsTransportException.Reason.IO_FAILURE,
                    "BTS 管道交换失败:" + pipePath, cause);
        }
    }
 
    /** 每次请求新建一条连接:实测同一连接上的第二条命令不会返回。 */
    private String openAndExchange(String request) throws IOException
    {
        RandomAccessFile pipe;
        try
        {
            pipe = new RandomAccessFile(pipePath, "rw");
        }
        catch (FileNotFoundException ex)
        {
            throw new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED,
                    "无法打开新威 BTS 管道(接口未启用?未激活?无管理员凭据?):" + pipePath, ex);
        }
        inFlight = pipe;
        try
        {
            pipe.write((request + new String(FRAME_END, StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8));
            return decode(readFrame(pipe, maxResponseBytes));
        }
        catch (java.io.EOFException ex)
        {
            throw new BtsTransportException(BtsTransportException.Reason.DISCONNECTED,
                    "新威 BTS 管道在应答结束前关闭:" + pipePath, ex);
        }
        catch (FrameTooLargeException ex)
        {
            throw new BtsTransportException(BtsTransportException.Reason.RESPONSE_TOO_LARGE,
                    "新威 BTS 应答超过 " + maxResponseBytes + " 字节:" + pipePath, ex);
        }
        catch (InvalidUtf8Exception ex)
        {
            throw new BtsTransportException(BtsTransportException.Reason.INVALID_UTF8,
                    "新威 BTS 应答不是合法 UTF-8:" + pipePath, ex);
        }
        finally
        {
            inFlight = null;
            try { pipe.close(); } catch (IOException ignored) { }
        }
    }
 
    /**
     * 按块读取并扫描 {@code \n\n},返回去掉结束符的应答字节。逐字节读在 SMB 上是一次读一次往返,
     * 而 {@code download} 一页最多上千行,所以必须成块读。
     */
    static byte[] readFrame(RandomAccessFile pipe, int maxPayloadBytes) throws IOException
    {
        byte[] chunk = new byte[READ_BUFFER_BYTES];
        ByteArrayOutputStream received = new ByteArrayOutputStream();
        int matched = 0;
        while (true)
        {
            int read = pipe.read(chunk);
            if (read < 0) throw new java.io.EOFException("新威 BTS 管道在应答结束前关闭");
            for (int i = 0; i < read; i++)
            {
                byte value = chunk[i];
                if (value == FRAME_END[matched])
                {
                    matched++;
                    if (matched == FRAME_END.length) return received.toByteArray();
                    continue;
                }
                if (matched > 0)
                {
                    // 已匹配到的结束符前缀是正文的一部分:原样补回,再按当前字节重判。
                    for (int prefix = 0; prefix < matched; prefix++) append(received, FRAME_END[prefix], maxPayloadBytes);
                    matched = 0;
                }
                if (value == FRAME_END[0])
                {
                    matched = 1;
                    continue;
                }
                append(received, value, maxPayloadBytes);
            }
        }
    }
 
    private static void append(ByteArrayOutputStream buffer, byte value, int maxPayloadBytes) throws FrameTooLargeException
    {
        if (buffer.size() >= maxPayloadBytes) throw new FrameTooLargeException();
        buffer.write(value);
    }
 
    private static String decode(byte[] bytes) throws InvalidUtf8Exception
    {
        try
        {
            return StandardCharsets.UTF_8.newDecoder()
                    .onMalformedInput(CodingErrorAction.REPORT)
                    .onUnmappableCharacter(CodingErrorAction.REPORT)
                    .decode(ByteBuffer.wrap(bytes)).toString();
        }
        catch (CharacterCodingException ex)
        {
            throw new InvalidUtf8Exception(ex);
        }
    }
 
    /**
     * 建立 SMB 会话。Java 无法向命名管道传凭据,所以只能先让系统用 {@code net.exe use} 认证一次,
     * 之后管道路径才打得开;这是"把人工那步 net use 收进后端"的落点。
     *
     * @param force true 表示先撤掉旧会话再建(用于重试路径)
     */
    private void ensureSession(boolean force)
    {
        if (force)
        {
            runNet("use", "\\\\" + host + "\\ipc$", "/delete", "/y");
            ESTABLISHED_SESSIONS.remove(sessionKey);
        }
        if (!force && ESTABLISHED_SESSIONS.contains(sessionKey)) return;
        String output = runNet("use", "\\\\" + host + "\\ipc$", "/user:" + host + "\\" + user, password);
        if (output == null) throw new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED,
                "无法建立到 " + host + " 的 SMB 会话", null);
        ESTABLISHED_SESSIONS.add(sessionKey);
    }
 
    /**
     * 执行一条 net.exe 命令。
     *
     * <p>返回值:成功返回输出;失败时若输出表明"该主机的会话已存在"(错误 1219/多重连接)
     * 也按成功处理——会话已经在了,管道照常可开。其余失败返回空值。
     * 建立与撤销都失败不抛出,交给后续管道打开去报错,避免凭据问题被当成会话问题。
     */
    private String runNet(String... arguments)
    {
        String[] command = new String[arguments.length + 1];
        command[0] = netExecutable();
        System.arraycopy(arguments, 0, command, 1, arguments.length);
        try
        {
            Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
            String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
            if (!process.waitFor(sessionTimeoutMs, TimeUnit.MILLISECONDS))
            {
                process.destroyForcibly();
                return null;
            }
            if (process.exitValue() == 0) return output;
            String normalized = output.toLowerCase(Locale.ROOT);
            if (normalized.contains("1219") || normalized.contains("多重连接") || normalized.contains("multiple connections"))
                return output;
            return null;
        }
        catch (IOException ex)
        {
            return null;
        }
        catch (InterruptedException ex)
        {
            Thread.currentThread().interrupt();
            return null;
        }
    }
 
    private static String netExecutable()
    {
        String systemRoot = System.getenv("SystemRoot");
        return (systemRoot == null || systemRoot.isBlank() ? "C:\\Windows" : systemRoot) + "\\System32\\net.exe";
    }
 
    /** 会话或连接类失败值得重建会话再试一次;协议与内容类失败重试没有意义。 */
    private static boolean isSessionRecoverable(BtsTransportException.Reason reason)
    {
        return reason == BtsTransportException.Reason.CONNECTION_FAILED
                || reason == BtsTransportException.Reason.DISCONNECTED
                || reason == BtsTransportException.Reason.IO_FAILURE;
    }
 
    /** 关掉在途句柄:这是唯一能打断阻塞中 ReadFile 的手段。 */
    private void closeInFlight()
    {
        RandomAccessFile pipe = inFlight;
        if (pipe == null) return;
        try { pipe.close(); } catch (IOException ignored) { }
    }
 
    /**
     * 在独立线程里关掉在途句柄,用来打断阻塞中的读。
     *
     * <p>为什么不自己关:实测关闭一条"服务端不作答的连接"**本身会阻塞**(关闭要等 SMB 层回收),
     * 那样超时就白设了——调用线程照样被钉死。而这个实例是串行执行的(BTS 一条连接只服务一条请求,
     * 注册表也按实例串行),一条不响应的命令足以把该实例之后的所有请求堵住。
     * 关闭线程是守护线程且不等它结束:最坏情况是每次超时留一个挂着的关闭线程,
     * 而超时本身应当罕见——真出现"某客户端不支持某命令",它的表现正是每次都无应答。
     */
    private void interruptRead()
    {
        RandomAccessFile pipe = inFlight;
        if (pipe == null) return;
        Thread closer = new Thread(() -> {
            try { pipe.close(); } catch (IOException ignored) { }
        }, "neware-bts-pipe-close");
        closer.setDaemon(true);
        closer.start();
    }
 
    @Override
    public void close()
    {
        requestLock.lock();
        try
        {
            closeInFlight();
        }
        finally
        {
            requestLock.unlock();
        }
        reader.shutdownNow();
    }
 
    private static String requireText(String value, String field)
    {
        String normalized = Objects.requireNonNull(value, field).trim();
        if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank");
        return normalized;
    }
 
    private static int timeout(Duration value, int maximumMs, String field)
    {
        int millis = Math.toIntExact(Objects.requireNonNull(value, field).toMillis());
        if (millis < 1) throw new IllegalArgumentException(field + " timeout must be positive");
        if (millis > maximumMs) throw new IllegalArgumentException(field + " timeout must not exceed " + maximumMs + " ms");
        return millis;
    }
 
    private static final class FrameTooLargeException extends IOException
    {
        private static final long serialVersionUID = 1L;
    }
 
    private static final class InvalidUtf8Exception extends IOException
    {
        private static final long serialVersionUID = 1L;
 
        private InvalidUtf8Exception(Throwable cause)
        {
            super(cause);
        }
    }
}