Add channel memory: CH button, dropdown, editor form, smart CTCSS/RPT auto-clear

- New ChannelStore.pas: TChannel record + JSON persistence (channels.json)
- New ChannelsForm.pas: full channel editor (list + fields, pre-fills from current radio state)
- CH button in RX block: left-click opens dropdown, right-click opens editor
- Active channel: button highlights with channel name, deactivates on VFO move
- ApplyChannel: auto-switches XVTR/HF band based on channel frequency; saves old
  band state before switching to prevent cache corruption
- SaveCurrentBand: skip save when VFO > 61 MHz (prevents HF cache corruption
  from VHF channel application without XVTR)
- DeactivateXvtr: save full XVTR state (was only saving LastFreq)
- CTCSS/RPT from channel marked as auto: cleared automatically when tuning away
  from channel frequency; survives band switches via FXvtrSettings auto-flags
- TXvtrEntry: add LastCTCSSAutoActive, LastFMRptAutoActive fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 15:39:37 +03:00
co-authored by Claude Sonnet 4.6
parent d08317e835
commit 1175e42516
7 changed files with 1298 additions and 24 deletions
+272
View File
@@ -0,0 +1,272 @@
unit ChannelStore;
{ TChannelStore — хранилище каналов (channel memory).
Формат файла channels.json (рядом с hpsdr_settings.json). }
{$mode objfpc}{$H+}
interface
uses
SysUtils, Classes, Math, fpJSON, jsonparser, jsonscanner;
const
CHANNELS_FILE = 'channels.json';
type
TChannel = record
Group: string;
Name: string;
RXFreq: Double; // Hz
TXFreq: Double; // Hz
Mode: Integer; // 0=LSB 1=USB 2=DSB 3=CWL 4=CWU 5=FM 6=AM 7=SAM
RptDir: Integer; // 0=none 1=minus 2=plus
RptOffsetHz: Double; // Hz
CTCSSOn: Boolean;
CTCSSToneIdx: Integer; // 0..37
Power: Integer; // 0..100
end;
TChannelStore = class
private
FChannels: array of TChannel;
FFilePath: string;
function JI(O: TJSONObject; const K: string; Def: Integer): Integer;
function JD(O: TJSONObject; const K: string; Def: Double): Double;
function JB(O: TJSONObject; const K: string; Def: Boolean): Boolean;
function JS(O: TJSONObject; const K: string; const Def: string): string;
public
constructor Create(const AFilePath: string = CHANNELS_FILE);
procedure Load;
procedure Save;
function Count: Integer;
function GetChannel(Idx: Integer): TChannel;
procedure AddChannel(const Ch: TChannel);
procedure UpdateChannel(Idx: Integer; const Ch: TChannel);
procedure DeleteChannel(Idx: Integer);
procedure MoveUp(Idx: Integer);
procedure MoveDown(Idx: Integer);
class procedure DefaultChannel(out Ch: TChannel);
end;
implementation
class procedure TChannelStore.DefaultChannel(out Ch: TChannel);
begin
FillChar(Ch, SizeOf(Ch), 0);
Ch.Group := '';
Ch.Name := 'New Channel';
Ch.RXFreq := 145500000.0;
Ch.TXFreq := 145500000.0;
Ch.Mode := 5; // FM
Ch.RptDir := 0;
Ch.RptOffsetHz := 600000.0;
Ch.CTCSSOn := False;
Ch.CTCSSToneIdx := 0;
Ch.Power := 100;
end;
constructor TChannelStore.Create(const AFilePath: string);
begin
inherited Create;
FFilePath := AFilePath;
SetLength(FChannels, 0);
end;
function TChannelStore.Count: Integer;
begin
Result := Length(FChannels);
end;
function TChannelStore.GetChannel(Idx: Integer): TChannel;
begin
if (Idx >= 0) and (Idx < Length(FChannels)) then
Result := FChannels[Idx]
else
DefaultChannel(Result);
end;
procedure TChannelStore.AddChannel(const Ch: TChannel);
var N: Integer;
begin
N := Length(FChannels);
SetLength(FChannels, N + 1);
FChannels[N] := Ch;
end;
procedure TChannelStore.UpdateChannel(Idx: Integer; const Ch: TChannel);
begin
if (Idx >= 0) and (Idx < Length(FChannels)) then
FChannels[Idx] := Ch;
end;
procedure TChannelStore.DeleteChannel(Idx: Integer);
var i: Integer;
begin
if (Idx < 0) or (Idx >= Length(FChannels)) then Exit;
for i := Idx to Length(FChannels) - 2 do
FChannels[i] := FChannels[i + 1];
SetLength(FChannels, Length(FChannels) - 1);
end;
procedure TChannelStore.MoveUp(Idx: Integer);
var Tmp: TChannel;
begin
if (Idx < 1) or (Idx >= Length(FChannels)) then Exit;
Tmp := FChannels[Idx];
FChannels[Idx] := FChannels[Idx - 1];
FChannels[Idx - 1] := Tmp;
end;
procedure TChannelStore.MoveDown(Idx: Integer);
var Tmp: TChannel;
begin
if (Idx < 0) or (Idx >= Length(FChannels) - 1) then Exit;
Tmp := FChannels[Idx];
FChannels[Idx] := FChannels[Idx + 1];
FChannels[Idx + 1] := Tmp;
end;
// ---------------------------------------------------------------------------
// JSON helpers
// ---------------------------------------------------------------------------
function TChannelStore.JI(O: TJSONObject; const K: string; Def: Integer): Integer;
var D: TJSONData;
begin
D := O.Find(K);
if D <> nil then try Result := D.AsInteger; except Result := Def; end
else Result := Def;
end;
function TChannelStore.JD(O: TJSONObject; const K: string; Def: Double): Double;
var D: TJSONData;
begin
D := O.Find(K);
if D <> nil then try Result := D.AsFloat; except Result := Def; end
else Result := Def;
end;
function TChannelStore.JB(O: TJSONObject; const K: string; Def: Boolean): Boolean;
var D: TJSONData;
begin
D := O.Find(K);
if D <> nil then try Result := D.AsBoolean; except Result := Def; end
else Result := Def;
end;
function TChannelStore.JS(O: TJSONObject; const K: string; const Def: string): string;
var D: TJSONData;
begin
D := O.Find(K);
if D <> nil then try Result := D.AsString; except Result := Def; end
else Result := Def;
end;
// ---------------------------------------------------------------------------
// Load / Save
// ---------------------------------------------------------------------------
procedure TChannelStore.Load;
var
F: TFileStream;
P: TJSONParser;
Root: TJSONData;
Arr: TJSONData;
Item: TJSONData;
O: TJSONObject;
Ch: TChannel;
i: Integer;
begin
SetLength(FChannels, 0);
if not FileExists(FFilePath) then Exit;
try
F := TFileStream.Create(FFilePath, fmOpenRead or fmShareDenyNone);
try
P := TJSONParser.Create(F, [joUTF8]);
try
Root := P.Parse;
if (Root <> nil) and (Root is TJSONObject) then
begin
Arr := TJSONObject(Root).Find('channels');
if (Arr <> nil) and (Arr is TJSONArray) then
begin
SetLength(FChannels, TJSONArray(Arr).Count);
for i := 0 to TJSONArray(Arr).Count - 1 do
begin
Item := TJSONArray(Arr).Items[i];
DefaultChannel(Ch);
if Item is TJSONObject then
begin
O := TJSONObject(Item);
Ch.Group := JS(O, 'group', '');
Ch.Name := JS(O, 'name', 'Channel');
Ch.RXFreq := JD(O, 'rx_freq', 145500000.0);
Ch.TXFreq := JD(O, 'tx_freq', 145500000.0);
Ch.Mode := EnsureRange(JI(O, 'mode', 5), 0, 7);
Ch.RptDir := EnsureRange(JI(O, 'rpt_dir', 0), 0, 2);
Ch.RptOffsetHz:= JD(O, 'rpt_offset', 600000.0);
Ch.CTCSSOn := JB(O, 'ctcss_on', False);
Ch.CTCSSToneIdx := EnsureRange(JI(O, 'ctcss_idx', 0), 0, 37);
Ch.Power := EnsureRange(JI(O, 'power', 100), 0, 100);
end;
FChannels[i] := Ch;
end;
end;
Root.Free;
end;
finally
P.Free;
end;
finally
F.Free;
end;
except
SetLength(FChannels, 0);
end;
end;
procedure TChannelStore.Save;
var
F: TFileStream;
Root: TJSONObject;
Arr: TJSONArray;
Obj: TJSONObject;
i: Integer;
S: string;
begin
Root := TJSONObject.Create;
try
Arr := TJSONArray.Create;
Root.Add('channels', Arr);
for i := 0 to Length(FChannels) - 1 do
begin
Obj := TJSONObject.Create;
Obj.Add('group', FChannels[i].Group);
Obj.Add('name', FChannels[i].Name);
Obj.Add('rx_freq', FChannels[i].RXFreq);
Obj.Add('tx_freq', FChannels[i].TXFreq);
Obj.Add('mode', FChannels[i].Mode);
Obj.Add('rpt_dir', FChannels[i].RptDir);
Obj.Add('rpt_offset', FChannels[i].RptOffsetHz);
Obj.Add('ctcss_on', FChannels[i].CTCSSOn);
Obj.Add('ctcss_idx', FChannels[i].CTCSSToneIdx);
Obj.Add('power', FChannels[i].Power);
Arr.Add(Obj);
end;
try
S := Root.FormatJSON([], 2);
F := TFileStream.Create(FFilePath, fmCreate);
try
if Length(S) > 0 then F.WriteBuffer(S[1], Length(S));
finally
F.Free;
end;
except
end;
finally
Root.Free;
end;
end;
end.