feat: Geutebruck GeViScope/GeViSoft Action Mapping System - MVP
This MVP release provides a complete full-stack solution for managing action mappings in Geutebruck's GeViScope and GeViSoft video surveillance systems. ## Features ### Flutter Web Application (Port 8081) - Modern, responsive UI for managing action mappings - Action picker dialog with full parameter configuration - Support for both GSC (GeViScope) and G-Core server actions - Consistent UI for input and output actions with edit/delete capabilities - Real-time action mapping creation, editing, and deletion - Server categorization (GSC: prefix for GeViScope, G-Core: prefix for G-Core servers) ### FastAPI REST Backend (Port 8000) - RESTful API for action mapping CRUD operations - Action template service with comprehensive action catalog (247 actions) - Server management (G-Core and GeViScope servers) - Configuration tree reading and writing - JWT authentication with role-based access control - PostgreSQL database integration ### C# SDK Bridge (gRPC, Port 50051) - Native integration with GeViSoft SDK (GeViProcAPINET_4_0.dll) - Action mapping creation with correct binary format - Support for GSC and G-Core action types - Proper Camera parameter inclusion in action strings (fixes CrossSwitch bug) - Action ID lookup table with server-specific action IDs - Configuration reading/writing via SetupClient ## Bug Fixes - **CrossSwitch Bug**: GSC and G-Core actions now correctly display camera/PTZ head parameters in GeViSet - Action strings now include Camera parameter: `@ PanLeft (Comment: "", Camera: 101028)` - Proper filter flags and VideoInput=0 for action mappings - Correct action ID assignment (4198 for GSC, 9294 for G-Core PanLeft) ## Technical Stack - **Frontend**: Flutter Web, Dart, Dio HTTP client - **Backend**: Python FastAPI, PostgreSQL, Redis - **SDK Bridge**: C# .NET 8.0, gRPC, GeViSoft SDK - **Authentication**: JWT tokens - **Configuration**: GeViSoft .set files (binary format) ## Credentials - GeViSoft/GeViScope: username=sysadmin, password=masterkey - Default admin: username=admin, password=admin123 ## Deployment All services run on localhost: - Flutter Web: http://localhost:8081 - FastAPI: http://localhost:8000 - SDK Bridge gRPC: localhost:50051 - GeViServer: localhost (default port) Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
559
GeViSetEditor/GeViSetEditor.Core/Parsers/SetFileParser.cs
Normal file
559
GeViSetEditor/GeViSetEditor.Core/Parsers/SetFileParser.cs
Normal file
@@ -0,0 +1,559 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
using GeViSetEditor.Core.Models;
|
||||
|
||||
namespace GeViSetEditor.Core.Parsers
|
||||
{
|
||||
/// <summary>
|
||||
/// Parser for GeViSet .set configuration files (binary format)
|
||||
/// Format: Proprietary binary with sections, rules, and action mappings
|
||||
/// </summary>
|
||||
public class SetFileParser
|
||||
{
|
||||
private byte[] _data;
|
||||
private int _position;
|
||||
|
||||
public GeViSetConfiguration Parse(string filePath)
|
||||
{
|
||||
_data = File.ReadAllBytes(filePath);
|
||||
_position = 0;
|
||||
|
||||
var config = new GeViSetConfiguration();
|
||||
|
||||
// Read header
|
||||
if (_data[_position] == 0x00) _position++; // Skip initial marker
|
||||
config.Header = ReadPascalString() ?? "GeViSoft Parameters";
|
||||
|
||||
Console.WriteLine($"Parsing {filePath} ({_data.Length:N0} bytes)");
|
||||
Console.WriteLine($"Header: {config.Header}\n");
|
||||
|
||||
// Parse all sections until end of file
|
||||
int sectionCount = 0;
|
||||
while (_position < _data.Length - 20)
|
||||
{
|
||||
var section = TryParseSection();
|
||||
if (section != null)
|
||||
{
|
||||
config.Sections.Add(section);
|
||||
sectionCount++;
|
||||
Console.WriteLine($"Section {sectionCount}: {section.Name} ({section.Items.Count} items, {section.Rules.Count} rules) - pos: {_position}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_position++; // Skip unknown byte
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nTotal: {sectionCount} sections parsed");
|
||||
|
||||
// Second pass: Find all "Rules" markers and extract action mappings directly
|
||||
Console.WriteLine($"\nSearching for Rules sections...");
|
||||
ExtractRulesDirectly(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private void ExtractRulesDirectly(GeViSetConfiguration config)
|
||||
{
|
||||
// Search for pattern: 05 52 75 6C 65 73 = "05 Rules"
|
||||
byte[] rulesPattern = new byte[] { 0x05, 0x52, 0x75, 0x6C, 0x65, 0x73 };
|
||||
|
||||
for (int i = 0; i < _data.Length - rulesPattern.Length; i++)
|
||||
{
|
||||
bool match = true;
|
||||
for (int j = 0; j < rulesPattern.Length; j++)
|
||||
{
|
||||
if (_data[i + j] != rulesPattern[j])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match)
|
||||
{
|
||||
Console.WriteLine($" Found Rules pattern at offset {i} (0x{i:X})");
|
||||
_position = i + rulesPattern.Length;
|
||||
|
||||
// Create or find a section for these rules
|
||||
var rulesSection = config.Sections.FirstOrDefault(s => s.Name == "ActionMappings");
|
||||
if (rulesSection == null)
|
||||
{
|
||||
rulesSection = new Section { Name = "ActionMappings" };
|
||||
config.Sections.Add(rulesSection);
|
||||
}
|
||||
|
||||
// Try to parse rules from this location
|
||||
ParseRulesSection(rulesSection);
|
||||
|
||||
Console.WriteLine($" Extracted {rulesSection.Rules.Count} rules");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Section TryParseSection()
|
||||
{
|
||||
int startPos = _position;
|
||||
|
||||
// Try to read section name
|
||||
string sectionName = ReadPascalString();
|
||||
if (string.IsNullOrEmpty(sectionName) || sectionName.Length > 50)
|
||||
{
|
||||
_position = startPos;
|
||||
return null;
|
||||
}
|
||||
|
||||
var section = new Section { Name = sectionName };
|
||||
|
||||
// Parse section contents
|
||||
int maxIterations = 1000; // Safety limit
|
||||
while (_position < _data.Length - 10 && maxIterations-- > 0)
|
||||
{
|
||||
// Check for "Rules" subsection (can be Pascal string OR length-prefixed)
|
||||
int savePos = _position;
|
||||
|
||||
// Try Pascal string first (0x07 marker)
|
||||
string possibleRules = ReadPascalString();
|
||||
if (possibleRules == "Rules")
|
||||
{
|
||||
Console.WriteLine($" Found Rules subsection (Pascal) at offset {savePos} in section '{sectionName}'");
|
||||
ParseRulesSection(section);
|
||||
break; // Rules typically end a section
|
||||
}
|
||||
|
||||
// Rewind and try length-prefixed format (no 0x07 marker)
|
||||
_position = savePos;
|
||||
possibleRules = ReadLengthPrefixedString();
|
||||
if (possibleRules == "Rules")
|
||||
{
|
||||
Console.WriteLine($" Found Rules subsection (LenPrefix) at offset {savePos} in section '{sectionName}'");
|
||||
ParseRulesSection(section);
|
||||
break; // Rules typically end a section
|
||||
}
|
||||
|
||||
// Neither worked, rewind
|
||||
_position = savePos;
|
||||
|
||||
// Try to read a config item
|
||||
var item = TryReadConfigItem();
|
||||
if (item != null)
|
||||
{
|
||||
section.Items.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if we've hit the next section or end
|
||||
if (IsLikelySectionStart())
|
||||
break;
|
||||
_position++;
|
||||
}
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
private void ParseRulesSection(Section section)
|
||||
{
|
||||
int startRuleCount = section.Rules.Count;
|
||||
int startPos = _position;
|
||||
|
||||
// Skip count/metadata bytes
|
||||
while (_position < _data.Length && _data[_position] <= 0x04)
|
||||
{
|
||||
_position++;
|
||||
}
|
||||
|
||||
Console.WriteLine($" ParseRulesSection: start={startPos}, after skip={_position}, bytes: {BitConverter.ToString(_data, _position, Math.Min(20, _data.Length - _position))}");
|
||||
|
||||
// Parse rules - keep trying until we stop finding action strings
|
||||
int maxRules = 100;
|
||||
int attempts = 0;
|
||||
int consecutiveFails = 0;
|
||||
|
||||
while (maxRules-- > 0 && _position < _data.Length - 20)
|
||||
{
|
||||
int beforePos = _position;
|
||||
var rule = TryParseRule();
|
||||
attempts++;
|
||||
|
||||
if (rule != null)
|
||||
{
|
||||
section.Rules.Add(rule);
|
||||
Console.WriteLine($" Found rule at pos {beforePos}, now at {_position}");
|
||||
consecutiveFails = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_position++;
|
||||
consecutiveFails++;
|
||||
|
||||
// Stop if we've had too many consecutive failures
|
||||
if (consecutiveFails > 100)
|
||||
{
|
||||
Console.WriteLine($" Too many consecutive failures at {_position}, stopping");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts > 200) break; // Safety limit
|
||||
}
|
||||
|
||||
Console.WriteLine($" ParseRulesSection complete: extracted {section.Rules.Count - startRuleCount} rules");
|
||||
}
|
||||
|
||||
private ActionRule TryParseRule()
|
||||
{
|
||||
int startPos = _position;
|
||||
|
||||
// Look for rule marker: 01 <id>
|
||||
if (_position + 2 >= _data.Length || _data[_position] != 0x01)
|
||||
{
|
||||
_position = startPos;
|
||||
return null;
|
||||
}
|
||||
|
||||
var rule = new ActionRule
|
||||
{
|
||||
RuleId = _data[_position + 1]
|
||||
};
|
||||
_position += 2;
|
||||
|
||||
// Skip size bytes (05 00 00 00 or 0a 00 00 00 patterns)
|
||||
if (_position + 4 < _data.Length && (_data[_position] == 0x05 || _data[_position] == 0x0a))
|
||||
{
|
||||
_position += 4;
|
||||
}
|
||||
|
||||
// Read trigger properties (fields starting with .)
|
||||
for (int i = 0; i < 30; i++) // Max 30 properties
|
||||
{
|
||||
if (_position >= _data.Length) break;
|
||||
|
||||
// Check for property field (01 <len> .<name>)
|
||||
if (_data[_position] == 0x01 && _position + 2 < _data.Length)
|
||||
{
|
||||
int len = _data[_position + 1];
|
||||
if (len > 0 && len < 50 && _position + 2 + len < _data.Length)
|
||||
{
|
||||
string propName = Encoding.UTF8.GetString(_data, _position + 2, len);
|
||||
_position += 2 + len;
|
||||
|
||||
// Read boolean value
|
||||
if (_position < _data.Length)
|
||||
{
|
||||
bool value = _data[_position++] != 0;
|
||||
if (propName.StartsWith("."))
|
||||
{
|
||||
rule.TriggerProperties[propName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Read main action string (07 01 40 <len_2bytes> <data>)
|
||||
string mainAction = ReadActionString();
|
||||
if (!string.IsNullOrEmpty(mainAction))
|
||||
{
|
||||
rule.MainAction = mainAction;
|
||||
}
|
||||
|
||||
// Skip metadata (04 02 40... patterns)
|
||||
SkipMetadata();
|
||||
|
||||
// Look for nested Rules section (action variations)
|
||||
int checkPos = _position;
|
||||
string nestedRules = ReadPascalString();
|
||||
if (nestedRules == "Rules")
|
||||
{
|
||||
ParseActionVariations(rule);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try length-prefixed format
|
||||
_position = checkPos;
|
||||
nestedRules = ReadLengthPrefixedString();
|
||||
if (nestedRules == "Rules")
|
||||
{
|
||||
ParseActionVariations(rule);
|
||||
}
|
||||
else
|
||||
{
|
||||
_position = checkPos; // Rewind if not "Rules"
|
||||
}
|
||||
}
|
||||
|
||||
// Only return rule if it has meaningful data
|
||||
if (!string.IsNullOrEmpty(rule.MainAction) || rule.TriggerProperties.Count > 0)
|
||||
{
|
||||
return rule;
|
||||
}
|
||||
|
||||
_position = startPos;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ParseActionVariations(ActionRule rule)
|
||||
{
|
||||
// Skip count bytes
|
||||
while (_position < _data.Length && _data[_position] <= 0x04)
|
||||
{
|
||||
_position++;
|
||||
}
|
||||
|
||||
// Parse each variation
|
||||
int maxVariations = 30;
|
||||
while (maxVariations-- > 0 && _position < _data.Length - 20)
|
||||
{
|
||||
var variation = TryParseActionVariation();
|
||||
if (variation != null)
|
||||
{
|
||||
rule.ActionVariations.Add(variation);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (IsLikelySectionStart())
|
||||
break;
|
||||
|
||||
// Look ahead for next variation
|
||||
bool foundNext = false;
|
||||
for (int i = 1; i < 20 && _position + i < _data.Length; i++)
|
||||
{
|
||||
if (_data[_position + i] == 0x01 && _position + i + 1 < _data.Length)
|
||||
{
|
||||
_position += i;
|
||||
foundNext = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundNext)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ActionVariation TryParseActionVariation()
|
||||
{
|
||||
int startPos = _position;
|
||||
|
||||
// Look for variation marker: 01 <id>
|
||||
if (_position + 2 >= _data.Length || _data[_position] != 0x01)
|
||||
{
|
||||
_position = startPos;
|
||||
return null;
|
||||
}
|
||||
|
||||
var variation = new ActionVariation
|
||||
{
|
||||
VariationId = _data[_position + 1]
|
||||
};
|
||||
_position += 2;
|
||||
|
||||
// Skip size (05 00 00 00)
|
||||
if (_position + 4 < _data.Length && _data[_position] == 0x05)
|
||||
{
|
||||
_position += 4;
|
||||
}
|
||||
|
||||
// Read action string
|
||||
variation.ActionString = ReadActionString() ?? "";
|
||||
|
||||
// Skip metadata
|
||||
SkipMetadata();
|
||||
|
||||
// Read action type (GscAction, GCoreAction, etc.)
|
||||
string actionType = ReadPascalString();
|
||||
if (actionType != null && actionType.Contains("Action"))
|
||||
{
|
||||
variation.ActionType = actionType;
|
||||
|
||||
// Read full command (another action string)
|
||||
variation.FullCommand = ReadActionString() ?? "";
|
||||
|
||||
// Read server type
|
||||
variation.ServerType = ReadPascalString() ?? "";
|
||||
|
||||
// Try to read server name
|
||||
int savePos = _position;
|
||||
string serverName = ReadPascalString();
|
||||
if (!string.IsNullOrEmpty(serverName) && serverName.Length < 100 && !serverName.StartsWith("."))
|
||||
{
|
||||
variation.ServerName = serverName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_position = savePos; // Rewind if not a valid server name
|
||||
}
|
||||
}
|
||||
|
||||
// Only return if we got meaningful data
|
||||
if (!string.IsNullOrEmpty(variation.ActionString))
|
||||
{
|
||||
return variation;
|
||||
}
|
||||
|
||||
_position = startPos;
|
||||
return null;
|
||||
}
|
||||
|
||||
private ConfigItem TryReadConfigItem()
|
||||
{
|
||||
int startPos = _position;
|
||||
|
||||
string name = ReadPascalString();
|
||||
if (string.IsNullOrEmpty(name) || name.Length > 100)
|
||||
{
|
||||
_position = startPos;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to read value
|
||||
object value = ReadValue();
|
||||
if (value == null)
|
||||
{
|
||||
_position = startPos;
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ConfigItem
|
||||
{
|
||||
Name = name,
|
||||
Value = value,
|
||||
Type = value switch
|
||||
{
|
||||
bool => ConfigValueType.Boolean,
|
||||
int => ConfigValueType.Integer,
|
||||
string => ConfigValueType.String,
|
||||
_ => ConfigValueType.Binary
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper methods for reading binary data
|
||||
|
||||
private string ReadPascalString()
|
||||
{
|
||||
if (_position + 2 > _data.Length || _data[_position] != 0x07)
|
||||
return null;
|
||||
|
||||
byte length = _data[_position + 1];
|
||||
if (_position + 2 + length > _data.Length || length == 0)
|
||||
return null;
|
||||
|
||||
string result = Encoding.UTF8.GetString(_data, _position + 2, length);
|
||||
_position += 2 + length;
|
||||
return result;
|
||||
}
|
||||
|
||||
private string ReadLengthPrefixedString()
|
||||
{
|
||||
// Simpler format: just <length_byte> <string_data> (no 0x07 marker)
|
||||
if (_position + 1 > _data.Length)
|
||||
return null;
|
||||
|
||||
byte length = _data[_position];
|
||||
if (length == 0 || length > 50 || _position + 1 + length > _data.Length)
|
||||
return null;
|
||||
|
||||
string result = Encoding.UTF8.GetString(_data, _position + 1, length);
|
||||
_position += 1 + length;
|
||||
return result;
|
||||
}
|
||||
|
||||
private string ReadActionString()
|
||||
{
|
||||
// Action strings: 07 01 40 <len_2bytes> <data>
|
||||
if (_position + 5 > _data.Length ||
|
||||
_data[_position] != 0x07 ||
|
||||
_data[_position + 1] != 0x01 ||
|
||||
_data[_position + 2] != 0x40)
|
||||
return null;
|
||||
|
||||
int length = BitConverter.ToUInt16(_data, _position + 3);
|
||||
if (_position + 5 + length > _data.Length || length > 500)
|
||||
return null;
|
||||
|
||||
string result = Encoding.UTF8.GetString(_data, _position + 5, length);
|
||||
_position += 5 + length;
|
||||
return result;
|
||||
}
|
||||
|
||||
private object ReadValue()
|
||||
{
|
||||
if (_position >= _data.Length)
|
||||
return null;
|
||||
|
||||
byte type = _data[_position];
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case 0x01: // Boolean
|
||||
if (_position + 2 <= _data.Length)
|
||||
{
|
||||
_position++;
|
||||
return _data[_position++] != 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x04: // Integer
|
||||
if (_position + 5 <= _data.Length)
|
||||
{
|
||||
_position++;
|
||||
int value = BitConverter.ToInt32(_data, _position);
|
||||
_position += 4;
|
||||
return value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x07: // String
|
||||
return ReadPascalString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SkipMetadata()
|
||||
{
|
||||
// Skip common metadata patterns (04 02 40... and 04 0a...)
|
||||
while (_position + 5 < _data.Length && _data[_position] == 0x04)
|
||||
{
|
||||
_position += 5;
|
||||
}
|
||||
}
|
||||
|
||||
private string PeekString(int length)
|
||||
{
|
||||
if (_position + length > _data.Length)
|
||||
return "";
|
||||
return Encoding.UTF8.GetString(_data, _position, length);
|
||||
}
|
||||
|
||||
private bool IsLikelySectionStart()
|
||||
{
|
||||
if (_position + 10 >= _data.Length)
|
||||
return true;
|
||||
|
||||
// Check for multiple nulls (section boundary)
|
||||
int nullCount = 0;
|
||||
for (int i = 0; i < Math.Min(10, _data.Length - _position); i++)
|
||||
{
|
||||
if (_data[_position + i] == 0x00)
|
||||
nullCount++;
|
||||
}
|
||||
|
||||
return nullCount >= 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user