feat(gas-logger): добавь непрерывный логгер и JSON-карту

This commit is contained in:
2026-10-01 22:54:29 +03:00
parent 675ce063f0
commit f7a6f767e1
22 changed files with 1250 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
/* Host-only firmware harness for the actual PM35 port and Python client. */
#include "gas_logger_pm35.h"
#include "gas_logger_pcan.h"
#include <assert.h>
static gas_logger log;
static uint16_t bank0[9120],bank1[9120],modbus[128],voltage[2];
static uint32_t irq;
static uint32_t enter(void *u) { uint32_t old=irq;(void)u;irq=1;return old; }
static void leave(void *u,uint32_t state) { (void)u;assert(irq==1);irq=state; }
static gl_pm35_context context={modbus,voltage,enter,leave,0};
int host_init(void) { irq=0;return gl_pm35_init(&log,&context,bank0,bank1,9120); }
int host_capture(uint32_t time,uint16_t value)
{ modbus[28]=value;modbus[127]=128;voltage[0]=1234;voltage[1]=5678;return gl_capture(&log,time,1); }
int host_read(uint16_t address,uint16_t *out,uint16_t count)
{ return gl_modbus_read(&log,address,out,count); }
int host_write(uint16_t address,uint16_t value) { return gl_modbus_write(&log,address,value); }
int host_pcan_read(uint16_t address,uint16_t *out)
{
pcan_gas_region_t region;gl_pcan_region_init(&region,&log);
if(address<region.base || (uint32_t)address>=region.base+region.count) return GL_ADDRESS;
return region.read(&region,(uint16_t)(address-region.base),out);
}

View File

@@ -0,0 +1,46 @@
"""Build in a temporary directory; run core + Python/C end-to-end tests."""
import argparse
import ctypes
import importlib.util
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
parser=argparse.ArgumentParser()
parser.add_argument('--cc',default=shutil.which('gcc'))
args=parser.parse_args()
if not args.cc:raise SystemExit('Specify --cc /path/to/gcc')
root=Path(__file__).resolve().parents[1]
templates=root.parents[1]
sys.path.insert(0,str(templates/'python'))
env=dict(os.environ)
env['PATH']=str(Path(args.cc).resolve().parent)+os.pathsep+env.get('PATH','')
flags=[args.cc,'-std=c99','-Wall','-Wextra','-Werror','-pedantic','-I'+str(root/'include')]
with tempfile.TemporaryDirectory(prefix='gas-logger-') as directory:
binary=Path(directory)/'core.exe'
subprocess.run(flags+[str(root/'src/gas_logger.c'),str(root/'tests/test_gas_logger.c'),'-o',str(binary)],check=True,env=env)
subprocess.run([str(binary)],check=True,env=env)
lib=Path(directory)/('device.dll' if os.name=='nt' else 'device.so')
subprocess.run(flags+([] if os.name=='nt' else ['-fPIC'])+['-shared','-I'+str(root/'ports/pm35'),
'-I'+str(templates/'c/set-protocol/include'),
str(root/'src/gas_logger.c'),str(root/'src/gas_logger_pcan.c'),
str(root/'ports/pm35/gas_logger_pm35.c'),str(root/'tests/host_device.c'),
'-o',str(lib)],check=True,env=env)
# Child releases the loaded DLL before TemporaryDirectory cleanup on Windows.
runner='''import ctypes,importlib.util,sys,unittest
spec=importlib.util.spec_from_file_location('test_client',sys.argv[1])
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
m.DEVICE=ctypes.CDLL(sys.argv[2])
m.DEVICE.host_capture.argtypes=[ctypes.c_uint32,ctypes.c_uint16]
m.DEVICE.host_read.argtypes=[ctypes.c_uint16,ctypes.POINTER(ctypes.c_uint16),ctypes.c_uint16]
m.DEVICE.host_write.argtypes=[ctypes.c_uint16,ctypes.c_uint16]
m.DEVICE.host_pcan_read.argtypes=[ctypes.c_uint16,ctypes.POINTER(ctypes.c_uint16)]
r=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromModule(m))
sys.exit(not r.wasSuccessful())'''
env['PYTHONPATH']=str(templates/'python')
subprocess.run([sys.executable,'-c',runner,str(root/'tests/test_client.py'),str(lib)],check=True,env=env)
subprocess.run([sys.executable,str(root/'tools/generate_map.py'),'--check'],check=True,env=env)

