diff --git a/ChannelStore.pas b/ChannelStore.pas
new file mode 100644
index 0000000..e161573
--- /dev/null
+++ b/ChannelStore.pas
@@ -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.
diff --git a/ChannelsForm.pas b/ChannelsForm.pas
new file mode 100644
index 0000000..e6390c1
--- /dev/null
+++ b/ChannelsForm.pas
@@ -0,0 +1,620 @@
+unit ChannelsForm;
+
+{ TChannelsForm — редактор списка каналов (channel memory).
+ Открывается по правой кнопке мыши на кнопке CH в блоке RX левой панели.
+ Список каналов слева (FlatListBox), поля редактирования справа.
+ Все изменения сохраняются немедленно в TChannelStore и на диск. }
+
+{$mode objfpc}{$H+}
+
+interface
+
+uses
+ Classes, SysUtils, Math,
+ Forms, Controls, Graphics, StdCtrls, ExtCtrls,
+ FlatButton, FlatCheckBox, FlatComboBox, FlatEdit,
+ FlatSpinEdit, FlatFloatSpinEdit, FlatListBox,
+ AppTheme, ChannelStore;
+
+const
+ // Копии из MainForm: нужны для выпадающих списков
+ CH_MODE_COUNT = 8;
+ CH_MODE_NAMES: array[0..CH_MODE_COUNT-1] of string =
+ ('LSB','USB','DSB','CWL','CWU','FM','AM','SAM');
+
+ CH_CTCSS_COUNT = 38;
+ CH_CTCSS_NAMES: array[0..CH_CTCSS_COUNT-1] of string = (
+ '67.0', '71.9', '74.4', '77.0', '79.7', '82.5', '85.4', '88.5',
+ '91.5', '94.8', '97.4', '100.0', '103.5', '107.2', '110.9', '114.8',
+ '118.8', '123.0', '127.3', '131.8', '136.5', '141.3', '146.2', '151.4',
+ '156.7', '162.2', '167.9', '173.8', '179.9', '186.2', '192.8', '203.5',
+ '210.7', '218.1', '225.7', '233.6', '241.8', '250.3');
+
+type
+ TChannelsForm = class(TForm)
+ private
+ // --- Левая панель ---
+ FPanelLeft: TPanel;
+ FBtnAdd: TFlatButton;
+ FBtnDelete: TFlatButton;
+ FBtnUp: TFlatButton;
+ FBtnDown: TFlatButton;
+ FList: TFlatListBox;
+
+ // --- Правая панель (поля редактирования) ---
+ FPanelRight: TPanel;
+
+ // Labels (для перекраски темой)
+ FLbls: array of TLabel;
+
+ // Поля
+ FEdGroup: TFlatEdit;
+ FEdName: TFlatEdit;
+ FSpinRXFreq: TFlatFloatSpinEdit;
+ FSpinTXFreq: TFlatFloatSpinEdit;
+ FCmbMode: TFlatComboBox;
+ FBtnRptNone: TFlatButton;
+ FBtnRptMinus: TFlatButton;
+ FBtnRptPlus: TFlatButton;
+ FSpinRptOffset: TFlatFloatSpinEdit;
+ FChkCTCSS: TFlatCheckBox;
+ FCmbCTCSS: TFlatComboBox;
+ FSpinPower: TFlatSpinEdit;
+
+ // Данные
+ FStore: TChannelStore;
+ FUpdating: Boolean;
+ FOnChanged: TNotifyEvent;
+ FCurrentDefaults: TChannel; // значения по умолчанию для новых каналов
+
+ procedure BuildUI;
+ procedure StyleBtn(B: TFlatButton; AActive: Boolean = False);
+ function MakeLabel(AParent: TWinControl; const Cap: string;
+ X, Y, W, H: Integer): TLabel;
+ procedure LoadChannelToFields(Idx: Integer);
+ procedure SaveFieldsToChannel;
+ procedure RefreshList(KeepIdx: Integer = -1);
+ function SelectedIdx: Integer;
+
+ // Handlers — список
+ procedure OnListClick(Sender: TObject);
+ procedure OnListDblClick(Sender: TObject);
+ // Handlers — тулбар
+ procedure OnAddClick(Sender: TObject);
+ procedure OnDeleteClick(Sender: TObject);
+ procedure OnUpClick(Sender: TObject);
+ procedure OnDownClick(Sender: TObject);
+ // Handlers — поля
+ procedure OnFieldChange(Sender: TObject);
+ procedure OnRptBtnClick(Sender: TObject);
+ procedure OnCTCSSChkChange(Sender: TObject);
+ procedure OnCTCSSCmbChange(Sender: TObject);
+ // Handlers — форма
+ procedure OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
+ public
+ constructor Create(AOwner: TComponent; AStore: TChannelStore;
+ AOnChanged: TNotifyEvent); reintroduce;
+ procedure ApplyTheme(const T: TAppTheme);
+ procedure SetCurrentDefaults(const Ch: TChannel);
+ property OnChannelsChanged: TNotifyEvent read FOnChanged write FOnChanged;
+ end;
+
+implementation
+
+const
+ // Геометрия
+ LPANEL_W = 185; // ширина левой панели
+ TOOLBAR_H = 32; // высота тулбара с кнопками Add/Del/Up/Down
+ BTN_H = 24;
+
+ // Правая панель
+ LBL_X = 8;
+ LBL_W = 95;
+ CTRL_X = 106;
+ ROW_H = 30; // высота строки
+ CTRL_H = 24; // высота контрола
+
+constructor TChannelsForm.Create(AOwner: TComponent; AStore: TChannelStore;
+ AOnChanged: TNotifyEvent);
+begin
+ inherited CreateNew(AOwner);
+ FStore := AStore;
+ FOnChanged := AOnChanged;
+ FUpdating := False;
+ TChannelStore.DefaultChannel(FCurrentDefaults);
+
+ Caption := 'Channels';
+ BorderStyle := bsSizeable;
+ Width := 720;
+ Height := 480;
+ Position := poScreenCenter;
+ KeyPreview := True;
+ OnClose := @OnFormClose;
+
+ BuildUI;
+ ApplyTheme(DarkTheme);
+ RefreshList(0);
+end;
+
+procedure TChannelsForm.BuildUI;
+var
+ i: Integer;
+ BtnW: Integer;
+ RW: Integer; // ширина правой панели
+ Y: Integer;
+begin
+ // ----- Левая панель -----
+ FPanelLeft := TPanel.Create(Self);
+ FPanelLeft.Parent := Self;
+ FPanelLeft.Align := alLeft;
+ FPanelLeft.Width := LPANEL_W;
+ FPanelLeft.BevelOuter := bvNone;
+
+ BtnW := (LPANEL_W - 4 - 3 * 2) div 4; // 4 кнопки с зазорами
+ FBtnAdd := TFlatButton.Create(Self);
+ FBtnAdd.Parent := FPanelLeft;
+ FBtnAdd.SetBounds(2, 4, BtnW, BTN_H);
+ FBtnAdd.Caption := 'ADD';
+ FBtnAdd.OnClick := @OnAddClick;
+
+ FBtnDelete := TFlatButton.Create(Self);
+ FBtnDelete.Parent := FPanelLeft;
+ FBtnDelete.SetBounds(2 + (BtnW + 2), 4, BtnW, BTN_H);
+ FBtnDelete.Caption := 'DEL';
+ FBtnDelete.OnClick := @OnDeleteClick;
+
+ FBtnUp := TFlatButton.Create(Self);
+ FBtnUp.Parent := FPanelLeft;
+ FBtnUp.SetBounds(2 + 2 * (BtnW + 2), 4, BtnW, BTN_H);
+ FBtnUp.Caption := '↑';
+ FBtnUp.OnClick := @OnUpClick;
+
+ FBtnDown := TFlatButton.Create(Self);
+ FBtnDown.Parent := FPanelLeft;
+ FBtnDown.SetBounds(2 + 3 * (BtnW + 2), 4, BtnW, BTN_H);
+ FBtnDown.Caption := '↓';
+ FBtnDown.OnClick := @OnDownClick;
+
+ FList := TFlatListBox.Create(Self);
+ FList.Parent := FPanelLeft;
+ FList.SetBounds(0, TOOLBAR_H, LPANEL_W, FPanelLeft.Height - TOOLBAR_H);
+ FList.Anchors := [akLeft, akTop, akRight, akBottom];
+ FList.OnClick := @OnListClick;
+ FList.OnDblClick := @OnListDblClick;
+
+ // ----- Правая панель -----
+ FPanelRight := TPanel.Create(Self);
+ FPanelRight.Parent := Self;
+ FPanelRight.Align := alClient;
+ FPanelRight.BevelOuter := bvNone;
+
+ SetLength(FLbls, 0);
+ RW := Width - LPANEL_W;
+
+ Y := 8;
+
+ // Group
+ MakeLabel(FPanelRight, 'Group', LBL_X, Y + 1, LBL_W, CTRL_H);
+ FEdGroup := TFlatEdit.Create(Self);
+ FEdGroup.Parent := FPanelRight;
+ FEdGroup.SetBounds(CTRL_X, Y, RW - CTRL_X - 8, CTRL_H);
+ FEdGroup.Anchors := [akLeft, akTop, akRight];
+ FEdGroup.OnChange := @OnFieldChange;
+ Inc(Y, ROW_H);
+
+ // Name
+ MakeLabel(FPanelRight, 'Name', LBL_X, Y + 1, LBL_W, CTRL_H);
+ FEdName := TFlatEdit.Create(Self);
+ FEdName.Parent := FPanelRight;
+ FEdName.SetBounds(CTRL_X, Y, RW - CTRL_X - 8, CTRL_H);
+ FEdName.Anchors := [akLeft, akTop, akRight];
+ FEdName.OnChange := @OnFieldChange;
+ Inc(Y, ROW_H);
+
+ // RX Freq
+ MakeLabel(FPanelRight, 'RX Freq', LBL_X, Y + 1, LBL_W, CTRL_H);
+ FSpinRXFreq := TFlatFloatSpinEdit.Create(Self);
+ FSpinRXFreq.Parent := FPanelRight;
+ FSpinRXFreq.SetBounds(CTRL_X, Y, RW - CTRL_X - 50, CTRL_H);
+ FSpinRXFreq.Anchors := [akLeft, akTop, akRight];
+ FSpinRXFreq.MinValue := 0.001;
+ FSpinRXFreq.MaxValue := 2000.0;
+ FSpinRXFreq.Increment := 0.001;
+ FSpinRXFreq.DecimalPlaces := 6;
+ FSpinRXFreq.Value := 145.5;
+ FSpinRXFreq.OnChange := @OnFieldChange;
+ MakeLabel(FPanelRight, 'MHz', RW - 46, Y + 1, 40, CTRL_H);
+ Inc(Y, ROW_H);
+
+ // TX Freq
+ MakeLabel(FPanelRight, 'TX Freq', LBL_X, Y + 1, LBL_W, CTRL_H);
+ FSpinTXFreq := TFlatFloatSpinEdit.Create(Self);
+ FSpinTXFreq.Parent := FPanelRight;
+ FSpinTXFreq.SetBounds(CTRL_X, Y, RW - CTRL_X - 50, CTRL_H);
+ FSpinTXFreq.Anchors := [akLeft, akTop, akRight];
+ FSpinTXFreq.MinValue := 0.001;
+ FSpinTXFreq.MaxValue := 2000.0;
+ FSpinTXFreq.Increment := 0.001;
+ FSpinTXFreq.DecimalPlaces := 6;
+ FSpinTXFreq.Value := 145.5;
+ FSpinTXFreq.OnChange := @OnFieldChange;
+ MakeLabel(FPanelRight, 'MHz', RW - 46, Y + 1, 40, CTRL_H);
+ Inc(Y, ROW_H);
+
+ // Mode
+ MakeLabel(FPanelRight, 'Mode', LBL_X, Y + 1, LBL_W, CTRL_H);
+ FCmbMode := TFlatComboBox.Create(Self);
+ FCmbMode.Parent := FPanelRight;
+ FCmbMode.SetBounds(CTRL_X, Y, 160, CTRL_H);
+ for i := 0 to CH_MODE_COUNT - 1 do FCmbMode.Items.Add(CH_MODE_NAMES[i]);
+ FCmbMode.ItemIndex := 5; // FM default
+ FCmbMode.OnChange := @OnFieldChange;
+ Inc(Y, ROW_H);
+
+ // RPTR direction
+ MakeLabel(FPanelRight, 'RPTR', LBL_X, Y + 1, LBL_W, CTRL_H);
+ FBtnRptNone := TFlatButton.Create(Self);
+ FBtnRptNone.Parent := FPanelRight;
+ FBtnRptNone.SetBounds(CTRL_X, Y, 55, CTRL_H);
+ FBtnRptNone.Caption := 'NONE';
+ FBtnRptNone.Tag := 0;
+ FBtnRptNone.OnClick := @OnRptBtnClick;
+
+ FBtnRptMinus := TFlatButton.Create(Self);
+ FBtnRptMinus.Parent := FPanelRight;
+ FBtnRptMinus.SetBounds(CTRL_X + 58, Y, 38, CTRL_H);
+ FBtnRptMinus.Caption := '−';
+ FBtnRptMinus.Tag := 1;
+ FBtnRptMinus.OnClick := @OnRptBtnClick;
+
+ FBtnRptPlus := TFlatButton.Create(Self);
+ FBtnRptPlus.Parent := FPanelRight;
+ FBtnRptPlus.SetBounds(CTRL_X + 99, Y, 38, CTRL_H);
+ FBtnRptPlus.Caption := '+';
+ FBtnRptPlus.Tag := 2;
+ FBtnRptPlus.OnClick := @OnRptBtnClick;
+ Inc(Y, ROW_H);
+
+ // RPTR Offset
+ MakeLabel(FPanelRight, 'RPT Offset', LBL_X, Y + 1, LBL_W, CTRL_H);
+ FSpinRptOffset := TFlatFloatSpinEdit.Create(Self);
+ FSpinRptOffset.Parent := FPanelRight;
+ FSpinRptOffset.SetBounds(CTRL_X, Y, RW - CTRL_X - 50, CTRL_H);
+ FSpinRptOffset.Anchors := [akLeft, akTop, akRight];
+ FSpinRptOffset.MinValue := 0.0;
+ FSpinRptOffset.MaxValue := 100.0;
+ FSpinRptOffset.Increment := 0.025;
+ FSpinRptOffset.DecimalPlaces := 3;
+ FSpinRptOffset.Value := 0.6;
+ FSpinRptOffset.OnChange := @OnFieldChange;
+ MakeLabel(FPanelRight, 'MHz', RW - 46, Y + 1, 40, CTRL_H);
+ Inc(Y, ROW_H);
+
+ // CTCSS
+ MakeLabel(FPanelRight, 'CTCSS', LBL_X, Y + 1, LBL_W, CTRL_H);
+ FChkCTCSS := TFlatCheckBox.Create(Self);
+ FChkCTCSS.Parent := FPanelRight;
+ FChkCTCSS.SetBounds(CTRL_X, Y, 26, CTRL_H);
+ FChkCTCSS.Caption := '';
+ FChkCTCSS.Checked := False;
+ FChkCTCSS.OnChange := @OnCTCSSChkChange;
+
+ FCmbCTCSS := TFlatComboBox.Create(Self);
+ FCmbCTCSS.Parent := FPanelRight;
+ FCmbCTCSS.SetBounds(CTRL_X + 30, Y, RW - CTRL_X - 38, CTRL_H);
+ FCmbCTCSS.Anchors := [akLeft, akTop, akRight];
+ for i := 0 to CH_CTCSS_COUNT - 1 do FCmbCTCSS.Items.Add(CH_CTCSS_NAMES[i]);
+ FCmbCTCSS.ItemIndex := 0;
+ FCmbCTCSS.OnChange := @OnCTCSSCmbChange;
+ Inc(Y, ROW_H);
+
+ // Power
+ MakeLabel(FPanelRight, 'Power', LBL_X, Y + 1, LBL_W, CTRL_H);
+ FSpinPower := TFlatSpinEdit.Create(Self);
+ FSpinPower.Parent := FPanelRight;
+ FSpinPower.SetBounds(CTRL_X, Y, 80, CTRL_H);
+ FSpinPower.MinValue := 0;
+ FSpinPower.MaxValue := 100;
+ FSpinPower.Value := 100;
+ FSpinPower.OnChange := @OnFieldChange;
+ MakeLabel(FPanelRight, '%', CTRL_X + 84, Y + 1, 20, CTRL_H);
+end;
+
+function TChannelsForm.MakeLabel(AParent: TWinControl; const Cap: string;
+ X, Y, W, H: Integer): TLabel;
+var L: TLabel;
+begin
+ L := TLabel.Create(Self);
+ L.Parent := AParent;
+ L.Caption := Cap;
+ L.SetBounds(X, Y, W, H);
+ L.Font.Name := 'Courier New';
+ L.Font.Size := 8;
+ L.Font.Color := DarkTheme.TextDim;
+ // Сохраняем для последующей перекраски темой
+ SetLength(FLbls, Length(FLbls) + 1);
+ FLbls[High(FLbls)] := L;
+ Result := L;
+end;
+
+procedure TChannelsForm.StyleBtn(B: TFlatButton; AActive: Boolean);
+var T: TAppTheme;
+begin
+ T := DarkTheme;
+ B.Active := AActive;
+ B.ClrNorm := T.BtnNorm;
+ B.ClrActive := T.BtnActive;
+ B.ClrHot := T.BtnHot;
+ B.ClrBorder := IfThen(AActive, T.BtnBorderActive, T.BtnBorderNorm);
+ B.ClrText := T.BtnText;
+ B.ClrTextAct := T.BtnTextActive;
+end;
+
+procedure TChannelsForm.ApplyTheme(const T: TAppTheme);
+var
+ i: Integer;
+ Ch: TChannel;
+begin
+ Color := T.Panel;
+ FPanelLeft.Color := T.Panel;
+ FPanelRight.Color := T.Panel;
+
+ FList.SetAppTheme(T);
+
+ StyleBtn(FBtnAdd);
+ StyleBtn(FBtnDelete);
+ StyleBtn(FBtnUp);
+ StyleBtn(FBtnDown);
+
+ // RPT buttons — пересчитываем Active из текущего состояния
+ if SelectedIdx >= 0 then
+ begin
+ Ch := FStore.GetChannel(SelectedIdx);
+ StyleBtn(FBtnRptNone, Ch.RptDir = 0);
+ StyleBtn(FBtnRptMinus, Ch.RptDir = 1);
+ StyleBtn(FBtnRptPlus, Ch.RptDir = 2);
+ end
+ else
+ begin
+ StyleBtn(FBtnRptNone, True);
+ StyleBtn(FBtnRptMinus, False);
+ StyleBtn(FBtnRptPlus, False);
+ end;
+
+ FEdGroup.SetAppTheme(T);
+ FEdName.SetAppTheme(T);
+ FSpinRXFreq.SetAppTheme(T);
+ FSpinTXFreq.SetAppTheme(T);
+ FCmbMode.SetAppTheme(T);
+ FSpinRptOffset.SetAppTheme(T);
+ FChkCTCSS.SetAppTheme(T);
+ FCmbCTCSS.SetAppTheme(T);
+ FSpinPower.SetAppTheme(T);
+
+ for i := 0 to High(FLbls) do
+ begin
+ FLbls[i].Font.Color := T.TextDim;
+ FLbls[i].Color := T.Panel;
+ FLbls[i].ParentColor := False;
+ end;
+end;
+
+// ---------------------------------------------------------------------------
+// Список каналов
+// ---------------------------------------------------------------------------
+
+function TChannelsForm.SelectedIdx: Integer;
+begin
+ Result := FList.ItemIndex;
+end;
+
+procedure TChannelsForm.RefreshList(KeepIdx: Integer);
+var
+ i, NewIdx: Integer;
+ Ch: TChannel;
+begin
+ FList.Items.Clear;
+ for i := 0 to FStore.Count - 1 do
+ begin
+ Ch := FStore.GetChannel(i);
+ if Ch.Group <> '' then
+ FList.Items.Add(Ch.Group + ': ' + Ch.Name)
+ else
+ FList.Items.Add(Ch.Name);
+ end;
+ if FStore.Count = 0 then
+ begin
+ LoadChannelToFields(-1);
+ Exit;
+ end;
+ NewIdx := EnsureRange(KeepIdx, 0, FStore.Count - 1);
+ FList.ItemIndex := NewIdx;
+ LoadChannelToFields(NewIdx);
+end;
+
+procedure TChannelsForm.LoadChannelToFields(Idx: Integer);
+var Ch: TChannel;
+ HasSel: Boolean;
+begin
+ FUpdating := True;
+ try
+ HasSel := (Idx >= 0) and (Idx < FStore.Count);
+ FEdGroup.Enabled := HasSel;
+ FEdName.Enabled := HasSel;
+ FSpinRXFreq.Enabled := HasSel;
+ FSpinTXFreq.Enabled := HasSel;
+ FCmbMode.Enabled := HasSel;
+ FBtnRptNone.Enabled := HasSel;
+ FBtnRptMinus.Enabled := HasSel;
+ FBtnRptPlus.Enabled := HasSel;
+ FSpinRptOffset.Enabled := HasSel;
+ FChkCTCSS.Enabled := HasSel;
+ FCmbCTCSS.Enabled := HasSel;
+ FSpinPower.Enabled := HasSel;
+
+ if not HasSel then Exit;
+
+ Ch := FStore.GetChannel(Idx);
+ FEdGroup.Text := Ch.Group;
+ FEdName.Text := Ch.Name;
+ FSpinRXFreq.Value := Ch.RXFreq / 1000000.0;
+ FSpinTXFreq.Value := Ch.TXFreq / 1000000.0;
+ FCmbMode.ItemIndex := EnsureRange(Ch.Mode, 0, CH_MODE_COUNT - 1);
+ StyleBtn(FBtnRptNone, Ch.RptDir = 0);
+ StyleBtn(FBtnRptMinus, Ch.RptDir = 1);
+ StyleBtn(FBtnRptPlus, Ch.RptDir = 2);
+ FSpinRptOffset.Value := Ch.RptOffsetHz / 1000000.0;
+ FChkCTCSS.Checked := Ch.CTCSSOn;
+ FCmbCTCSS.ItemIndex := EnsureRange(Ch.CTCSSToneIdx, 0, CH_CTCSS_COUNT - 1);
+ FSpinPower.Value := Ch.Power;
+ finally
+ FUpdating := False;
+ end;
+end;
+
+procedure TChannelsForm.SaveFieldsToChannel;
+var
+ Idx: Integer;
+ Ch: TChannel;
+ i: Integer;
+begin
+ if FUpdating then Exit;
+ Idx := SelectedIdx;
+ if (Idx < 0) or (Idx >= FStore.Count) then Exit;
+
+ Ch := FStore.GetChannel(Idx);
+ Ch.Group := FEdGroup.Text;
+ Ch.Name := FEdName.Text;
+ Ch.RXFreq := FSpinRXFreq.Value * 1000000.0;
+ Ch.TXFreq := FSpinTXFreq.Value * 1000000.0;
+ Ch.Mode := EnsureRange(FCmbMode.ItemIndex, 0, CH_MODE_COUNT - 1);
+ // RptDir берётся из кнопок (Active)
+ if FBtnRptMinus.Active then Ch.RptDir := 1
+ else if FBtnRptPlus.Active then Ch.RptDir := 2
+ else Ch.RptDir := 0;
+ Ch.RptOffsetHz := FSpinRptOffset.Value * 1000000.0;
+ Ch.CTCSSOn := FChkCTCSS.Checked;
+ Ch.CTCSSToneIdx := EnsureRange(FCmbCTCSS.ItemIndex, 0, CH_CTCSS_COUNT - 1);
+ Ch.Power := FSpinPower.Value;
+
+ FStore.UpdateChannel(Idx, Ch);
+ FStore.Save;
+
+ // Обновляем имя в списке
+ FUpdating := True;
+ try
+ if Ch.Group <> '' then
+ FList.Items[Idx] := Ch.Group + ': ' + Ch.Name
+ else
+ FList.Items[Idx] := Ch.Name;
+ finally
+ FUpdating := False;
+ end;
+
+ if Assigned(FOnChanged) then FOnChanged(Self);
+end;
+
+// ---------------------------------------------------------------------------
+// Обработчики кнопок тулбара
+// ---------------------------------------------------------------------------
+
+procedure TChannelsForm.OnListClick(Sender: TObject);
+begin
+ if FUpdating then Exit;
+ LoadChannelToFields(FList.ItemIndex);
+end;
+
+procedure TChannelsForm.OnListDblClick(Sender: TObject);
+begin
+ LoadChannelToFields(FList.ItemIndex);
+end;
+
+procedure TChannelsForm.OnAddClick(Sender: TObject);
+var
+ Ch: TChannel;
+ NewIdx: Integer;
+begin
+ Ch := FCurrentDefaults; // берём значения текущего состояния радио
+ FStore.AddChannel(Ch);
+ FStore.Save;
+ NewIdx := FStore.Count - 1;
+ RefreshList(NewIdx);
+ if Assigned(FOnChanged) then FOnChanged(Self);
+ // Фокус на имя для быстрого переименования
+ if FEdName.Enabled then FEdName.SetFocus;
+ FEdName.SelectAll;
+end;
+
+procedure TChannelsForm.OnDeleteClick(Sender: TObject);
+var Idx: Integer;
+begin
+ Idx := SelectedIdx;
+ if (Idx < 0) or (Idx >= FStore.Count) then Exit;
+ FStore.DeleteChannel(Idx);
+ FStore.Save;
+ RefreshList(EnsureRange(Idx, 0, FStore.Count - 1));
+ if Assigned(FOnChanged) then FOnChanged(Self);
+end;
+
+procedure TChannelsForm.OnUpClick(Sender: TObject);
+var Idx: Integer;
+begin
+ Idx := SelectedIdx;
+ if Idx < 1 then Exit;
+ FStore.MoveUp(Idx);
+ FStore.Save;
+ RefreshList(Idx - 1);
+ if Assigned(FOnChanged) then FOnChanged(Self);
+end;
+
+procedure TChannelsForm.OnDownClick(Sender: TObject);
+var Idx: Integer;
+begin
+ Idx := SelectedIdx;
+ if (Idx < 0) or (Idx >= FStore.Count - 1) then Exit;
+ FStore.MoveDown(Idx);
+ FStore.Save;
+ RefreshList(Idx + 1);
+ if Assigned(FOnChanged) then FOnChanged(Self);
+end;
+
+// ---------------------------------------------------------------------------
+// Обработчики полей редактирования
+// ---------------------------------------------------------------------------
+
+procedure TChannelsForm.OnFieldChange(Sender: TObject);
+begin
+ SaveFieldsToChannel;
+end;
+
+procedure TChannelsForm.OnRptBtnClick(Sender: TObject);
+var Dir: Integer;
+begin
+ Dir := (Sender as TFlatButton).Tag;
+ StyleBtn(FBtnRptNone, Dir = 0);
+ StyleBtn(FBtnRptMinus, Dir = 1);
+ StyleBtn(FBtnRptPlus, Dir = 2);
+ SaveFieldsToChannel;
+end;
+
+procedure TChannelsForm.OnCTCSSChkChange(Sender: TObject);
+begin
+ SaveFieldsToChannel;
+end;
+
+procedure TChannelsForm.OnCTCSSCmbChange(Sender: TObject);
+begin
+ SaveFieldsToChannel;
+end;
+
+procedure TChannelsForm.OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
+begin
+ CloseAction := caHide; // скрываем, не уничтожаем — указатель в MainForm остаётся валидным
+end;
+
+procedure TChannelsForm.SetCurrentDefaults(const Ch: TChannel);
+begin
+ FCurrentDefaults := Ch;
+end;
+
+end.
diff --git a/FMRepeater.pas b/FMRepeater.pas
new file mode 100644
index 0000000..d86e7f4
--- /dev/null
+++ b/FMRepeater.pas
@@ -0,0 +1,79 @@
+unit FMRepeater;
+
+{ FM repeater offset helpers — constants, default offsets, auto-direction logic. }
+
+{$mode objfpc}{$H+}
+
+interface
+
+const
+ RPT_NONE = 0;
+ RPT_MINUS = 1;
+ RPT_PLUS = 2;
+
+ RPT_2M_BEGIN = 144000000.0;
+ RPT_2M_END = 148000000.0;
+ RPT_70CM_BEGIN = 430000000.0;
+ RPT_70CM_END = 440000000.0;
+ RPT_DEFAULT_2M = 600000.0; // 0.600 MHz
+ RPT_DEFAULT_70CM = 7600000.0; // 7.600 MHz
+
+ // European 2m repeater input segment: auto-enable minus direction
+ RPT_AUTO_MINUS_LO = 145600000.0; // 145.600 MHz
+ RPT_AUTO_MINUS_HI = 145787500.0; // 145.7875 MHz
+
+{ Returns default offset (Hz) for the given visible frequency.
+ 70 cm band → 7.600 MHz; everything else → 0.600 MHz. }
+function RptDefaultOffsetHz(FreqHz: Double): Double;
+
+{ Returns RPT_MINUS when FreqHz falls in the 2 m auto-minus segment,
+ RPT_NONE otherwise. }
+function RptAutoDir(FreqHz: Double): Integer;
+
+{ Formats OffsetHz as MHz with 3 decimal places, e.g. "0.600". }
+function RptFormatOffset(OffsetHz: Double): string;
+
+{ Parses a MHz string ("0.600", "7.6", "7,600") to Hz.
+ Returns 0 on parse error or out-of-range value. }
+function RptParseOffset(const S: string): Double;
+
+implementation
+
+uses SysUtils;
+
+function RptDefaultOffsetHz(FreqHz: Double): Double;
+begin
+ if (FreqHz >= RPT_70CM_BEGIN) and (FreqHz <= RPT_70CM_END) then
+ Result := RPT_DEFAULT_70CM
+ else
+ Result := RPT_DEFAULT_2M;
+end;
+
+function RptAutoDir(FreqHz: Double): Integer;
+begin
+ if (FreqHz >= RPT_AUTO_MINUS_LO) and (FreqHz <= RPT_AUTO_MINUS_HI) then
+ Result := RPT_MINUS
+ else
+ Result := RPT_NONE;
+end;
+
+function RptFormatOffset(OffsetHz: Double): string;
+begin
+ Result := Format('%.3f', [OffsetHz / 1000000.0]);
+end;
+
+function RptParseOffset(const S: string): Double;
+var
+ V: Double;
+ Code: Integer;
+ Norm: string;
+begin
+ Norm := StringReplace(Trim(S), ',', '.', [rfReplaceAll]);
+ Val(Norm, V, Code);
+ if (Code = 0) and (V > 0.0) and (V <= 100.0) then
+ Result := V * 1000000.0
+ else
+ Result := 0.0;
+end;
+
+end.
diff --git a/FlatButton.pas b/FlatButton.pas
index 363481f..ec6a228 100644
--- a/FlatButton.pas
+++ b/FlatButton.pas
@@ -42,7 +42,9 @@ type
property ClrBorder: TColor read FClrBorder write FClrBorder;
property ClrText: TColor read FClrText write FClrText;
property ClrTextAct:TColor read FClrTextAct write FClrTextAct;
- property OnClick: TNotifyEvent read FOnClick write FOnClick;
+ property OnClick: TNotifyEvent read FOnClick write FOnClick;
+ property OnMouseDown;
+ property OnMouseUp;
property Caption;
property Font;
property Enabled;
diff --git a/MainForm.pas b/MainForm.pas
index 65f159c..cd955b4 100644
--- a/MainForm.pas
+++ b/MainForm.pas
@@ -38,7 +38,8 @@ uses
PlatformUtils,
WinFirewall,
BoardUtils, WisdomBuilder, UISync,
- FlatEdit, FMRepeater;
+ FlatEdit, FMRepeater,
+ ChannelStore, ChannelsForm;
const
CLR_BG = TColor($00101010);
@@ -257,7 +258,8 @@ type
FFMStepIdx: Integer;
FFMRptDir: Integer; // RPT_NONE / RPT_MINUS / RPT_PLUS
FFMRptOffsetHz: Double;
- FFMRptAutoActive: Boolean; // True = текущий minus включён автоматически
+ FFMRptAutoActive: Boolean; // True = RPT включён автоматически (авто или из канала)
+ FFMCTCSSAutoActive: Boolean; // True = CTCSS включён из канала (не вручную)
FWfManualHigh: Double;
FWfManualLow: Double;
FWfAGCOffset: Double;
@@ -403,6 +405,11 @@ type
BtnTUN: TFlatButton;
PanelRXBlock: TPanel;
+ BtnChannels: TFlatButton;
+ FChannelsDropDown: TFlatDropDown;
+ FChannelStore: TChannelStore;
+ FChannelsForm: TObject; // TChannelsForm (cast при использовании)
+ FActiveChannelIdx: Integer; // -1 = нет активного канала
// ---- Right panel ----
PanelRight: TPanel;
@@ -517,6 +524,14 @@ type
procedure BtnFMStepSelClick(Sender: TObject);
procedure OnCTCSSDropDownSelect(Sender: TObject; Idx: Integer);
procedure OnStepDropDownSelect(Sender: TObject; Idx: Integer);
+ procedure BtnChannelsClick(Sender: TObject);
+ procedure BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+ procedure OnChannelsDropDownSelect(Sender: TObject; Idx: Integer);
+ procedure RefreshChannelsDropDown;
+ procedure OnChannelStoreChanged(Sender: TObject);
+ procedure ApplyChannel(const Ch: TChannel);
+ procedure CheckChannelActive;
procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
@@ -974,15 +989,20 @@ begin
FXvtrSettings.Entries[FCurrentXvtr].LastCTCSSToneIdx := FFMCTCSSToneIdx;
FXvtrSettings.Entries[FCurrentXvtr].LastFMStepOn := FFMStepOn;
FXvtrSettings.Entries[FCurrentXvtr].LastFMStepIdx := FFMStepIdx;
- FXvtrSettings.Entries[FCurrentXvtr].LastFMRptDir := FFMRptDir;
- FXvtrSettings.Entries[FCurrentXvtr].LastFMRptOffsetHz := FFMRptOffsetHz;
- FXvtrSettings.Entries[FCurrentXvtr].LastCTun := FCTun;
- FXvtrSettings.Entries[FCurrentXvtr].LastAGCMode := FAGCMode;
- FXvtrSettings.Entries[FCurrentXvtr].LastAGCTop := FAGCTop;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMRptDir := FFMRptDir;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMRptOffsetHz := FFMRptOffsetHz;
+ FXvtrSettings.Entries[FCurrentXvtr].LastCTun := FCTun;
+ FXvtrSettings.Entries[FCurrentXvtr].LastAGCMode := FAGCMode;
+ FXvtrSettings.Entries[FCurrentXvtr].LastAGCTop := FAGCTop;
+ FXvtrSettings.Entries[FCurrentXvtr].LastCTCSSAutoActive := FFMCTCSSAutoActive;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMRptAutoActive := FFMRptAutoActive;
end;
FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
Exit;
end;
+ // Не сохраняем, если VFO ушёл в VHF/UHF область (например, после применения
+ // канала на 2м без XVTR) — иначе HF-кэш диапазона будет перезаписан VHF-частотой.
+ if FVfoA > 61000000 then Exit;
FBandCache[FCurrentBand] := MakeBandSettings;
FSettings.SaveBand(FDevMAC, FCurrentBand, FBandCache[FCurrentBand]);
end;
@@ -1135,9 +1155,10 @@ begin
FFMSQLevel := 30;
FFMStepOn := True;
FFMStepIdx := FM_STEP_DEF;
- FFMRptDir := RPT_NONE;
- FFMRptOffsetHz := RPT_DEFAULT_2M;
- FFMRptAutoActive := False;
+ FFMRptDir := RPT_NONE;
+ FFMRptOffsetHz := RPT_DEFAULT_2M;
+ FFMRptAutoActive := False;
+ FFMCTCSSAutoActive := False;
FSpectrumBufCount := 1024;
FWaterfallBufCount := 1024;
FWaterfallFrameInterval := 2;
@@ -1152,6 +1173,8 @@ begin
FTXSpecRange := 80.0;
FTXSpecGridStep := 10.0;
FSettingsForm := nil;
+ FChannelsForm := nil;
+ FActiveChannelIdx := -1;
FillChar(FDevMAC, SizeOf(FDevMAC), 0);
// Инициализируем кэш диапазонов умолчаниями
@@ -1162,6 +1185,10 @@ begin
// Загружаем JSON настройки
FSettings := TSettingsManager.Create;
FSettings.Load;
+
+ // Загружаем список каналов
+ FChannelStore := TChannelStore.Create(CHANNELS_FILE);
+ FChannelStore.Load;
if FSettings.LoadStartupPreview(StartupVfoA, StartupVfoB, StartupRate) then
begin
FVfoA := StartupVfoA;
@@ -1411,6 +1438,7 @@ begin
FSettings.SaveStartupPreview(FVfoA, FVfoB, FSampleRate);
FSettings.Save;
FSettings.Free;
+ FreeAndNil(FChannelStore);
FWebServer.Stop;
FWebServer.Free;
if Assigned(FCATTcp) then begin FCATTcp.Stop; FreeAndNil(FCATTcp); end;
@@ -1719,6 +1747,17 @@ begin
BtnCTun.Tag := 0;
StyleButton(BtnCTun, FCTun);
+ BtnChannels := MakeBtn(PanelRXBlock, 'Channel',
+ LeftPanelButtonLeft(LEFT_W, 3, 1), 16,
+ LeftPanelButtonWidth(LEFT_W, 3, 1),
+ BTN_H, BtnChannelsClick);
+ BtnChannels.OnMouseDown := BtnChannelsMouseDown;
+ StyleButton(BtnChannels, False);
+
+ FChannelsDropDown := TFlatDropDown.Create(BtnChannels, PanelLeft, 1, BTN_SM);
+ FChannelsDropDown.OnSelect := OnChannelsDropDownSelect;
+ RefreshChannelsDropDown;
+
MakeLbl(PanelRXBlock, 'VOL', 4, 50);
TrkVolume := TFlatSlider.Create(Self);
TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := 34; TrkVolume.Top := 46;
@@ -2599,6 +2638,8 @@ begin
if BtnFMRptPlus <> nil then StyleButton(BtnFMRptPlus, BtnFMRptPlus.Active);
if FCTCSSDropDown <> nil then FCTCSSDropDown.ApplyStyle(StyleButton, T.Panel);
if FStepDropDown <> nil then FStepDropDown.ApplyStyle(StyleButton, T.Panel);
+ if BtnChannels <> nil then StyleButton(BtnChannels, False);
+ if FChannelsDropDown <> nil then FChannelsDropDown.ApplyStyle(StyleButton, T.Panel);
StyleButton(BtnNR, BtnNR.Active);
StyleButton(BtnNB, BtnNB.Active);
StyleButton(BtnSNB, BtnSNB.Active);
@@ -4593,6 +4634,26 @@ begin
end;
end;
+ // Если нет активного канала, но CTCSS/RPT были установлены каналом (авто-флаги),
+ // сбрасываем их при первом же изменении частоты
+ if FActiveChannelIdx < 0 then
+ begin
+ if FFMCTCSSAutoActive then
+ begin
+ FFMCTCSSAutoActive := False;
+ FFMCTCSSOn := False;
+ if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, False);
+ if FWDSPReady then FDSPEngine.SetTXCTCSS(False, CTCSS_TONES[FFMCTCSSToneIdx]);
+ end;
+ if FFMRptAutoActive then
+ begin
+ FFMRptDir := RPT_NONE;
+ FFMRptAutoActive := False;
+ if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, False);
+ if BtnFMRptPlus <> nil then StyleButton(BtnFMRptPlus, False);
+ end;
+ end;
+
// Auto-enable repeater minus for 2 m repeater input segment
if FMode = MODE_FM then UpdateRptAutoState;
@@ -4613,6 +4674,8 @@ begin
FWaterfallDirty := True;
PbWaterfall.Invalidate;
end;
+
+ CheckChannelActive;
end;
// ---------------------------------------------------------------------------
@@ -5206,6 +5269,7 @@ procedure TMainForm.CloseCTCSSPopup;
begin
if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup;
CloseFMStepPopup;
+ if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup;
end;
procedure TMainForm.BtnFMRptMinusClick(Sender: TObject);
@@ -5299,7 +5363,8 @@ end;
procedure TMainForm.CloseFMStepPopup;
begin
- if FStepDropDown <> nil then FStepDropDown.ClosePopup;
+ if FStepDropDown <> nil then FStepDropDown.ClosePopup;
+ if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup;
end;
procedure TMainForm.SetFMStep(Idx: Integer);
@@ -5331,6 +5396,208 @@ begin
SaveCurrentBand;
end;
+// ---------------------------------------------------------------------------
+// Channels dropdown / store
+// ---------------------------------------------------------------------------
+
+procedure TMainForm.RefreshChannelsDropDown;
+var
+ names: array of string;
+ i: Integer;
+begin
+ if (FChannelStore = nil) or (FChannelsDropDown = nil) then Exit;
+ SetLength(names, FChannelStore.Count);
+ for i := 0 to FChannelStore.Count - 1 do
+ names[i] := FChannelStore.GetChannel(i).Name;
+ FChannelsDropDown.SetItems(names);
+ if (FActiveChannelIdx >= 0) and (FActiveChannelIdx < FChannelStore.Count) then
+ FChannelsDropDown.SetItemIndex(FActiveChannelIdx)
+ else if BtnChannels <> nil then
+ BtnChannels.Caption := 'Channel';
+end;
+
+procedure TMainForm.OnChannelStoreChanged(Sender: TObject);
+begin
+ RefreshChannelsDropDown;
+end;
+
+procedure TMainForm.BtnChannelsClick(Sender: TObject);
+begin
+ if FChannelStore.Count = 0 then Exit;
+ FCTCSSDropDown.ClosePopup;
+ FStepDropDown.ClosePopup;
+ FChannelsDropDown.Toggle;
+end;
+
+procedure TMainForm.BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+var
+ Def: TChannel;
+begin
+ if Button <> mbRight then Exit;
+ FChannelsDropDown.ClosePopup;
+ if FChannelsForm = nil then
+ FChannelsForm := TChannelsForm.Create(Self, FChannelStore, OnChannelStoreChanged);
+ // Передаём текущее состояние радио как значения по умолчанию для нового канала
+ TChannelStore.DefaultChannel(Def);
+ Def.RXFreq := FVfoA;
+ Def.TXFreq := ActiveTXFreqHz;
+ Def.Mode := FMode;
+ Def.RptDir := FFMRptDir;
+ Def.RptOffsetHz := FFMRptOffsetHz;
+ Def.CTCSSOn := FFMCTCSSOn;
+ Def.CTCSSToneIdx := FFMCTCSSToneIdx;
+ Def.Power := 100;
+ Def.Name := '';
+ TChannelsForm(FChannelsForm).SetCurrentDefaults(Def);
+ TChannelsForm(FChannelsForm).ApplyTheme(DarkTheme);
+ TChannelsForm(FChannelsForm).Show;
+end;
+
+procedure TMainForm.OnChannelsDropDownSelect(Sender: TObject; Idx: Integer);
+var Ch: TChannel;
+begin
+ if (FChannelStore = nil) or (Idx < 0) or (Idx >= FChannelStore.Count) then Exit;
+ Ch := FChannelStore.GetChannel(Idx);
+ FActiveChannelIdx := Idx;
+ StyleButton(BtnChannels, True);
+ BtnChannels.Caption := Ch.Name;
+ BtnChannels.Repaint;
+ ApplyChannel(Ch);
+end;
+
+procedure TMainForm.ApplyChannel(const Ch: TChannel);
+var
+ i, NewBand, XvtrIdx: Integer;
+begin
+ // Сохраняем текущее состояние до любых изменений
+ SaveCurrentBand;
+
+ // Ищем подходящий XVTR-слот по частоте канала
+ XvtrIdx := -1;
+ for i := 0 to CFG_XVTR_COUNT - 1 do
+ if FXvtrSettings.Entries[i].Enabled and
+ (Ch.RXFreq >= FXvtrSettings.Entries[i].FreqBegin) and
+ (Ch.RXFreq <= FXvtrSettings.Entries[i].FreqEnd) then
+ begin
+ XvtrIdx := i;
+ Break;
+ end;
+
+ NewBand := -1;
+
+ if XvtrIdx >= 0 then
+ begin
+ // Канал на трансвертерном диапазоне — активируем XVTR если не активен
+ if FCurrentXvtr <> XvtrIdx then
+ ActivateXvtrBand(XvtrIdx);
+ end
+ else
+ begin
+ // Канал на HF (или неизвестная частота)
+ // Если были на XVTR — выходим из него, иначе ApplyVfoA заклэмпит частоту
+ if FCurrentXvtr >= 0 then
+ DeactivateXvtr;
+ NewBand := FreqToBandIdx(Ch.RXFreq);
+ if (NewBand >= 0) and (NewBand <> FCurrentBand) then
+ begin
+ StyleButton(BtnBand[FCurrentBand], False);
+ FCurrentBand := NewBand;
+ StyleButton(BtnBand[FCurrentBand], True);
+ FDriveLevel := CalcDriveByte;
+ end;
+ end;
+
+ // Режим (после ActivateXvtrBand/DeactivateXvtr FMode обновился — сравниваем снова)
+ if FMode <> Ch.Mode then
+ begin
+ FMode := Ch.Mode;
+ for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FMode);
+ if FWDSPReady then
+ begin
+ FDSPEngine.SetMode(FMode);
+ if FTuning then FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, 1.0);
+ end;
+ UpdateFilterButtons;
+ ApplyModeFilter;
+ SyncSpecViewFreq;
+ end;
+
+ // FM-параметры
+ if FMode = MODE_FM then
+ begin
+ FFMCTCSSOn := Ch.CTCSSOn;
+ FFMCTCSSAutoActive := Ch.CTCSSOn; // CTCSS из канала — авто, сбросится при уходе с частоты
+ SetFMCTCSSTone(Ch.CTCSSToneIdx);
+ if BtnFMCTCSS <> nil then
+ begin
+ StyleButton(BtnFMCTCSS, FFMCTCSSOn);
+ BtnFMCTCSS.Repaint;
+ end;
+ if FWDSPReady then
+ FDSPEngine.SetTXCTCSS(FFMCTCSSOn, CTCSS_TONES[FFMCTCSSToneIdx]);
+ FFMRptDir := Ch.RptDir;
+ FFMRptOffsetHz := Ch.RptOffsetHz;
+ FFMRptAutoActive := Ch.RptDir <> RPT_NONE; // RPT из канала — авто, сбросится при уходе
+ if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, FFMRptDir = RPT_MINUS);
+ if BtnFMRptPlus <> nil then StyleButton(BtnFMRptPlus, FFMRptDir = RPT_PLUS);
+ if EdFMRptOffset <> nil then EdFMRptOffset.Text := RptFormatOffset(FFMRptOffsetHz);
+ end;
+
+ // Частота VFO A
+ ApplyVfoA(Round(Ch.RXFreq));
+
+ // Сохраняем состояние
+ if XvtrIdx >= 0 then
+ SaveCurrentBand // сохраняем в XVTR-слот (CTCSS/RPT/Mode из канала)
+ else if NewBand >= 0 then
+ SaveCurrentBand; // сохраняем в HF band-кэш
+end;
+
+procedure TMainForm.CheckChannelActive;
+var Ch: TChannel;
+begin
+ if FActiveChannelIdx < 0 then Exit;
+ if (FChannelStore = nil) or (FActiveChannelIdx >= FChannelStore.Count) then
+ begin
+ FActiveChannelIdx := -1;
+ Exit;
+ end;
+ Ch := FChannelStore.GetChannel(FActiveChannelIdx);
+ if Round(FVfoA) <> Round(Ch.RXFreq) then
+ begin
+ FActiveChannelIdx := -1;
+ StyleButton(BtnChannels, False);
+ BtnChannels.Caption := 'Channel';
+ BtnChannels.Repaint;
+
+ // Сбрасываем CTCSS если он был включён каналом (не вручную)
+ if FFMCTCSSAutoActive then
+ begin
+ FFMCTCSSAutoActive := False;
+ FFMCTCSSOn := False;
+ if BtnFMCTCSS <> nil then
+ begin
+ StyleButton(BtnFMCTCSS, False);
+ BtnFMCTCSS.Repaint;
+ end;
+ if FWDSPReady then
+ FDSPEngine.SetTXCTCSS(False, CTCSS_TONES[FFMCTCSSToneIdx]);
+ end;
+
+ // Сбрасываем RPT если он был включён каналом (не вручную), затем
+ // даём UpdateRptAutoState переоценить авто-MINUS для текущей частоты
+ if FFMRptAutoActive then
+ begin
+ FFMRptDir := RPT_NONE;
+ FFMRptAutoActive := False;
+ if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, False);
+ if BtnFMRptPlus <> nil then StyleButton(BtnFMRptPlus, False);
+ UpdateRptAutoState;
+ end;
+ end;
+end;
+
procedure TMainForm.SetFMCTCSSTone(Idx: Integer);
begin
FFMCTCSSToneIdx := Idx;
@@ -5341,7 +5608,8 @@ end;
procedure TMainForm.BtnFMCTCSSClick(Sender: TObject);
begin
- FFMCTCSSOn := not FFMCTCSSOn;
+ FFMCTCSSOn := not FFMCTCSSOn;
+ FFMCTCSSAutoActive := False; // ручное действие снимает авто-флаг
StyleButton(BtnFMCTCSS, FFMCTCSSOn);
BtnFMCTCSS.Repaint; // Flush visual state before WDSP call
if FWDSPReady then
@@ -6808,15 +7076,17 @@ begin
if FWDSPReady then FDSPEngine.SetMode(FMode);
UpdateFilterButtons; // показывает/скрывает FM-панели, обновляет SQL UI
ApplyModeFilter;
- FFMCTCSSOn := E.LastCTCSSOn;
+ FFMCTCSSOn := E.LastCTCSSOn;
+ FFMCTCSSAutoActive := E.LastCTCSSAutoActive;
if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, FFMCTCSSOn);
SetFMCTCSSTone(E.LastCTCSSToneIdx);
ApplyFMSquelch;
FFMStepOn := E.LastFMStepOn;
SetFMStep(E.LastFMStepIdx);
if BtnFMStep <> nil then StyleButton(BtnFMStep, FFMStepOn);
- FFMRptDir := E.LastFMRptDir;
- FFMRptOffsetHz := E.LastFMRptOffsetHz;
+ FFMRptDir := E.LastFMRptDir;
+ FFMRptAutoActive := E.LastFMRptAutoActive;
+ FFMRptOffsetHz := E.LastFMRptOffsetHz;
if FFMRptOffsetHz <= 0 then
FFMRptOffsetHz := RptDefaultOffsetHz(E.FreqBegin);
if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, FFMRptDir = RPT_MINUS);
@@ -6871,10 +7141,25 @@ procedure TMainForm.DeactivateXvtr;
var i: Integer;
begin
if FCurrentXvtr < 0 then Exit;
- // Запоминаем LastFreq для bandstack
+ // Сохраняем полное состояние XVTR-слота (раньше сохранялся только LastFreq)
if FCurrentXvtr < CFG_XVTR_COUNT then
begin
- FXvtrSettings.Entries[FCurrentXvtr].LastFreq := FVfoA;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFreq := FVfoA;
+ FXvtrSettings.Entries[FCurrentXvtr].LastMode := FMode;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFilterIdx := FFilter;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMSQOn := FFMSQOn;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMSQLevel := FFMSQLevel;
+ FXvtrSettings.Entries[FCurrentXvtr].LastCTCSSOn := FFMCTCSSOn;
+ FXvtrSettings.Entries[FCurrentXvtr].LastCTCSSToneIdx := FFMCTCSSToneIdx;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMStepOn := FFMStepOn;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMStepIdx := FFMStepIdx;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMRptDir := FFMRptDir;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMRptOffsetHz := FFMRptOffsetHz;
+ FXvtrSettings.Entries[FCurrentXvtr].LastCTun := FCTun;
+ FXvtrSettings.Entries[FCurrentXvtr].LastAGCMode := FAGCMode;
+ FXvtrSettings.Entries[FCurrentXvtr].LastAGCTop := FAGCTop;
+ FXvtrSettings.Entries[FCurrentXvtr].LastCTCSSAutoActive := FFMCTCSSAutoActive;
+ FXvtrSettings.Entries[FCurrentXvtr].LastFMRptAutoActive := FFMRptAutoActive;
if FDevConnected then
begin
FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
diff --git a/Settings.pas b/Settings.pas
index 6a3efa1..33561ae 100644
--- a/Settings.pas
+++ b/Settings.pas
@@ -152,8 +152,10 @@ type
LastCTun: Boolean;
LastAGCMode: Integer;
LastAGCTop: Integer;
- LastFMRptDir: Integer;
- LastFMRptOffsetHz: Double;
+ LastFMRptDir: Integer;
+ LastFMRptOffsetHz: Double;
+ LastCTCSSAutoActive: Boolean; // CTCSS был включён каналом (авто)
+ LastFMRptAutoActive: Boolean; // RPT был включён каналом (авто)
end;
TXvtrSettings = record
@@ -1182,8 +1184,10 @@ begin
X.Entries[i].LastCTun := False;
X.Entries[i].LastAGCMode := 1;
X.Entries[i].LastAGCTop := 90;
- X.Entries[i].LastFMRptDir := 0;
- X.Entries[i].LastFMRptOffsetHz := 0.0;
+ X.Entries[i].LastFMRptDir := 0;
+ X.Entries[i].LastFMRptOffsetHz := 0.0;
+ X.Entries[i].LastCTCSSAutoActive := False;
+ X.Entries[i].LastFMRptAutoActive := False;
end;
// Шаблоны (пользователь должен сам Enable, чтобы трансверторы появились)
X.Entries[0].ButtonText := '2m';
@@ -1240,8 +1244,10 @@ begin
X.Entries[i].LastCTun := JB(EObj, 'last_ctun', X.Entries[i].LastCTun);
X.Entries[i].LastAGCMode := EnsureRange(JI(EObj, 'last_agc_mode', X.Entries[i].LastAGCMode), 0, 4);
X.Entries[i].LastAGCTop := EnsureRange(JI(EObj, 'last_agc_top', X.Entries[i].LastAGCTop), 0, 120);
- X.Entries[i].LastFMRptDir := EnsureRange(JI(EObj, 'last_fmrpt_dir', X.Entries[i].LastFMRptDir), 0, 2);
- X.Entries[i].LastFMRptOffsetHz := JD(EObj, 'last_fmrpt_offset', X.Entries[i].LastFMRptOffsetHz);
+ X.Entries[i].LastFMRptDir := EnsureRange(JI(EObj, 'last_fmrpt_dir', X.Entries[i].LastFMRptDir), 0, 2);
+ X.Entries[i].LastFMRptOffsetHz := JD(EObj, 'last_fmrpt_offset', X.Entries[i].LastFMRptOffsetHz);
+ X.Entries[i].LastCTCSSAutoActive := JB(EObj, 'last_ctcss_auto', X.Entries[i].LastCTCSSAutoActive);
+ X.Entries[i].LastFMRptAutoActive := JB(EObj, 'last_fmrpt_auto', X.Entries[i].LastFMRptAutoActive);
// Валидация: LastFreq должен быть в [FreqBegin..FreqEnd]
if (X.Entries[i].LastFreq < X.Entries[i].FreqBegin) or
(X.Entries[i].LastFreq > X.Entries[i].FreqEnd) then
@@ -1285,6 +1291,8 @@ begin
JW(EObj, 'last_agc_top', X.Entries[i].LastAGCTop);
JW(EObj, 'last_fmrpt_dir', X.Entries[i].LastFMRptDir);
JW(EObj, 'last_fmrpt_offset', X.Entries[i].LastFMRptOffsetHz);
+ JW(EObj, 'last_ctcss_auto', X.Entries[i].LastCTCSSAutoActive);
+ JW(EObj, 'last_fmrpt_auto', X.Entries[i].LastFMRptAutoActive);
end;
end;
diff --git a/ewsdr.lpi b/ewsdr.lpi
index bf57087..1c19b71 100644
--- a/ewsdr.lpi
+++ b/ewsdr.lpi
@@ -150,6 +150,14 @@
+
+
+
+
+
+
+
+