From 1175e42516a52af368324d0a9fd5008886c9818e Mon Sep 17 00:00:00 2001 From: Uladzimir Karpenka Date: Wed, 27 May 2026 15:39:37 +0300 Subject: [PATCH] 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 --- ChannelStore.pas | 272 +++++++++++++++++++++ ChannelsForm.pas | 620 +++++++++++++++++++++++++++++++++++++++++++++++ FMRepeater.pas | 79 ++++++ FlatButton.pas | 4 +- MainForm.pas | 319 ++++++++++++++++++++++-- Settings.pas | 20 +- ewsdr.lpi | 8 + 7 files changed, 1298 insertions(+), 24 deletions(-) create mode 100644 ChannelStore.pas create mode 100644 ChannelsForm.pas create mode 100644 FMRepeater.pas 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 @@ + + + + + + + +