View File

@@ -0,0 +1,111 @@
"""Integration: Python client -> register callbacks -> compiled C PM35 logger."""
import copy
import ctypes
import unittest
from set_devices.gas_logger import GasLoggerClient, load_map, validate_map
DEVICE = None # set by run_tests.py before loading the suite
class ClientTests(unittest.TestCase):
def setUp(self):
self.lib=DEVICE
self.assertEqual(self.lib.host_init(),0)
self.client=GasLoggerClient(self.read,self.write)
self.client.start()
for i in range(800):
self.assertEqual(self.lib.host_capture(i,i),0)
def read(self,address,count):
out=(ctypes.c_uint16*count)()
result=self.lib.host_read(address,out,count)
if result: raise RuntimeError('read '+str(result))
return list(out)
def write(self,address,value):
result=self.lib.host_write(address,value)
if result: raise RuntimeError('write '+str(result))
def test_download_keeps_recording_and_order(self):
def progress(done,total):
for i in range(20): self.assertEqual(self.lib.host_capture(900+done+i,99),0)
records=self.client.download(progress=progress)
self.assertEqual([r['time_ms'] for r in records],list(range(40,800)))
self.assertEqual([r['current'] for r in records],list(range(40,800)))
self.assertTrue(all(r['zpt2']==5678 for r in records))
self.assertEqual(self.client.status()[2],1)
self.assertGreater(self.client.status()[6],0)
def test_cancel_retains_snapshot_and_explicit_resume(self):
cancel=[False]
def progress(done,total): cancel[0]=True
with self.assertRaises(InterruptedError):
self.client.download(cancelled=lambda:cancel[0],progress=progress)
self.assertEqual(self.client.status()[2],3)
with self.assertRaises(RuntimeError): self.client.download()
self.assertEqual(len(self.client.download(resume=True)),760)
def test_partial_reply_leaves_pin(self):
original=self.client.read
self.client.read=lambda a,n: original(a,n)[:-1] if a>=0x6100 else original(a,n)
with self.assertRaises(ValueError): self.client.download()
self.client.read=original
self.assertEqual(self.client.status()[2],3)
def test_no_writes_for_wrong_schema(self):
m=load_map();m['channels'][0]['name']='Other'
writes=[]
client=GasLoggerClient(self.read,lambda *a:writes.append(a),m)
with self.assertRaises(ValueError): client.download()
self.assertEqual(writes,[])
def test_generation_mismatch_does_not_release(self):
self.write(0x6012,2)
with self.assertRaises(ValueError):self.client.release(999)
self.assertEqual(self.client.status()[2],3)
def test_classic_can_chunks_and_pcan_region(self):
def read(a,n):
words=[]
for i in range(n):
out=ctypes.c_uint16()
self.assertEqual(self.lib.host_pcan_read(a+i,ctypes.byref(out)),0)
words.append(out.value)
return words
client=GasLoggerClient(read,self.write,block_words=4)
self.assertEqual(len(client.download()),760)
def test_json_rejects_collisions_and_drift(self):
base=load_map()
for change in (
lambda m:m['channels'][0].update(gas=0x6000),
lambda m:m['channels'][1].update(gas=m['channels'][0]['gas']),
lambda m:m['service'].update(data_offset=512),
lambda m:m['service']['registers'][0].update(key='wrong'),
lambda m:m.update(capacity=65535),
):
m=copy.deepcopy(base);change(m)
with self.assertRaises(ValueError):validate_map(m)
def test_replaced_snapshot_is_rejected_before_release(self):
changed=[False]
def progress(done,total):
if changed[0]:return
changed[0]=True
self.client.release(1)
self.assertEqual(self.lib.host_capture(2000,999),0)
self.write(0x6012,2)
# Simulates a second, unsupported controlling client. We must never
# accept a mixture of generations or release the newer snapshot.
with self.assertRaises((ValueError,RuntimeError)):
self.client.download(progress=progress)
status=self.client.status()
self.assertEqual(status[2],3)
self.assertEqual(status[8],2)
def test_start_does_not_reset_history(self):
before=self.client.status()
self.client.start()
after=self.client.status()
self.assertEqual(before[6],after[6])
self.assertEqual(before[12:14],after[12:14])

View File

@@ -0,0 +1,54 @@
#include "gas_logger.h"
#include <assert.h>
#include <stdio.h>
typedef struct { uint32_t irq; uint16_t value; int fail; } board;
static uint32_t enter(void *u) { board *b=u; uint32_t old=b->irq; b->irq=1; return old; }
static void leave(void *u,uint32_t state) { board *b=u; assert(b->irq==1); b->irq=state; }
static int source(void *u,uint16_t a,uint16_t *v)
{ board *b=u; assert(b->irq==1); if(b->fail && a==11) return 0; *v=b->value+a; return 1; }
int main(void)
{
gl_channel channels[]={{10,100,200},{11,101,201}};
gl_config c={0x6000,2,3,0x12345678UL,channels};
board b={0,0,0};
gl_port p={&b,enter,leave,source};
gas_logger l;
uint16_t bank0[21],bank1[21],out[125],i;
assert(gl_init(&l,&c,&p,bank0,bank1,20)==GL_ARGUMENT);
assert(gl_init(&l,&c,&p,bank0,bank1,21)==GL_OK);
assert(gl_snapshot(&l)==GL_EMPTY);
assert(gl_capture(&l,0,0)==GL_BUSY);
assert(gl_write(&l,0x6012,GL_START)==GL_OK);
for(i=0;i<5;i++) { b.value=i; assert(gl_capture(&l,i,2)==GL_OK); }
assert(gl_snapshot(&l)==GL_OK && l.recording && l.count==0);
assert(gl_read(&l,0x6100,out,21)==GL_OK);
assert(out[0]==2 && out[7]==3 && out[14]==4 && out[5]==12);
for(i=5;i<15;i++) { b.value=i; assert(gl_capture(&l,i,3)==GL_OK); }
assert(gl_snapshot(&l)==GL_BUSY && l.missed==1 && l.recording);
assert(gl_read(&l,0x6100,out,21)==GL_OK && out[0]==2 && out[14]==4);
assert(gl_read(&l,0x6114,out,2)==GL_ADDRESS);
assert(gl_read(&l,65535,out,2)==GL_ARGUMENT);
assert(gl_write(&l,0x6012,GL_RELEASE)==GL_STALE);
assert(gl_write(&l,0x6013,1)==GL_OK);
assert(gl_write(&l,0x6014,0)==GL_OK);
assert(gl_write(&l,0x6012,GL_RELEASE)==GL_OK && l.recording);
assert(gl_read(&l,0x6100,out,1)==GL_ADDRESS);
assert(gl_snapshot(&l)==GL_OK);
assert(gl_read(&l,0x6100,out,21)==GL_OK && out[0]==12 && out[14]==14);
assert(gl_modbus_read(&l,100,out,2)==GL_OK && out[0]==24 && out[1]==25);
assert(gl_can_read(&l,200,out,2)==GL_OK && out[0]==24 && out[1]==25);
b.fail=1; out[0]=555;out[1]=666;
assert(gl_read(&l,10,out,2)==GL_SOURCE_ERROR && out[0]==555 && out[1]==666);
assert(gl_capture(&l,15,0)==GL_SOURCE_ERROR && l.count==0 && l.source_errors==1);
b.fail=0;b.irq=1;
assert(gl_capture(&l,16,0)==GL_OK && b.irq==1);
b.irq=0;l.sequence=0xffffffffUL;
assert(gl_capture(&l,0xffffffffUL,0)==GL_OK && l.sequence==0);
assert(gl_write(&l,0x6012,0)==GL_ARGUMENT && l.recording);
c.base=0xff00;
assert(gl_init(&l,&c,&p,bank0,bank1,21)==GL_ARGUMENT);
c.base=0x6000;channels[1].can=200;
assert(gl_init(&l,&c,&p,bank0,bank1,21)==GL_ARGUMENT);
assert(b.irq==0);
puts("GAS logger core: PASS");return 0;
}